Merge branch 'main' into feature/key-binding

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leslie LeungandCursor committed 2026-05-04 18:35:21 +08:00
commit f5e828c083
13 files changed
+364 -30

No files matched your search

+12 -1
View File
@@ -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<Coordinator>>;
@@ -401,6 +402,16 @@ pub fn set_vocab_enabled(
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn list_vocab_presets() -> Result<VocabPresetStore, String> {
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]
+7 -21
View File
@@ -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<R: tauri::Runtime>(app: &AppHandle<R>, 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<R: tauri::Runtime>(app: &AppHandle<R>) {
fn show_qa_window_no_activate<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) -> 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<R: tauri::Runtime>(window: &tauri::WebviewWindow<R
}
let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) };
let _ = unsafe {
SetWindowPos(
hwnd,
HWND_TOPMOST,
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_SHOWWINDOW | SWP_NOACTIVATE,
)
};
true
}
+50 -1
View File
@@ -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<VocabPresetStore> {
let dir = data_dir()?;
ensure_dir(&dir)?;
read_or_default::<VocabPresetStore>(&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);
}
}
+16
View File
@@ -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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct VocabPresetStore {
pub custom: Vec<VocabPreset>,
pub overrides: Vec<VocabPreset>,
pub disabled_builtin_preset_ids: Vec<String>,
}
fn default_true() -> bool {
true
}
+11
View File
@@ -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',
+11
View File
@@ -164,6 +164,17 @@ export const zhCN = {
tipDisabled: '点击禁用此词条',
tipEnabled: '点击启用此词条',
removeAria: '删除',
presets: {
title: '场景预设',
tip: '可多选后批量启用;支持编辑和新建,已为后续导入导出预留本地结构。',
create: '新建预设',
apply: '启用所选',
save: '保存预设',
edit: '编辑 {{name}}',
newPreset: '新预设',
namePlaceholder: '预设名称',
wordsPlaceholder: '词条(用逗号或换行分隔)',
},
},
style: {
kicker: 'STYLE',
+14
View File
@@ -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<void> {
return invokeOrMock('set_vocab_enabled', { id, enabled }, () => undefined);
}
export function listVocabPresets(): Promise<VocabPresetStore> {
return invokeOrMock('list_vocab_presets', undefined, () => ({
custom: [],
overrides: [],
disabledBuiltinPresetIds: [],
}));
}
export function saveVocabPresets(store: VocabPresetStore): Promise<void> {
return invokeOrMock('save_vocab_presets', { store }, () => undefined);
}
// ── Dictation lifecycle ────────────────────────────────────────────────
export function startDictation(): Promise<void> {
return invokeOrMock('start_dictation', undefined, () => undefined);
+12
View File
@@ -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'
@@ -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": ["公文", "批示", "督办", "政务", "会签", "材料"]
}
]
+46
View File
@@ -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<VocabPreset[]> {
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<string>();
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);
}
+26 -6
View File
@@ -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<boolean> {
const { invoke } = await import('@tauri-apps/api/core');
return invoke<boolean>('plugin:autostart|is_enabled');
}
async function autostartEnable(): Promise<void> {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('plugin:autostart|enable');
}
async function autostartDisable(): Promise<void> {
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<SettingsSectionId>(initialSection);
@@ -266,15 +281,19 @@ function AutostartRow() {
const [error, setError] = useState<string | null>(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);
+137 -1
View File
@@ -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<DictionaryEntry[]>([]);
@@ -14,6 +17,11 @@ export function Vocab() {
const inputRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState<string | null>(null);
const [presets, setPresets] = useState<VocabPreset[]>(DEFAULT_VOCAB_PRESETS);
const [selectedPresetIds, setSelectedPresetIds] = useState<string[]>([]);
const [editingPresetId, setEditingPresetId] = useState<string | null>(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<string, DictionaryEntry[]>();
const addedPhrases = new Set<string>();
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 (
<>
<PageHeader
@@ -95,6 +189,48 @@ export function Vocab() {
}
/>
<Card padding={0}>
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<strong style={{ fontSize: 12 }}>{t('vocab.presets.title')}</strong>
{presets.map(p => (
<button
key={p.id}
onClick={() => togglePreset(p.id)}
style={{
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 999,
padding: '4px 10px',
fontSize: 12,
background: selectedPresetIds.includes(p.id) ? 'var(--ol-blue-soft)' : 'var(--ol-surface-2)',
}}
>
{p.name}
</button>
))}
<Btn size="sm" variant="ghost" onClick={createPreset}>{t('vocab.presets.create')}</Btn>
<Btn size="sm" variant="primary" onClick={applySelectedPresets}>{t('vocab.presets.apply')}</Btn>
</div>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 10 }}>{t('vocab.presets.tip')}</div>
{editingPresetId && (
<div style={{ marginTop: 10, display: 'grid', gap: 8 }}>
<input value={presetNameDraft} onChange={e => setPresetNameDraft(e.target.value)} placeholder={t('vocab.presets.namePlaceholder')} />
<textarea value={presetPhrasesDraft} onChange={e => setPresetPhrasesDraft(e.target.value)} placeholder={t('vocab.presets.wordsPlaceholder')} rows={3} />
<div style={{ display: 'flex', gap: 8 }}>
<Btn size="sm" variant="primary" onClick={() => void savePreset()}>{t('vocab.presets.save')}</Btn>
<Btn size="sm" variant="ghost" onClick={() => setEditingPresetId(null)}>{t('common.cancel')}</Btn>
</div>
</div>
)}
{!editingPresetId && presets.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{presets.map(p => (
<Btn key={`${p.id}-edit`} size="sm" variant="ghost" onClick={() => startEditPreset(p)}>
{t('vocab.presets.edit', { name: p.name })}
</Btn>
))}
</div>
)}
</div>
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)' }}>
<div style={{ display: 'flex', gap: 8 }}>
<input
@@ -0,0 +1,5 @@
declare module '@tauri-apps/plugin-autostart' {
export function enable(): Promise<void>;
export function disable(): Promise<void>;
export function isEnabled(): Promise<boolean>;
}