diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 508000d3..9aaa6976 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -13,7 +13,7 @@ use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVaul use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; use crate::types::{ CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus, - PolishMode, QaHotkeyBinding, UserPreferences, WindowsImeStatus, + PolishMode, QaHotkeyBinding, UserPreferences, VocabPresetStore, WindowsImeStatus, }; type CoordinatorState<'a> = State<'a, Arc>; @@ -356,6 +356,16 @@ pub fn set_vocab_enabled( .map_err(|e| e.to_string()) } +#[tauri::command] +pub fn list_vocab_presets() -> Result { + crate::persistence::list_vocab_presets().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn save_vocab_presets(store: VocabPresetStore) -> Result<(), String> { + crate::persistence::save_vocab_presets(&store).map_err(|e| e.to_string()) +} + // ─────────────────────────── dictation lifecycle ─────────────────────────── #[tauri::command] diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 8bf20894..ecd872a1 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -202,6 +202,8 @@ pub fn run() { commands::add_vocab, commands::remove_vocab, commands::set_vocab_enabled, + commands::list_vocab_presets, + commands::save_vocab_presets, commands::start_dictation, commands::stop_dictation, commands::cancel_dictation, diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index 3d772ace..dd17d7a6 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -22,7 +22,7 @@ use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::types::{DictationSession, DictionaryEntry, UserPreferences}; +use crate::types::{DictationSession, DictionaryEntry, UserPreferences, VocabPresetStore}; const HISTORY_CAP: usize = 200; const HISTORY_FILE: &str = "history.json"; @@ -30,6 +30,7 @@ const PREFERENCES_FILE: &str = "preferences.json"; /// 与 Swift `Sources/OpenLessPersistence/DictionaryStore.swift` 同名, /// 让旧版词汇表在升级后无缝继承。**不要**改成 `vocab.json`,会丢用户数据。 const VOCAB_FILE: &str = "dictionary.json"; +const VOCAB_PRESETS_FILE: &str = "vocab-presets.json"; /// Swift 老 `CredentialsVault` 的 JSON 备用路径。 /// 升级到 Tauri 版后,先尝试 Keychain;Keychain 没有时回落读这个文件, @@ -593,6 +594,20 @@ fn count_occurrences(haystack: &str, needle: &str) -> u64 { count } +pub fn list_vocab_presets() -> Result { + let dir = data_dir()?; + ensure_dir(&dir)?; + read_or_default::(&dir.join(VOCAB_PRESETS_FILE)) +} + +pub fn save_vocab_presets(store: &VocabPresetStore) -> Result<()> { + let dir = data_dir()?; + ensure_dir(&dir)?; + let path = dir.join(VOCAB_PRESETS_FILE); + let json = serde_json::to_vec_pretty(store).context("encode vocab presets failed")?; + atomic_write(&path, &json) +} + // ───────────────────────── CredentialsVault ───────────────────────── #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -719,3 +734,37 @@ impl CredentialsVault { } } } + +#[cfg(test)] +mod tests { + use super::{list_vocab_presets, save_vocab_presets}; + use crate::types::{VocabPreset, VocabPresetStore}; + use std::fs; + use std::path::PathBuf; + + #[test] + fn vocab_presets_roundtrip_json_file() { + let tmp: PathBuf = std::env::temp_dir().join(format!("openless-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&tmp).expect("create temp dir"); + // Linux path helper uses XDG_DATA_HOME first. + unsafe { + std::env::set_var("XDG_DATA_HOME", &tmp); + } + let store = VocabPresetStore { + custom: vec![VocabPreset { + id: "test".into(), + name: "测试".into(), + phrases: vec!["PR".into(), "CI".into()], + }], + overrides: vec![], + disabled_builtin_preset_ids: vec!["chef".into()], + }; + save_vocab_presets(&store).expect("save presets"); + let loaded = list_vocab_presets().expect("list presets"); + assert_eq!(loaded.custom.len(), 1); + assert_eq!(loaded.custom[0].id, "test"); + assert_eq!(loaded.custom[0].phrases, vec!["PR".to_string(), "CI".to_string()]); + assert_eq!(loaded.disabled_builtin_preset_ids, vec!["chef".to_string()]); + let _ = fs::remove_dir_all(&tmp); + } +} diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 50b2f078..f608d44b 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -68,6 +68,22 @@ pub struct DictionaryEntry { pub created_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VocabPreset { + pub id: String, + pub name: String, + pub phrases: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct VocabPresetStore { + pub custom: Vec, + pub overrides: Vec, + pub disabled_builtin_preset_ids: Vec, +} + fn default_true() -> bool { true } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index b4a3804a..dda72c28 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -166,6 +166,17 @@ export const en: typeof zhCN = { tipDisabled: 'Click to disable this entry', tipEnabled: 'Click to enable this entry', removeAria: 'Remove', + presets: { + title: 'Scenario presets', + tip: 'Multi-select to apply in batch; supports edit/create and keeps local preset structure ready for future import/export.', + create: 'New preset', + apply: 'Apply selected', + save: 'Save preset', + edit: 'Edit {{name}}', + newPreset: 'New preset', + namePlaceholder: 'Preset name', + wordsPlaceholder: 'Terms (comma or newline separated)', + }, }, style: { kicker: 'STYLE', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 99380f66..c5bd4220 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -164,6 +164,17 @@ export const zhCN = { tipDisabled: '点击禁用此词条', tipEnabled: '点击启用此词条', removeAria: '删除', + presets: { + title: '场景预设', + tip: '可多选后批量启用;支持编辑和新建,已为后续导入导出预留本地结构。', + create: '新建预设', + apply: '启用所选', + save: '保存预设', + edit: '编辑 {{name}}', + newPreset: '新预设', + namePlaceholder: '预设名称', + wordsPlaceholder: '词条(用逗号或换行分隔)', + }, }, style: { kicker: 'STYLE', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index e49824f8..4ae8cd79 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -13,6 +13,8 @@ import type { QaHotkeyBinding, UserPreferences, WindowsImeStatus, + VocabPreset, + VocabPresetStore, } from './types'; import { OL_DATA } from './mockData'; @@ -200,6 +202,18 @@ export function setVocabEnabled(id: string, enabled: boolean): Promise { return invokeOrMock('set_vocab_enabled', { id, enabled }, () => undefined); } +export function listVocabPresets(): Promise { + return invokeOrMock('list_vocab_presets', undefined, () => ({ + custom: [], + overrides: [], + disabledBuiltinPresetIds: [], + })); +} + +export function saveVocabPresets(store: VocabPresetStore): Promise { + return invokeOrMock('save_vocab_presets', { store }, () => undefined); +} + // ── Dictation lifecycle ──────────────────────────────────────────────── export function startDictation(): Promise { return invokeOrMock('start_dictation', undefined, () => undefined); diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 53b62037..a8577fd1 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -29,6 +29,18 @@ export interface DictionaryEntry { createdAt: string; } +export interface VocabPreset { + id: string; + name: string; + phrases: string[]; +} + +export interface VocabPresetStore { + custom: VocabPreset[]; + overrides: VocabPreset[]; + disabledBuiltinPresetIds: string[]; +} + export type HotkeyTrigger = | 'rightOption' | 'leftOption' diff --git a/openless-all/app/src/lib/vocab-presets.json b/openless-all/app/src/lib/vocab-presets.json new file mode 100644 index 00000000..8351b215 --- /dev/null +++ b/openless-all/app/src/lib/vocab-presets.json @@ -0,0 +1,17 @@ +[ + { + "id": "programmer", + "name": "程序员", + "phrases": ["PR", "CI", "tag", "release", "issue", "Rust", "TypeScript"] + }, + { + "id": "chef", + "name": "厨师", + "phrases": ["出品", "备料", "火候", "刀工", "摆盘", "sous vide"] + }, + { + "id": "civil-servant", + "name": "公务员", + "phrases": ["公文", "批示", "督办", "政务", "会签", "材料"] + } +] diff --git a/openless-all/app/src/lib/vocabPresets.ts b/openless-all/app/src/lib/vocabPresets.ts new file mode 100644 index 00000000..8ffb28aa --- /dev/null +++ b/openless-all/app/src/lib/vocabPresets.ts @@ -0,0 +1,46 @@ +import defaultPresetsJson from './vocab-presets.json'; +import { listVocabPresets, saveVocabPresets } from './ipc'; +import type { VocabPreset, VocabPresetStore } from './types'; + +export const DEFAULT_VOCAB_PRESETS: VocabPreset[] = defaultPresetsJson as VocabPreset[]; + +export async function loadVocabPresets(): Promise { + const store = await listVocabPresets(); + const builtin = new Map(DEFAULT_VOCAB_PRESETS.map(p => [p.id, p] as const)); + for (const id of store.disabledBuiltinPresetIds || []) { + builtin.delete(id); + } + for (const preset of store.overrides || []) { + if (!preset || !preset.id) continue; + if (builtin.has(preset.id)) builtin.set(preset.id, preset); + } + const custom = (store.custom || []).filter(p => p && p.id); + return [...builtin.values(), ...custom]; +} + +export async function persistVocabPresets(presets: VocabPreset[]) { + const builtinMap = new Map(DEFAULT_VOCAB_PRESETS.map(p => [p.id, p] as const)); + const store: VocabPresetStore = { + custom: [], + overrides: [], + disabledBuiltinPresetIds: [], + }; + const seenBuiltin = new Set(); + for (const preset of presets) { + const base = builtinMap.get(preset.id); + if (!base) { + store.custom.push(preset); + continue; + } + seenBuiltin.add(preset.id); + if (JSON.stringify(base) !== JSON.stringify(preset)) { + store.overrides.push(preset); + } + } + for (const id of builtinMap.keys()) { + if (!seenBuiltin.has(id)) { + store.disabledBuiltinPresetIds.push(id); + } + } + await saveVocabPresets(store); +} diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 7ab68f90..9ad3caeb 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -4,7 +4,6 @@ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { disable as disableAutostart, enable as enableAutostart, isEnabled as isAutostartEnabled } from '@tauri-apps/plugin-autostart'; import { Icon } from '../components/Icon'; import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoUpdate'; import { APP_VERSION_LABEL } from '../lib/appVersion'; @@ -15,6 +14,7 @@ import { checkMicrophonePermission, getHotkeyStatus, getWindowsImeStatus, + isTauri, openExternal, openSystemSettings, listProviderModels, @@ -52,6 +52,21 @@ export type SettingsSectionId = 'recording' | 'providers' | 'shortcuts' | 'permi const SECTION_ORDER: SettingsSectionId[] = ['recording', 'providers', 'shortcuts', 'permissions', 'language', 'about']; +async function autostartIsEnabled(): Promise { + const { invoke } = await import('@tauri-apps/api/core'); + return invoke('plugin:autostart|is_enabled'); +} + +async function autostartEnable(): Promise { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('plugin:autostart|enable'); +} + +async function autostartDisable(): Promise { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('plugin:autostart|disable'); +} + export function Settings({ embedded = false, initialSection = 'recording' }: SettingsProps) { const { t } = useTranslation(); const [section, setSection] = useState(initialSection); @@ -269,15 +284,19 @@ function AutostartRow() { const [error, setError] = useState(null); useEffect(() => { + if (!isTauri) { + setLoaded(true); + return; + } let cancelled = false; - isAutostartEnabled() - .then(v => { + autostartIsEnabled() + .then((v: boolean) => { if (!cancelled) { setEnabled(v); setLoaded(true); } }) - .catch(err => { + .catch((err: unknown) => { console.error('[autostart] isEnabled failed', err); if (!cancelled) setLoaded(true); }); @@ -290,8 +309,9 @@ function AutostartRow() { setEnabled(next); setError(null); try { - if (next) await enableAutostart(); - else await disableAutostart(); + if (!isTauri) return; + if (next) await autostartEnable(); + else await autostartDisable(); } catch (err) { console.error('[autostart] toggle failed', err); setEnabled(!next); diff --git a/openless-all/app/src/pages/Vocab.tsx b/openless-all/app/src/pages/Vocab.tsx index 0e53f65f..3df4241a 100644 --- a/openless-all/app/src/pages/Vocab.tsx +++ b/openless-all/app/src/pages/Vocab.tsx @@ -4,9 +4,12 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { addVocab, isTauri, listVocab, removeVocab, setVocabEnabled } from '../lib/ipc'; -import type { DictionaryEntry } from '../lib/types'; +import type { DictionaryEntry, VocabPreset } from '../lib/types'; +import { DEFAULT_VOCAB_PRESETS, loadVocabPresets, persistVocabPresets } from '../lib/vocabPresets'; import { Btn, Card, PageHeader } from './_atoms'; +const NEW_PRESET_DRAFT_ID = '__new__'; + export function Vocab() { const { t } = useTranslation(); const [entries, setEntries] = useState([]); @@ -14,6 +17,11 @@ export function Vocab() { const inputRef = useRef(null); const [error, setError] = useState(null); + const [presets, setPresets] = useState(DEFAULT_VOCAB_PRESETS); + const [selectedPresetIds, setSelectedPresetIds] = useState([]); + const [editingPresetId, setEditingPresetId] = useState(null); + const [presetNameDraft, setPresetNameDraft] = useState(''); + const [presetPhrasesDraft, setPresetPhrasesDraft] = useState(''); const refresh = async () => { try { @@ -30,6 +38,9 @@ export function Vocab() { useEffect(() => { refresh(); + void loadVocabPresets() + .then(setPresets) + .catch(err => setError(err instanceof Error ? err.message : String(err))); // 订阅后端 vocab:updated:每段口述结束、record_hits 触发后由 coordinator 推送。 // Vocab 页面打开期间能即时看到命中数累加,无需切到其他 tab 再切回。 if (!isTauri) return; @@ -82,6 +93,89 @@ export function Vocab() { } }; + const togglePreset = (id: string) => { + setSelectedPresetIds(prev => (prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])); + }; + + const startEditPreset = (preset: VocabPreset) => { + setEditingPresetId(preset.id); + setPresetNameDraft(preset.name); + setPresetPhrasesDraft(preset.phrases.join(', ')); + }; + + const savePreset = async () => { + if (!editingPresetId) return; + const name = presetNameDraft.trim(); + if (!name) return; + const phrases = Array.from( + new Set( + presetPhrasesDraft + .split(/[,\n]/) + .map(s => s.trim()) + .filter(Boolean), + ), + ); + const next = + editingPresetId === NEW_PRESET_DRAFT_ID + ? [...presets, { id: `user-${Date.now()}`, name, phrases }] + : presets.map(p => (p.id === editingPresetId ? { ...p, name, phrases } : p)); + try { + await persistVocabPresets(next); + setPresets(next); + setEditingPresetId(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const createPreset = () => { + setEditingPresetId(NEW_PRESET_DRAFT_ID); + setPresetNameDraft(t('vocab.presets.newPreset')); + setPresetPhrasesDraft(''); + }; + + const applySelectedPresets = async () => { + const selected = presets.filter(p => selectedPresetIds.includes(p.id)); + if (selected.length === 0) return; + const byPhrase = new Map(); + const addedPhrases = new Set(); + for (const entry of entries) { + const key = entry.phrase.trim().toLowerCase(); + if (!byPhrase.has(key)) byPhrase.set(key, []); + byPhrase.get(key)?.push(entry); + } + let failures = 0; + for (const p of selected) { + for (const phrase of p.phrases) { + const key = phrase.trim().toLowerCase(); + if (addedPhrases.has(key)) continue; + const existing = byPhrase.get(key) || []; + if (existing.length === 0) { + try { + await addVocab(phrase); + addedPhrases.add(key); + } catch { + failures += 1; + } + continue; + } + for (const item of existing) { + if (!item.enabled) { + try { + await setVocabEnabled(item.id, true); + } catch { + failures += 1; + } + } + } + } + } + await refresh(); + if (failures > 0) { + setError(`部分词条添加失败(${failures})`); + } + }; + return ( <> +
+
+ {t('vocab.presets.title')} + {presets.map(p => ( + + ))} + {t('vocab.presets.create')} + {t('vocab.presets.apply')} +
+
{t('vocab.presets.tip')}
+ {editingPresetId && ( +
+ setPresetNameDraft(e.target.value)} placeholder={t('vocab.presets.namePlaceholder')} /> +