diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 86a73300..89d0cebe 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -13,7 +13,8 @@ use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVaul use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; use crate::types::{ ComboBinding, CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, - HotkeyStatus, PolishMode, ShortcutBinding, UserPreferences, WindowsImeStatus, + HotkeyStatus, PolishMode, ShortcutBinding, UserPreferences, VocabPresetStore, + WindowsImeStatus, }; type CoordinatorState<'a> = State<'a, Arc>; @@ -401,6 +402,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 826d5ed3..c03e0e4c 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -206,6 +206,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, @@ -666,12 +668,10 @@ pub(crate) fn show_qa_window(app: &AppHandle, content_kind }); } #[cfg(target_os = "windows")] - { - if !show_qa_window_no_activate(&window) { - log::warn!("[qa] show_no_activate failed; falling back to window.show()"); - if let Err(e) = window.show() { - log::warn!("[qa] show fallback failed: {e}"); - } + if !show_qa_window_no_activate(&window) { + log::warn!("[qa] show_no_activate failed; falling back to window.show()"); + if let Err(e) = window.show() { + log::warn!("[qa] show fallback failed: {e}"); } } #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] @@ -724,10 +724,7 @@ pub(crate) fn hide_qa_window(app: &AppHandle) { fn show_qa_window_no_activate(window: &tauri::WebviewWindow) -> bool { use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use windows::Win32::Foundation::HWND; - use windows::Win32::UI::WindowsAndMessaging::{ - SetWindowPos, ShowWindow, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, - SWP_SHOWWINDOW, SW_SHOWNOACTIVATE, - }; + use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOWNOACTIVATE}; let Ok(handle) = window.window_handle() else { return false; @@ -741,17 +738,6 @@ fn show_qa_window_no_activate(window: &tauri::WebviewWindow 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 434e5677..b3e6f6f1 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 07b11254..f2872ce9 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 57b85875..a5d1fe1e 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 603224ba..15ce1000 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -15,6 +15,8 @@ import type { ShortcutBinding, UserPreferences, WindowsImeStatus, + VocabPreset, + VocabPresetStore, } from './types'; import { OL_DATA } from './mockData'; @@ -207,6 +209,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 f2628a55..3a08030e 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 89a5c647..1fc16be5 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 { ShortcutRecorder } from '../components/ShortcutRecorder'; import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoUpdate'; @@ -15,6 +14,7 @@ import { checkMicrophonePermission, getHotkeyStatus, getWindowsImeStatus, + isTauri, openExternal, openSystemSettings, listProviderModels, @@ -57,6 +57,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); @@ -266,15 +281,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); }); @@ -287,8 +306,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')} /> +