From 6dee1b7c13eb1a8fa8fb83df50969a04272ce24e Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 18 Sep 2026 19:51:44 +0800 Subject: [PATCH 1/5] feat: add quick note recording workflow --- memory-graph.md | 64 ++++++ .../components/AndroidPermissionsPanel.tsx | 118 +++++----- .../app/android/frontend/lib/androidTypes.ts | 15 ++ .../kotlin/OpenLessAndroidPreferences.kt | 27 +++ .../app/android/kotlin/OpenLessNative.kt | 2 + .../android/kotlin/OpenLessOverlayService.kt | 65 +++--- .../crates/openless-core/src/android_types.rs | 36 +++ .../app/crates/openless-core/src/api.rs | 206 ++++++++++++++++-- .../openless-core/src/dictation_context.rs | 137 +++++++++++- .../openless-core/src/dictation_engine.rs | 27 +++ .../app/crates/openless-core/src/history.rs | 166 ++++++++++++-- .../app/crates/openless-core/src/lib.rs | 47 ++-- .../app/crates/openless-core/src/ports.rs | 11 + .../app/crates/openless-core/src/settings.rs | 2 + .../crates/openless-core/src/shared_types.rs | 55 ++++- .../openless-core/src/shortcut_types.rs | 40 ++++ .../app/crates/openless-core/src/types.rs | 1 + .../src-tauri/src/android/native_bridge.rs | 33 +++ .../app/src-tauri/src/commands/history.rs | 88 +++++++- .../app/src-tauri/src/commands/hotkeys.rs | 17 ++ openless-all/app/src-tauri/src/coordinator.rs | 23 ++ .../src-tauri/src/coordinator/hotkey_loops.rs | 43 ++++ .../app/src-tauri/src/core_adapters.rs | 92 +++++++- openless-all/app/src-tauri/src/lib.rs | 5 + .../app/src-tauri/src/persistence/paths.rs | 12 + openless-all/app/src-tauri/src/recorder.rs | 36 ++- .../app/src/components/FloatingShell.tsx | 11 +- .../app/src/components/MobileMoreSheet.tsx | 1 + openless-all/app/src/i18n/de.ts | 18 ++ openless-all/app/src/i18n/en.ts | 18 ++ openless-all/app/src/i18n/es.ts | 18 ++ openless-all/app/src/i18n/fr.ts | 18 ++ openless-all/app/src/i18n/ja.ts | 18 ++ openless-all/app/src/i18n/ko.ts | 18 ++ openless-all/app/src/i18n/zh-CN.ts | 18 ++ openless-all/app/src/i18n/zh-TW.ts | 18 ++ .../app/src/lib/history-retranscribe.test.ts | 8 + .../app/src/lib/history-retranscribe.ts | 14 +- openless-all/app/src/lib/ipc/history.ts | 12 + openless-all/app/src/lib/ipc/hotkeys.ts | 7 + openless-all/app/src/lib/ipc/index.ts | 2 + openless-all/app/src/lib/ipc/mock-data.ts | 7 + openless-all/app/src/lib/types.ts | 18 +- openless-all/app/src/pages/History.tsx | 177 +++++++++++++-- openless-all/app/src/pages/QuickNote.tsx | 46 ++++ .../src/pages/settings/ShortcutsSection.tsx | 20 ++ openless-all/app/src/state/useAppState.ts | 1 + 47 files changed, 1628 insertions(+), 208 deletions(-) create mode 100644 memory-graph.md create mode 100644 openless-all/app/src/pages/QuickNote.tsx diff --git a/memory-graph.md b/memory-graph.md new file mode 100644 index 000000000..d0ef06f3f --- /dev/null +++ b/memory-graph.md @@ -0,0 +1,64 @@ +# Memory Graph — openless + +- slug: openless +- path: `F:/编程/openless` +- updated: 2026-09-18 + +## Summary + +OpenLess:Tauri 2 + Rust + WebView 听写/选区助手。上游 Open-Less/openless,维护者克隆 HKLHaoBin/openless。 + +## Entities + +- SelectionVoice (Feature): 选区语音问答/编辑,EditPlan 结构化改写 +- EditPlan (Module): XML/JSON 操作计划,本地确定性 apply +- StylePack (Entity): 含 prompt / selectionPrompt / voiceEditPrompt +- Issue1076 (Issue): EditPlan 解析失败(模型输出正文) +- Issue1046 (Issue): 历史页对已完成转录的录音提供重新转录与试听 +- Issue1081 (Issue): 激活后立即打开麦克风、避免首字丢失,并评估文件式 ASR +- QuickNote (Feature): 速记,统一历史中的独立记录类型;永久保留录音,支持播放、导出、重转写与重润色 +- QuickNoteCapture (Workflow): Android 先开始未定类录音,结束时由普通点击或方向手势决定普通听写/速记 + +## Relations + +- SelectionVoice --uses--> EditPlan: 编辑意图生成方案后替换选区 +- SelectionVoice --reads--> StylePack.voiceEditPrompt: 空则 prefs 自定义,再则默认 XML/JSON prompt +- EditPlan --parses-with-priority--> Xml|Json: 用户选择优先格式,另一种兜底 +- QA Panel --shows--> model_output: 解析失败时展示原始模型输出 +- History --retranscribes--> archived_recording: 有归档 WAV 的传统 ASR 条目可用当前 provider 重转 +- OpenLess --tracks--> Issue1081: 上游 Issue,关联 Core 2.0 的录音启动等待与非流式 ASR 方案 +- QuickNote --belongs-to--> History: 速记必须在统一历史记录中留痕,但拥有独立的音频生命周期 +- QuickNoteCapture --classifies-at-stop--> QuickNote: Android 录音开始时无法预判意图,速记手势在结束时立即生效 +- QuickNote --uses--> AudioArchive: 从录音第一帧开始持久化,转写失败时仍可播放、导出和重转写 + +## Facts + +- 2026-09-15:扫描确认该项目包含 `.cursor`,已纳入本次全局图谱一致性更新。 +- 2026-09-14 开分支 fix/selection-voice-editplan-prompt-format(基于 upstream/beta) +- Issue: https://github.com/Open-Less/openless/issues/1076(完全解决前不提 PR) +- PR: https://github.com/Open-Less/openless/pull/1077(目标 beta,关联并关闭 #1076) +- 根因:听写润色 user framing「只输出正文」与 EditPlan system prompt 冲突 +- folia-major 参考:OUTPUT CONTRACT + 剥围栏/平衡括号候选解析 +- 2026-09-15 基于 upstream/beta 创建 fix/1046-history-retranscription;历史页将重新转录入口从失败状态扩展到所有有归档录音的传统 ASR 条目,继续保留多模态能力边界 +- 2026-09-15 提交 c038676e 并推送至 origin/fix/1046-history-retranscription;review-bugbot 复审结论为无 bug,CI、Android APK 与跨平台桌面发布构建均成功 +- 2026-09-15 已下载 Android 四架构 APK 与 Windows/macOS 桌面产物;APK ZIP、Updater JSON、macOS updater tar.gz 及文件完整性静态校验全部通过,因无连接 ADB 设备未执行真机安装 +- 2026-09-15 全局扫描确认项目根目录已有 `memory-graph.md`,纳入按项目名路由表;当前工作区仍有未提交的 vendor 修改 +- 2026-09-15 向上游 `Open-Less/openless` 的 `beta` 提交 PR #1079;克隆仓库误建的 PR #6 已关闭,源分支仍为 `HKLHaoBin:fix/1046-history-retranscription` +- 2026-09-16 Android 日志诊断:历史录音 WAV 可正常读取(3,519,088 bytes),但同一 Bailian ASR 收尾路径在录音停止后约 12 秒报 `final result timed out`;移动端 History 在详情页打开时只把 `actionError` 渲染在隐藏的列表面板,因而重转失败可能表现为“无报错、无成功”。 +- 2026-09-16 Android 悬浮窗诊断:多次 `cpal` 回调持续收到非零音频并正常释放麦克风,录音链路本身可用;`08:32:30` 的 `start_dictation failed` 直接原因是 Bailian WebSocket 在 5 秒上限内连接超时。`cpal Stream pause before drop failed` 只是停止时的清理警告。悬浮窗红色描边表示录音中,红色底色表示错误态;原生启动调用是 fire-and-forget,启动失败后的错误视觉可能保留。 +- 2026-09-16 克隆仓库 `beta` 快进同步上游 `Open-Less/openless/beta` 至 `9b55ae1a`,其中包含百炼 WebSocket 优先 IPv4 修复 `af9eaa3`;克隆发布准备提交为 `07ce1e9a`。 +- 2026-09-16 克隆仓库发布 `v2.0.0-Beta.2-tauri`(版本 `2.0.0-Beta.2`,GitHub prerelease);Android 与桌面发布工作流均成功,Android 四架构 APK、签名和 Beta 更新清单已上传。发布工作流改用 `${{ github.repository }}` 生成克隆仓库清单 URL;旧版手机首次切换仍需手动覆盖安装 APK。 +- 2026-09-17 启动延迟诊断:Android 与 Windows 共用的 Core 2.0 录音管线在 `fc38edd1` 重构后变为“先等待 `TranscriptionEngine::start`(云 ASR 建立新 WebSocket),再调用 `AudioRecorder::start`”;因此每次点击都会把 DNS/TCP/TLS/WS 建连放在麦克风启动前。旧协调器则先开 Recorder,再用 `DeferredAsrBridge` 缓冲等待 ASR 的音频。当前日志中 Windows `ToggleDictation` 到 `inputDevice` 约 0.3–0.8 秒,`inputDevice` 到首个 cpal 回调约 0.6 秒;Android 首回调约 0.3–0.5 秒。非零 PCM 持续到达,说明麦克风/权限不是主因;`cpal Stream pause before drop failed` 是停止时清理警告。 +- 2026-09-17 已向上游 Open-Less/openless 提交 Issue #1081(https://github.com/Open-Less/openless/issues/1081),描述先开麦、首字保留、当前 partial 不可见及文件式 ASR 备选方案;标签补加因账号缺少上游 `AddLabelsToLabelable` 权限失败。 +- 2026-09-18 讨论 Issue #1029(https://github.com/Open-Less/openless/issues/1029)形成速记方案边界:所有听写仍进入统一历史;速记是独立记录类型而非独立用户可见历史;暂不自动追加 Markdown;用户可选择当前或其他风格包;重润色先预览、应用后更新正文;速记录音永久保留至用户手动删除。 +- 2026-09-18 Android 速记流程确定为“先普通点击开始、结束时普通点击=普通听写,配置方向手势=速记”,因此录音意图在开始时未定,必须从第一帧开始落盘;桌面端使用独立快捷键并复用现有快捷键组件;桌面可指定音频路径,Android 通过分享/文件导出。 + +## Decisions + +- 用户可选 EditPlan 输出优先 XML 或 JSON;解析双向兜底 +- 提示词:设置自定义 > 风格包 voiceEditPrompt > 内置默认 +- 失败错误保留 ---model_output--- 供 QA 面板展示 +- 重新转录按钮不改变录音归档隐私策略:只有 `hasAudioRecording` 为 true 且非多模态条目展示,成功/润色失败/转录失败均可使用 +- 速记音频不受 `recordAudioForDebug` 开关影响;普通听写仍可按原录音保留策略处理,速记的音频清理仅由用户手动删除触发 +- Android 四个方向在设置中作为可配置动作槽;默认行为需兼容现有翻译、QA 收尾和取消录音语义,速记手势在录音结束时分类当前会话 +- 速记不自动追加 Markdown;正文可按用户选择的风格包生成,重润色结果应用后写回速记记录 diff --git a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx index 4613808a7..086f145ee 100644 --- a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx +++ b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx @@ -21,8 +21,7 @@ import type { AndroidAccessibilityStatus, AndroidInsertStrategy, AndroidOverlayActivationMode, - AndroidOverlayCancelSwipeDirection, - AndroidOverlayLeftSwipeAction, + AndroidOverlayGestureAction, AndroidOverlayStatus, AndroidOverlayTrigger, AndroidPreferenceKey, @@ -39,6 +38,7 @@ function pickAndroidPrefs(settings: UserPreferences): AndroidPrefsSlice { androidOverlayActivationMode: settings.androidOverlayActivationMode, androidOverlayLeftSwipeAction: settings.androidOverlayLeftSwipeAction, androidOverlayCancelSwipeDirection: settings.androidOverlayCancelSwipeDirection, + androidOverlayGestureActions: settings.androidOverlayGestureActions, androidOverlaySizeDp: settings.androidOverlaySizeDp, }; } @@ -619,72 +619,64 @@ export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPane - -
- - - {t( - `settings.permissions.androidOverlayLeftSwipeActionHint.${androidPrefs?.androidOverlayLeftSwipeAction ?? 'translation'}`, - )} - -
-
- +
- - - {t( - `settings.permissions.androidOverlayCancelSwipeDirectionHint.${androidPrefs?.androidOverlayCancelSwipeDirection ?? 'up'}`, - )} - + {(['up', 'down', 'left', 'right'] as const).map((direction) => ( + + ))}
diff --git a/openless-all/app/android/frontend/lib/androidTypes.ts b/openless-all/app/android/frontend/lib/androidTypes.ts index 2ff3179ce..60f3641a2 100644 --- a/openless-all/app/android/frontend/lib/androidTypes.ts +++ b/openless-all/app/android/frontend/lib/androidTypes.ts @@ -5,6 +5,20 @@ export type AndroidOverlayTrigger = 'background' | 'keyboard' | 'always'; export type AndroidOverlayActivationMode = 'tap' | 'long_press'; export type AndroidOverlayLeftSwipeAction = 'translation' | 'style_pack'; export type AndroidOverlayCancelSwipeDirection = 'up' | 'down'; +export type AndroidOverlayGestureAction = + | 'none' + | 'quick_note' + | 'translation' + | 'style_pack' + | 'cancel' + | 'qa'; + +export interface AndroidOverlayGestureActions { + up: AndroidOverlayGestureAction; + down: AndroidOverlayGestureAction; + left: AndroidOverlayGestureAction; + right: AndroidOverlayGestureAction; +} export interface AndroidOverlayStatus { permission: 'granted' | 'notGranted' | 'notAndroid'; @@ -64,6 +78,7 @@ export type AndroidPreferenceKey = | 'androidOverlayActivationMode' | 'androidOverlayLeftSwipeAction' | 'androidOverlayCancelSwipeDirection' + | 'androidOverlayGestureActions' | 'androidOverlaySizeDp'; export function normalizeAndroidOverlayTrigger( diff --git a/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt b/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt index 94d654cb7..c897b3304 100644 --- a/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt +++ b/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt @@ -14,6 +14,7 @@ object OpenLessAndroidPreferences { private const val KEY_OVERLAY_ACTIVATION_MODE = "androidOverlayActivationMode" private const val KEY_OVERLAY_LEFT_SWIPE_ACTION = "androidOverlayLeftSwipeAction" private const val KEY_OVERLAY_CANCEL_SWIPE_DIRECTION = "androidOverlayCancelSwipeDirection" + private const val KEY_OVERLAY_GESTURE_ACTIONS = "androidOverlayGestureActions" private const val KEY_OVERLAY_SIZE_DP = "androidOverlaySizeDp" private const val DEFAULT_OVERLAY_SIZE_DP = 72 private const val MIN_OVERLAY_SIZE_DP = 48 @@ -22,6 +23,8 @@ object OpenLessAndroidPreferences { private val VALID_OVERLAY_ACTIVATION_MODES = setOf("tap", "long_press") private val VALID_OVERLAY_LEFT_SWIPE_ACTIONS = setOf("translation", "style_pack") private val VALID_OVERLAY_CANCEL_SWIPE_DIRECTIONS = setOf("up", "down") + private val VALID_OVERLAY_GESTURE_ACTIONS = + setOf("none", "quick_note", "translation", "style_pack", "cancel", "qa") fun overlayTriggerMode(context: Context): String? { val value = readPreferenceString(context, KEY_OVERLAY_TRIGGER) ?: return null @@ -54,6 +57,30 @@ object OpenLessAndroidPreferences { } ?: "up" } + fun overlayGestureAction(context: Context, direction: String): String { + for (file in preferenceFiles(context).distinctBy { it.absolutePath }) { + if (!file.isFile) continue + try { + val actions = JSONObject(file.readText()).optJSONObject(KEY_OVERLAY_GESTURE_ACTIONS) + val value = actions?.optString(direction, "")?.takeIf { + it in VALID_OVERLAY_GESTURE_ACTIONS + } + if (value != null) return value + } catch (error: Throwable) { + Log.w(TAG, "read gesture actions ${file.absolutePath} failed", error) + } + } + val legacyLeft = overlayLeftSwipeAction(context) + val legacyCancel = overlayCancelSwipeDirection(context) + return when (direction) { + "up" -> if (legacyCancel == "up") "cancel" else "none" + "down" -> if (legacyCancel == "down") "cancel" else "none" + "left" -> legacyLeft + "right" -> "qa" + else -> "none" + } + } + fun overlaySizeDp(context: Context): Int { return readPreferenceInt(context, KEY_OVERLAY_SIZE_DP) ?.coerceIn(MIN_OVERLAY_SIZE_DP, MAX_OVERLAY_SIZE_DP) ?: DEFAULT_OVERLAY_SIZE_DP diff --git a/openless-all/app/android/kotlin/OpenLessNative.kt b/openless-all/app/android/kotlin/OpenLessNative.kt index 745de9202..6289aa0c8 100644 --- a/openless-all/app/android/kotlin/OpenLessNative.kt +++ b/openless-all/app/android/kotlin/OpenLessNative.kt @@ -20,6 +20,8 @@ object OpenLessNative { @JvmStatic external fun nativeStopDictationWithTranslation(translation: Boolean) + @JvmStatic external fun nativeStopDictationAsQuickNote() + @JvmStatic external fun nativeCancelDictation() @JvmStatic external fun nativeBackendSnapshot(): String diff --git a/openless-all/app/android/kotlin/OpenLessOverlayService.kt b/openless-all/app/android/kotlin/OpenLessOverlayService.kt index fd2d2d7bc..9a6731ec8 100644 --- a/openless-all/app/android/kotlin/OpenLessOverlayService.kt +++ b/openless-all/app/android/kotlin/OpenLessOverlayService.kt @@ -436,7 +436,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList if ( recording && verticalSwipe != null && - matchesConfiguredCancelSwipe(verticalSwipe) && + gestureAction(verticalSwipe) != "none" && !swipeConsumed ) { pendingSwipe = verticalSwipe @@ -448,6 +448,7 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList if ( (recording || armed || longPressRecording) && swipe != null && + gestureAction(swipe) != "none" && !swipeConsumed ) { pendingSwipe = swipe @@ -574,33 +575,38 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList return if (dy < 0) SwipeDirection.Up else SwipeDirection.Down } - private fun matchesConfiguredCancelSwipe(direction: SwipeDirection): Boolean { - val configured = OpenLessAndroidPreferences.overlayCancelSwipeDirection(this) - return (direction == SwipeDirection.Up && configured == "up") || - (direction == SwipeDirection.Down && configured == "down") + private fun gestureAction(direction: SwipeDirection): String { + return OpenLessAndroidPreferences.overlayGestureAction(this, direction.name.lowercase()) } private fun applySwipePreview(direction: SwipeDirection) { - when (direction) { - SwipeDirection.Left -> applyVisualState(OverlayVisualState.Armed) - SwipeDirection.Right -> applyVisualState(OverlayVisualState.Processing) - SwipeDirection.Up, - SwipeDirection.Down -> applyVisualState(OverlayVisualState.Error) + when (gestureAction(direction)) { + "quick_note", + "translation", + "style_pack" -> applyVisualState(OverlayVisualState.Processing) + "qa" -> applyVisualState(OverlayVisualState.Processing) + "cancel" -> applyVisualState(OverlayVisualState.Error) + else -> Unit } } private fun commitSwipe(direction: SwipeDirection) { Log.i(TAG, "commit swipe direction=$direction recording=$recording processing=$processing") - when (direction) { - SwipeDirection.Left -> handleLeftSwipe() - SwipeDirection.Right -> finalizeQaFromOverlay() - SwipeDirection.Up, - SwipeDirection.Down -> cancelRecordingFromOverlay(direction) + when (gestureAction(direction)) { + "quick_note" -> stopQuickNoteFromOverlay() + "translation" -> stopRecordingFromOverlay(translation = true) + "style_pack" -> { + switchStylePackFromOverlay() + if (recording) stopRecordingFromOverlay() + } + "qa" -> finalizeQaFromOverlay() + "cancel" -> cancelRecordingFromOverlay(direction) + else -> Unit } } private fun cancelRecordingFromOverlay(direction: SwipeDirection) { - if (!recording || !matchesConfiguredCancelSwipe(direction)) { + if (!recording || gestureAction(direction) != "cancel") { return } try { @@ -618,18 +624,6 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } } - private fun handleLeftSwipe() { - when (OpenLessAndroidPreferences.overlayLeftSwipeAction(this)) { - "style_pack" -> { - switchStylePackFromOverlay() - if (recording) { - stopRecordingFromOverlay() - } - } - else -> stopRecordingFromOverlay(translation = true) - } - } - private fun switchStylePackFromOverlay() { try { OpenLessNative.nativeSwitchStylePack() @@ -728,6 +722,21 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList } } + private fun stopQuickNoteFromOverlay() { + try { + recording = false + processing = true + applyVisualState(OverlayVisualState.Processing) + OpenLessNative.nativeStopDictationAsQuickNote() + } catch (error: Throwable) { + Log.w(TAG, "stop quick note bridge unavailable", error) + recording = false + processing = false + applyVisualState(OverlayVisualState.Error) + showToast("语音服务未就绪,请打开 OpenLess 后重试") + } + } + private fun tryPromoteRecordingForeground(): Boolean { if ( checkSelfPermission(Manifest.permission.RECORD_AUDIO) != diff --git a/openless-all/app/crates/openless-core/src/android_types.rs b/openless-all/app/crates/openless-core/src/android_types.rs index 5dbf4eeac..1704ff8d5 100644 --- a/openless-all/app/crates/openless-core/src/android_types.rs +++ b/openless-all/app/crates/openless-core/src/android_types.rs @@ -49,6 +49,38 @@ pub enum AndroidOverlayCancelSwipeDirection { Down, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum AndroidOverlayGestureAction { + #[default] + None, + QuickNote, + Translation, + StylePack, + Cancel, + Qa, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AndroidOverlayGestureActions { + pub up: AndroidOverlayGestureAction, + pub down: AndroidOverlayGestureAction, + pub left: AndroidOverlayGestureAction, + pub right: AndroidOverlayGestureAction, +} + +impl Default for AndroidOverlayGestureActions { + fn default() -> Self { + Self { + up: AndroidOverlayGestureAction::Cancel, + down: AndroidOverlayGestureAction::None, + left: AndroidOverlayGestureAction::Translation, + right: AndroidOverlayGestureAction::Qa, + } + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum AndroidAccessibilityState { @@ -142,6 +174,10 @@ pub fn default_android_overlay_cancel_swipe_direction() -> AndroidOverlayCancelS AndroidOverlayCancelSwipeDirection::Up } +pub fn default_android_overlay_gesture_actions() -> AndroidOverlayGestureActions { + AndroidOverlayGestureActions::default() +} + pub fn default_android_overlay_size_dp() -> u32 { 72 } diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index d1feab8f3..0a945cdff 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -14,8 +14,8 @@ use crate::credentials::{ ProviderSlot, SecretValue, }; use crate::dictation_context::{ - DictationAudioSource, DictationContext, DictationProviderInvocations, DictationStartOptions, - DictationStopOptions, + DictationAudioSource, DictationContext, DictationOutputTarget, DictationProviderInvocations, + DictationStartOptions, DictationStopOptions, }; use crate::domains::{LessComputerRunRequest, LessComputerRunResult}; use crate::errors::{BackendError, BackendErrorCode}; @@ -3215,6 +3215,15 @@ impl OpenLessBackend { } } + pub fn dictation_output_target(&self) -> Option { + self.state + .read() + .expect("backend state lock poisoned") + .dictation_context + .as_ref() + .map(|context| context.output_target) + } + /// Dispatch a launcher/single-instance intent through the same state /// machine and domain Interfaces used by normal host calls. pub async fn dispatch_cli_intent( @@ -4239,6 +4248,18 @@ impl OpenLessBackend { Ok(()) } + pub fn upsert_history( + &self, + session: DictationSession, + retention_days: u32, + max_entries: Option, + ) -> Result<(), BackendError> { + self.history + .upsert_with_retention(session, retention_days, max_entries)?; + self.publish_history_changed(); + Ok(()) + } + pub fn delete_history(&self, id: &str) -> Result<(), BackendError> { self.history.delete(id)?; self.publish_history_changed(); @@ -4253,6 +4274,45 @@ impl OpenLessBackend { Ok(updated) } + pub fn apply_history_repolish( + &self, + session_id: &str, + text: String, + style_pack_id: Option, + ) -> Result { + if text.trim().is_empty() { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "repolish text is empty", + )); + } + let mut entry = self + .list_history()? + .into_iter() + .find(|entry| entry.id == session_id) + .ok_or_else(|| { + BackendError::new(BackendErrorCode::InvalidArgument, "history entry not found") + })?; + if entry.source != HistorySource::QuickNote { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "only quick-note history can apply repolish results", + )); + } + entry.final_text = text; + entry.error_code = None; + if style_pack_id.is_some() { + entry.style_pack_id = style_pack_id; + } + if !self.update_history_entry(entry.clone())? { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "history entry not found", + )); + } + Ok(entry) + } + pub fn apply_history_retranscription( &self, session_id: &str, @@ -4276,10 +4336,13 @@ impl OpenLessBackend { .ok_or_else(|| { BackendError::new(BackendErrorCode::InvalidArgument, "history entry not found") })?; - if !matches!( - entry.error_code.as_deref(), - Some("transcribeFailed" | "emptyTranscript") - ) { + if entry.source != HistorySource::QuickNote + && entry.error_code.as_deref() != Some("recording") + && !matches!( + entry.error_code.as_deref(), + Some("transcribeFailed" | "emptyTranscript") + ) + { return Err(BackendError::new( BackendErrorCode::InvalidState, "history entry is not a failed transcription", @@ -4925,6 +4988,9 @@ impl OpenLessBackend { "dictation session was cancelled while the engine was starting", )); } + if context.output_target != DictationOutputTarget::ForegroundApp { + self.persist_recording_started(&context, session_id); + } Ok(session_id) } @@ -4986,17 +5052,27 @@ impl OpenLessBackend { "active dictation session has no captured context", ) })?; + let target = match options.quick_note { + Some(true) => DictationOutputTarget::QuickNote, + Some(false) => DictationOutputTarget::ForegroundApp, + None if captured.output_target == DictationOutputTarget::Undecided => { + DictationOutputTarget::ForegroundApp + } + None => captured.output_target, + }; + let targeted = captured.with_output_target(target); let context = match options.translation_requested { Some(requested) => { - Arc::new(captured.with_translation_requested(requested)) + Arc::new(targeted.with_translation_requested(requested)) } - None => captured, + None => Arc::new(targeted), }; let context_changed = state.dictation_translation_requested.take().is_some() || state.dictation_context.as_ref().is_some_and(|previous| { previous.polish.translation_active != context.polish.translation_active + || previous.output_target != context.output_target }); state.dictation_context = Some(Arc::clone(&context)); state.dictation.translation_active = context.polish.translation_active; @@ -5022,6 +5098,14 @@ impl OpenLessBackend { changed.await; }; + // Android's undecided capture prepared a non-streaming insertion + // resource so an ordinary tap can still finish normally. A quick-note + // gesture changes the target at the stop boundary, so cancel that + // unused foreground insertion before the engine finalizes. + if context.output_target == DictationOutputTarget::QuickNote { + let _ = self.cancel_text_insertion(session_id).await; + } + if context_changed { if let Err(error) = self .deps @@ -5303,6 +5387,65 @@ impl OpenLessBackend { Ok(result) } + fn history_source_for_context(context: &DictationContext) -> HistorySource { + if context.output_target == DictationOutputTarget::QuickNote { + HistorySource::QuickNote + } else { + HistorySource::Voice + } + } + + fn persist_recording_started(&self, context: &DictationContext, session_id: SessionId) { + let preferences = self.get_preferences(); + let front_app = + crate::shared_types::split_front_app_opt(context.polish.front_app.as_deref()); + let pipeline_mode = match context.pipeline_mode { + crate::shared_types::PipelineMode::Traditional => "traditional", + crate::shared_types::PipelineMode::Multimodal => "multimodal", + }; + let session = DictationSession { + id: session_id.to_string(), + created_at: self.clock.now_utc().to_rfc3339(), + source: Self::history_source_for_context(context), + raw_transcript: String::new(), + asr_transcript: None, + final_text: String::new(), + mode: context.polish.mode, + style_pack_id: Some(context.polish.style_pack_id.clone()), + translation_active: context.polish.translation_active, + polish_source: None, + app_bundle_id: front_app.bundle_id, + app_name: front_app.name, + insert_status: HistoryInsertStatus::NotRequested, + error_code: Some("recording".to_string()), + duration_ms: None, + dictionary_entry_count: None, + has_audio_recording: Some(context.recording.archive_enabled), + asr_provider: Some(context.asr.provider_id.clone()), + asr_model: context.asr.model.clone(), + llm_provider: None, + llm_model: None, + pipeline_mode: Some(pipeline_mode.to_string()), + asr_ms: None, + polish_ms: None, + }; + if let Err(error) = self.upsert_history( + session, + preferences.history_retention_days, + preferences.history_max_entries, + ) { + log::warn!("failed to persist recording-start history: {error}"); + } + } + + fn history_created_at(&self, session_id: &str) -> String { + self.list_history() + .ok() + .and_then(|entries| entries.into_iter().find(|entry| entry.id == session_id)) + .map(|entry| entry.created_at) + .unwrap_or_else(|| self.clock.now_utc().to_rfc3339()) + } + fn persist_completed_dictation( &self, context: &DictationContext, @@ -5341,8 +5484,8 @@ impl OpenLessBackend { ); let session = DictationSession { id: result.session_id.to_string(), - created_at: self.clock.now_utc().to_rfc3339(), - source: HistorySource::Voice, + created_at: self.history_created_at(&result.session_id.to_string()), + source: Self::history_source_for_context(context), raw_transcript: result.raw_text.clone(), asr_transcript: engine_result.asr_transcript.clone(), final_text: result.polished_text.clone(), @@ -5367,7 +5510,7 @@ impl OpenLessBackend { asr_ms: attribution.asr_ms, polish_ms: attribution.polish_ms, }; - if let Err(error) = self.append_history( + if let Err(error) = self.upsert_history( session, preferences.history_retention_days, preferences.history_max_entries, @@ -5417,8 +5560,8 @@ impl OpenLessBackend { ); let session = DictationSession { id: session_id.to_string(), - created_at: self.clock.now_utc().to_rfc3339(), - source: HistorySource::Voice, + created_at: self.history_created_at(&session_id.to_string()), + source: Self::history_source_for_context(context), raw_transcript: raw_text.clone(), asr_transcript: Some(raw_text), final_text, @@ -5441,7 +5584,7 @@ impl OpenLessBackend { asr_ms: attribution.asr_ms, polish_ms: attribution.polish_ms, }; - if let Err(error) = self.append_history( + if let Err(error) = self.upsert_history( session, preferences.history_retention_days, preferences.history_max_entries, @@ -5623,7 +5766,7 @@ impl OpenLessBackend { &self, session_id: Option, ) -> Result<(), BackendError> { - let active = { + let (active, preserve_quick_note) = { let mut state = self.state.write().expect("backend state lock poisoned"); ensure_running(&state)?; let active = state.dictation.session_id.ok_or_else(|| { @@ -5638,6 +5781,12 @@ impl OpenLessBackend { "session id does not match the active session", )); } + let preserve_quick_note = state + .dictation_context + .as_ref() + .is_some_and(|context| { + context.output_target == DictationOutputTarget::QuickNote + }); state.dictation.phase = DictationPhase::Cancelled; self.events.publish( Some(active), @@ -5648,7 +5797,7 @@ impl OpenLessBackend { state.silence_monitor = None; state.transcripts.remove(&active); self.phase_changed.notify_waiters(); - active + (active, preserve_quick_note) }; let cancel_result = self.cancel_session_adapters(active).await; // The state can already display cancellation, but native audio/input @@ -5656,6 +5805,21 @@ impl OpenLessBackend { // cleanup finishes, including on its error path. self.voice_sessions.release(active); let host_result = self.hide_dictation_feedback(active); + if preserve_quick_note { + if let Some(mut entry) = self + .list_history()? + .into_iter() + .find(|entry| entry.id == active.to_string()) + { + entry.error_code = Some("cancelled".to_string()); + entry.has_audio_recording = Some(true); + let _ = self.update_history_entry(entry); + } + } else { + // Undecided captures that are explicitly cancelled are not notes; + // remove their provisional row after native archive cleanup. + let _ = self.delete_history(&active.to_string()); + } cancel_result?; host_result?; Ok(()) @@ -8687,7 +8851,10 @@ mod tests { ) .unwrap(); let first = backend.start().await.expect("first start must not fail"); - let second = backend.start().await.expect("handshake start must not fail"); + let second = backend + .start() + .await + .expect("handshake start must not fail"); assert!(first.backend.running); assert!(second.backend.running); let _ = data_dir; @@ -9193,6 +9360,7 @@ mod tests { backend .stop_dictation_with_options(DictationStopOptions { translation_requested: Some(true), + ..DictationStopOptions::default() }) .await .unwrap(); @@ -9268,6 +9436,7 @@ mod tests { backend .stop_dictation_with_options(DictationStopOptions { translation_requested: Some(false), + ..DictationStopOptions::default() }) .await .unwrap(); @@ -9463,6 +9632,7 @@ mod tests { let error = backend .stop_dictation_with_options(DictationStopOptions { translation_requested: Some(true), + ..DictationStopOptions::default() }) .await .expect_err("context update failure must abort finalization"); @@ -10314,6 +10484,7 @@ mod tests { let error = backend .stop_dictation_with_options(DictationStopOptions { translation_requested: Some(true), + ..DictationStopOptions::default() }) .await .expect_err("translation must retain the unavailable LLM snapshot"); @@ -12108,6 +12279,7 @@ mod tests { start: DictationStartOptions::default(), stop: DictationStopOptions { translation_requested: Some(true), + ..DictationStopOptions::default() }, }, ) diff --git a/openless-all/app/crates/openless-core/src/dictation_context.rs b/openless-all/app/crates/openless-core/src/dictation_context.rs index 6ea37661e..0d7e627e0 100644 --- a/openless-all/app/crates/openless-core/src/dictation_context.rs +++ b/openless-all/app/crates/openless-core/src/dictation_context.rs @@ -20,11 +20,25 @@ pub enum DictationAudioSource { External, } +/// Output intent for the current capture. +/// +/// Android starts a capture before the user has decided whether it is an +/// ordinary dictation or a quick note. The terminal gesture resolves the +/// `Undecided` value without losing the already captured audio. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum DictationOutputTarget { + #[default] + ForegroundApp, + QuickNote, + Undecided, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct DictationStartOptions { pub translation_requested: bool, pub audio_source: DictationAudioSource, pub insert_text: bool, + pub output_target: DictationOutputTarget, pub style_pack_id: Option, pub front_app: Option, pub cursor_context: Option, @@ -36,6 +50,7 @@ impl Default for DictationStartOptions { translation_requested: false, audio_source: DictationAudioSource::Microphone, insert_text: true, + output_target: DictationOutputTarget::ForegroundApp, style_pack_id: None, front_app: None, cursor_context: None, @@ -52,6 +67,11 @@ impl Default for DictationStartOptions { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct DictationStopOptions { pub translation_requested: Option, + /// `Some(true)` finishes the active capture as a quick note; + /// `Some(false)` explicitly finishes it as ordinary dictation. + /// `None` preserves the current target, resolving Android's undecided + /// capture to ordinary dictation. + pub quick_note: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -138,6 +158,9 @@ pub struct RecordingPlan { /// keep PCM in memory; successful-recording retention is a separate policy. pub archive_enabled: bool, pub archive_successful_recording: bool, + /// A quick-note or undecided Android capture must not silently continue + /// without a disk archive when its path cannot be created. + pub archive_required: bool, pub retention_days: u32, pub max_entries: Option, pub silence_after_ms: Option, @@ -146,6 +169,8 @@ pub struct RecordingPlan { #[derive(Debug, Clone, PartialEq, Eq)] pub struct DictationContext { pub audio_source: DictationAudioSource, + pub output_target: DictationOutputTarget, + pub(crate) normal_archive_successful_recording: bool, pub recording: RecordingPlan, pub pipeline_mode: PipelineMode, pub correction_rules: Vec, @@ -244,11 +269,20 @@ impl DictationContext { eligible_polish_context_turns(recent_history, &style_pack.id, translation_active); Self { audio_source: options.audio_source, + output_target: options.output_target, + normal_archive_successful_recording: preferences.record_audio_for_debug, recording: RecordingPlan { microphone_device_name: non_blank(&preferences.microphone_device_name), mute_during_recording: preferences.mute_during_recording, archive_enabled: true, - archive_successful_recording: preferences.record_audio_for_debug, + archive_successful_recording: matches!( + options.output_target, + DictationOutputTarget::QuickNote | DictationOutputTarget::Undecided + ) || preferences.record_audio_for_debug, + archive_required: matches!( + options.output_target, + DictationOutputTarget::QuickNote | DictationOutputTarget::Undecided + ), retention_days: preferences.history_retention_days, // Recordings and transcript history have independent caps in // the UI; only the age limit is shared with history. @@ -284,9 +318,15 @@ impl DictationContext { prior_turns, }, insertion: DictationInsertionContext { - enabled: options.insert_text, + enabled: options.insert_text + && !matches!(options.output_target, DictationOutputTarget::QuickNote), observe_edits: preferences.cursor_context_enabled, - streaming: preferences.streaming_insert, + // An undecided Android capture must not stream text into the + // foreground app before the terminal gesture classifies it. + streaming: !matches!( + options.output_target, + DictationOutputTarget::Undecided | DictationOutputTarget::QuickNote + ) && preferences.streaming_insert, save_streamed_text_to_clipboard: preferences.streaming_insert_save_clipboard, restore_clipboard_after_paste: preferences.restore_clipboard_after_paste, paste_shortcut: preferences.paste_shortcut, @@ -302,6 +342,39 @@ impl DictationContext { } } + pub fn with_output_target(&self, target: DictationOutputTarget) -> Self { + let mut next = self.clone(); + next.output_target = target; + match target { + DictationOutputTarget::QuickNote => { + next.insertion.enabled = false; + next.insertion.streaming = false; + next.recording.archive_successful_recording = true; + next.recording.archive_required = true; + } + DictationOutputTarget::ForegroundApp => { + if self.output_target == DictationOutputTarget::Undecided { + next.insertion.enabled = true; + next.insertion.streaming = false; + } + next.recording.archive_successful_recording = + next.normal_archive_successful_recording(); + next.recording.archive_required = false; + } + DictationOutputTarget::Undecided => { + next.insertion.enabled = true; + next.insertion.streaming = false; + next.recording.archive_successful_recording = true; + next.recording.archive_required = true; + } + } + next + } + + fn normal_archive_successful_recording(&self) -> bool { + self.normal_archive_successful_recording + } + pub fn effective_polish_prompts(&self, raw_text: &str) -> (String, String) { let style_system_prompt = if self.polish.translation_active { crate::prompt_compose::build_polish_translate_system_prompt( @@ -536,4 +609,62 @@ mod tests { assert_eq!(prompt, "OpenLess."); assert!(prompt.chars().count() <= ASR_PROMPT_CHAR_BUDGET); } + + #[test] + fn quick_note_target_forces_permanent_archive_and_skips_insertion() { + let preferences = UserPreferences::default(); + let pack = builtin_style_pack_for_mode(preferences.default_mode); + let context = DictationContext::capture( + &preferences, + &pack, + DictationProviderInvocations::new( + ProviderInvocation::for_provider(preferences.active_asr_provider.clone()), + ProviderInvocation::for_provider(preferences.active_llm_provider.clone()), + ProviderInvocation::for_provider("omni"), + ), + Vec::new(), + Vec::new(), + &DictationStartOptions { + insert_text: false, + output_target: DictationOutputTarget::QuickNote, + ..DictationStartOptions::default() + }, + ); + + assert_eq!(context.output_target, DictationOutputTarget::QuickNote); + assert!(context.recording.archive_required); + assert!(context.recording.archive_successful_recording); + assert!(!context.insertion.enabled); + assert!(!context.insertion.streaming); + } + + #[test] + fn undecided_capture_can_resolve_to_quick_note_at_stop() { + let preferences = UserPreferences::default(); + let pack = builtin_style_pack_for_mode(preferences.default_mode); + let context = DictationContext::capture( + &preferences, + &pack, + DictationProviderInvocations::new( + ProviderInvocation::for_provider(preferences.active_asr_provider.clone()), + ProviderInvocation::for_provider(preferences.active_llm_provider.clone()), + ProviderInvocation::for_provider("omni"), + ), + Vec::new(), + Vec::new(), + &DictationStartOptions { + output_target: DictationOutputTarget::Undecided, + ..DictationStartOptions::default() + }, + ); + let quick_note = context.with_output_target(DictationOutputTarget::QuickNote); + let ordinary = context.with_output_target(DictationOutputTarget::ForegroundApp); + + assert!(context.recording.archive_required); + assert!(!context.insertion.streaming); + assert!(!quick_note.insertion.enabled); + assert!(quick_note.recording.archive_successful_recording); + assert!(ordinary.insertion.enabled); + assert_eq!(ordinary.output_target, DictationOutputTarget::ForegroundApp); + } } diff --git a/openless-all/app/crates/openless-core/src/dictation_engine.rs b/openless-all/app/crates/openless-core/src/dictation_engine.rs index 1df40a722..09cd11482 100644 --- a/openless-all/app/crates/openless-core/src/dictation_engine.rs +++ b/openless-all/app/crates/openless-core/src/dictation_engine.rs @@ -356,6 +356,24 @@ impl DictationEngine for PipelineDictationEngine { failure.has_audio_recording = has_audio_recording; return Err(failure); } + if context.output_target == crate::dictation_context::DictationOutputTarget::QuickNote + { + if let Some(archive) = archive.as_ref() { + if let Err(error) = archive.promote_to_quick_note().await { + log::error!( + "[quick-note] failed to move the archive into permanent storage: {error}" + ); + } + } + } else if context.recording.archive_successful_recording { + if let Some(archive) = archive.as_ref() { + if let Err(error) = archive.demote_to_ordinary_recording().await { + log::warn!( + "[recording] failed to move retained debug archive to ordinary storage: {error}" + ); + } + } + } if session.cancelled.load(Ordering::Acquire) { let _ = cancel_transcription_once(&session, transcription).await; remove_session(&sessions, session_id, &session); @@ -632,6 +650,9 @@ impl DictationEngine for PipelineDictationEngine { if session.cancelled.swap(true, Ordering::AcqRel) { return Ok(()); } + let preserve_quick_note_archive = + session.context().output_target + == crate::dictation_context::DictationOutputTarget::QuickNote; let (recording, transcription) = { let mut resources = session @@ -642,7 +663,13 @@ impl DictationEngine for PipelineDictationEngine { }; let mut first_error = None; if let Some(recording) = recording { + let archive = recording.archive(); retain_first_error(&mut first_error, recording.stop().await); + if !preserve_quick_note_archive { + if let Some(archive) = archive { + retain_first_error(&mut first_error, archive.discard().await); + } + } } if let Some(transcription) = transcription { retain_first_error( diff --git a/openless-all/app/crates/openless-core/src/history.rs b/openless-all/app/crates/openless-core/src/history.rs index c72218037..e7abc7d59 100644 --- a/openless-all/app/crates/openless-core/src/history.rs +++ b/openless-all/app/crates/openless-core/src/history.rs @@ -5,7 +5,7 @@ use std::sync::Mutex; use crate::errors::{BackendError, BackendErrorCode}; use crate::persistence::{atomic_write, persistence_error, read_or_default}; -use crate::types::DictationSession; +use crate::types::{DictationSession, HistorySource}; pub const HISTORY_CAP: usize = 200; @@ -40,21 +40,46 @@ impl HistoryStore { let _guard = self.lock_store()?; let mut sessions = self.read_locked()?; sessions.insert(0, session); - if retention_days > 0 { - let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days)); - sessions.retain(|session| { - chrono::DateTime::parse_from_rfc3339(&session.created_at) - .map(|time| time.with_timezone(&chrono::Utc) >= cutoff) - .unwrap_or(true) - }); + retain_with_policy(&mut sessions, retention_days, max_entries); + self.write_locked(&sessions) + } + + /// Replace an in-progress record when its terminal result arrives, or + /// append it when no provisional record exists (legacy/recovery path). + pub fn upsert_with_retention( + &self, + session: DictationSession, + retention_days: u32, + max_entries: Option, + ) -> Result<(), BackendError> { + let _guard = self.lock_store()?; + let mut sessions = self.read_locked()?; + if let Some(existing) = sessions.iter_mut().find(|item| item.id == session.id) { + let mut replacement = session; + if replacement.has_audio_recording.is_none() { + replacement.has_audio_recording = existing.has_audio_recording; + } + *existing = replacement; + } else { + sessions.insert(0, session); } - let cap = max_entries - .map(|count| (count as usize).clamp(5, HISTORY_CAP)) - .unwrap_or(HISTORY_CAP); - sessions.truncate(cap); + retain_with_policy(&mut sessions, retention_days, max_entries); self.write_locked(&sessions) } + pub fn contains(&self, id: &str) -> Result { + let _guard = self.lock_store()?; + Ok(self.read_locked()?.iter().any(|session| session.id == id)) + } + + pub fn read_entry(&self, id: &str) -> Result, BackendError> { + let _guard = self.lock_store()?; + Ok(self + .read_locked()? + .into_iter() + .find(|session| session.id == id)) + } + pub fn recent_within_minutes( &self, minutes: u32, @@ -99,7 +124,12 @@ impl HistoryStore { pub fn clear(&self) -> Result<(), BackendError> { let _guard = self.lock_store()?; - self.write_locked(&[]) + let quick_notes = self + .read_locked()? + .into_iter() + .filter(|session| session.source == HistorySource::QuickNote) + .collect::>(); + self.write_locked(&quick_notes) } fn lock_store(&self) -> Result, BackendError> { @@ -119,6 +149,41 @@ impl HistoryStore { } } +fn retain_with_policy( + sessions: &mut Vec, + retention_days: u32, + max_entries: Option, +) { + // Quick notes are intentionally outside the ordinary history retention + // policy. A recording-start draft is also protected until it reaches a + // terminal state, because its audio may be the only recoverable artifact + // after a process crash. + if retention_days > 0 { + let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days)); + sessions.retain(|session| { + session.source == HistorySource::QuickNote + || session.error_code.as_deref() == Some("recording") + || chrono::DateTime::parse_from_rfc3339(&session.created_at) + .map(|time| time.with_timezone(&chrono::Utc) >= cutoff) + .unwrap_or(true) + }); + } + let cap = max_entries + .map(|count| (count as usize).clamp(5, HISTORY_CAP)) + .unwrap_or(HISTORY_CAP); + let mut ordinary_seen = 0usize; + sessions.retain(|session| { + if session.source == HistorySource::QuickNote + || session.error_code.as_deref() == Some("recording") + { + true + } else { + ordinary_seen += 1; + ordinary_seen <= cap + } + }); +} + #[cfg(test)] mod tests { use super::*; @@ -200,4 +265,79 @@ mod tests { assert!(store.recent_within_minutes(0).unwrap().is_empty()); let _ = std::fs::remove_file(path); } + + #[test] + fn quick_notes_survive_ordinary_history_cap() { + let path = std::env::temp_dir().join(format!( + "openless-core-quick-note-retention-{}.json", + uuid::Uuid::new_v4().simple() + )); + let store = HistoryStore::at_path(path.clone()); + let mut note = session("quick", chrono::Utc::now().to_rfc3339()); + note.source = HistorySource::QuickNote; + store.append_with_retention(note, 1, Some(5)).unwrap(); + for index in 0..8 { + store + .append_with_retention( + session( + &format!("ordinary-{index}"), + chrono::Utc::now().to_rfc3339(), + ), + 1, + Some(5), + ) + .unwrap(); + } + let sessions = store.list().unwrap(); + assert!(sessions.iter().any(|entry| entry.id == "quick")); + assert_eq!( + sessions + .iter() + .filter(|entry| entry.source == HistorySource::Voice) + .count(), + 5 + ); + let _ = std::fs::remove_file(path); + } + + #[test] + fn clearing_ordinary_history_preserves_quick_notes() { + let path = std::env::temp_dir().join(format!( + "openless-core-quick-note-clear-{}.json", + uuid::Uuid::new_v4().simple() + )); + let store = HistoryStore::at_path(path.clone()); + store + .append_with_retention( + session("ordinary", chrono::Utc::now().to_rfc3339()), + 0, + None, + ) + .unwrap(); + let mut note = session("quick", chrono::Utc::now().to_rfc3339()); + note.source = HistorySource::QuickNote; + store.append_with_retention(note, 0, None).unwrap(); + store.clear().unwrap(); + let entries = store.list().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].id, "quick"); + let _ = std::fs::remove_file(path); + } + + #[test] + fn upsert_preserves_an_existing_archived_audio_flag_when_update_omits_it() { + let path = std::env::temp_dir().join(format!( + "openless-core-quick-note-audio-flag-{}.json", + uuid::Uuid::new_v4().simple() + )); + let store = HistoryStore::at_path(path.clone()); + let mut original = session("audio", chrono::Utc::now().to_rfc3339()); + original.has_audio_recording = Some(true); + store.append_with_retention(original, 0, None).unwrap(); + let mut replacement = session("audio", chrono::Utc::now().to_rfc3339()); + replacement.has_audio_recording = None; + store.upsert_with_retention(replacement, 0, None).unwrap(); + assert_eq!(store.list().unwrap()[0].has_audio_recording, Some(true)); + let _ = std::fs::remove_file(path); + } } diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 4d0d4bd69..d93ac1560 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -180,27 +180,27 @@ pub mod contract { BackendSnapshot, CliDispatchOutcome, CliIntent, Clock, CorrectionRule, CredentialKey, CredentialMetadata, CredentialNamespace, CredentialStore, CredentialsStatus, DictationContext, DictationEngine, DictationHotkeyDispatchOptions, DictationHotkeyEdge, - DictationInsertStatus, DictationPhase, DictationResult, DictationSession, - DictationStartOptions, DictationStateSnapshot, DictionaryEntry, DirectoryResourceResolver, - DownloadProgress, EngineFailure, EngineFailureStage, EngineProgress, EngineProgressSink, - EngineResult, EngineStage, EventRecvError, EventSubscription, HistoryChange, - HistoryInsertStatus, HistorySource, HostAction, HostActions, HostContextAdapter, - HostContextCapture, HotkeyRuntimeTarget, HotkeyStatus, InMemoryCredentialStore, - InsertFallbackPayload, InsertOutcome, LessComputerEvent, LessComputerEventKind, - LessComputerHotkeyAction, LessComputerVoiceSession, LocalAsrMirror, LocalAsrModelId, - LocalAsrRuntime, LocalAsrTarget, NotificationLevel, NotificationPayload, OpenLessBackend, - PendingCorrection, PermissionSnapshot, PermissionState, PlatformCapabilities, PolishDelta, - PolishFailurePolicy, PolishMode, PolishOutput, ProviderService, QaVoiceCaptureResult, - QaVoiceCaptureSession, RecordingArchive, RecordingControlAction, RecordingControlRequest, - RecordingControlSink, RecordingEvent, RecordingPlan, RecordingProgressSink, - ResourceResolver, RuleSource, SecretValue, SelectionPolishOutputMode, - SelectionVoiceIntentMode, SelectionVoiceManualIntent, SessionId, SettingsCollisionPolicy, - SettingsEffectFailure, SettingsEffectKind, SettingsEffectPlan, SettingsEffectReceipt, - SettingsRuntime, SettingsUpdateOptions, SettingsUpdateOutcome, SettingsValueChange, - StartupSnapshot, StylePack, StylePackChange, StylePackExample, StylePackKind, TaskSpawner, - TextInserter, TextPolisher, TextStreamChunk, TextStreamSink, TokioTaskSpawner, - TranscriptAccumulator, TranscriptDelta, TranscriptOutput, TranscriptionEngine, - TranscriptionSession, VocabPreset, VocabPresetStore, VocabularyChange, + DictationInsertStatus, DictationOutputTarget, DictationPhase, DictationResult, + DictationSession, DictationStartOptions, DictationStateSnapshot, DictionaryEntry, + DirectoryResourceResolver, DownloadProgress, EngineFailure, EngineFailureStage, + EngineProgress, EngineProgressSink, EngineResult, EngineStage, EventRecvError, + EventSubscription, HistoryChange, HistoryInsertStatus, HistorySource, HostAction, + HostActions, HostContextAdapter, HostContextCapture, HotkeyRuntimeTarget, HotkeyStatus, + InMemoryCredentialStore, InsertFallbackPayload, InsertOutcome, LessComputerEvent, + LessComputerEventKind, LessComputerHotkeyAction, LessComputerVoiceSession, LocalAsrMirror, + LocalAsrModelId, LocalAsrRuntime, LocalAsrTarget, NotificationLevel, NotificationPayload, + OpenLessBackend, PendingCorrection, PermissionSnapshot, PermissionState, + PlatformCapabilities, PolishDelta, PolishFailurePolicy, PolishMode, PolishOutput, + ProviderService, QaVoiceCaptureResult, QaVoiceCaptureSession, RecordingArchive, + RecordingControlAction, RecordingControlRequest, RecordingControlSink, RecordingEvent, + RecordingPlan, RecordingProgressSink, ResourceResolver, RuleSource, SecretValue, + SelectionPolishOutputMode, SelectionVoiceIntentMode, SelectionVoiceManualIntent, SessionId, + SettingsCollisionPolicy, SettingsEffectFailure, SettingsEffectKind, SettingsEffectPlan, + SettingsEffectReceipt, SettingsRuntime, SettingsUpdateOptions, SettingsUpdateOutcome, + SettingsValueChange, StartupSnapshot, StylePack, StylePackChange, StylePackExample, + StylePackKind, TaskSpawner, TextInserter, TextPolisher, TextStreamChunk, TextStreamSink, + TokioTaskSpawner, TranscriptAccumulator, TranscriptDelta, TranscriptOutput, + TranscriptionEngine, TranscriptionSession, VocabPreset, VocabPresetStore, VocabularyChange, VoiceTranscriptionSession, BACKEND_CONTRACT_VERSION, DICTATION_SAMPLE_RATE, }; } @@ -237,8 +237,9 @@ pub use credentials::{ }; pub use dictation_context::{ build_asr_prompt, eligible_polish_context_turns, DictationAudioSource, DictationContext, - DictationInsertionContext, DictationPolishContext, DictationStartOptions, DictationStopOptions, - PolishHistoryTurn, ProviderInvocation, RecordingPlan, ASR_PROMPT_CHAR_BUDGET, + DictationInsertionContext, DictationOutputTarget, DictationPolishContext, + DictationStartOptions, DictationStopOptions, PolishHistoryTurn, ProviderInvocation, + RecordingPlan, ASR_PROMPT_CHAR_BUDGET, }; pub use dictation_engine::{PipelineDictationEngine, PolishFailurePolicy}; pub use domains::*; diff --git a/openless-all/app/crates/openless-core/src/ports.rs b/openless-all/app/crates/openless-core/src/ports.rs index f570c5156..cebbe9354 100644 --- a/openless-all/app/crates/openless-core/src/ports.rs +++ b/openless-all/app/crates/openless-core/src/ports.rs @@ -382,6 +382,17 @@ pub trait RecordingControlSink: Send + Sync { pub trait RecordingArchive: Send + Sync { fn is_available(&self) -> bool; + /// Move an undecided capture into the permanent quick-note archive. + /// Hosts that do not need separate storage can keep the default no-op. + fn promote_to_quick_note(&self) -> BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { Ok(()) }) + } + + /// Move a retained undecided capture back into ordinary debug storage. + fn demote_to_ordinary_recording(&self) -> BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { Ok(()) }) + } + fn read_pcm(&self) -> BoxFuture<'static, Result, BackendError>> { Box::pin(async { Err(BackendError::new( diff --git a/openless-all/app/crates/openless-core/src/settings.rs b/openless-all/app/crates/openless-core/src/settings.rs index 37d0f7ccf..c3d3f53cd 100644 --- a/openless-all/app/crates/openless-core/src/settings.rs +++ b/openless-all/app/crates/openless-core/src/settings.rs @@ -46,6 +46,7 @@ pub struct HotkeyRuntimeTarget { pub dictation: ShortcutBinding, pub dictation_mode: HotkeyMode, pub qa: Option, + pub quick_note: Option, pub translation: ShortcutBinding, pub switch_style: Option, pub open_app: Option, @@ -61,6 +62,7 @@ impl From<&UserPreferences> for HotkeyRuntimeTarget { dictation: preferences.dictation_hotkey.clone(), dictation_mode: preferences.hotkey.mode, qa: preferences.qa_hotkey.clone(), + quick_note: preferences.quick_note_hotkey.clone(), translation: preferences.translation_hotkey.clone(), switch_style: preferences.switch_style_hotkey.clone(), open_app: preferences.open_app_hotkey.clone(), diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index c2f9fed15..ab4400997 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -5,16 +5,18 @@ use serde::{Deserialize, Serialize}; use crate::android_types::{ default_android_insert_strategy, default_android_overlay_activation_mode, - default_android_overlay_cancel_swipe_direction, default_android_overlay_left_swipe_action, - default_android_overlay_size_dp, default_android_overlay_trigger, - normalize_android_insert_strategy, normalize_android_overlay_size_dp, + default_android_overlay_cancel_swipe_direction, default_android_overlay_gesture_actions, + default_android_overlay_left_swipe_action, default_android_overlay_size_dp, + default_android_overlay_trigger, normalize_android_insert_strategy, + normalize_android_overlay_size_dp, }; pub use crate::android_types::{ AndroidAccessibilityDiagnosis, AndroidAccessibilityRecoveryOutcome, AndroidAccessibilityRecoveryResult, AndroidAccessibilityState, AndroidAccessibilityStatus, AndroidInsertStrategy, AndroidOverlayActivationMode, AndroidOverlayCancelSwipeDirection, - AndroidOverlayLeftSwipeAction, AndroidOverlayPermissionState, AndroidOverlayStatus, - AndroidOverlayTrigger, AndroidShizukuState, AndroidShizukuStatus, + AndroidOverlayGestureAction, AndroidOverlayGestureActions, AndroidOverlayLeftSwipeAction, + AndroidOverlayPermissionState, AndroidOverlayStatus, AndroidOverlayTrigger, + AndroidShizukuState, AndroidShizukuStatus, }; pub use crate::types::{HistorySource, PolishMode}; @@ -438,6 +440,9 @@ pub struct UserPreferences { /// 默认 Cmd+Shift+; (macOS) / Ctrl+Shift+; (Windows)。详见 issue #118。 #[serde(default = "default_qa_hotkey")] pub qa_hotkey: Option, + /// 独立的速记快捷键。None = 未配置;启用后按一次开始、再按一次结束。 + #[serde(default)] + pub quick_note_hotkey: Option, /// 选区润色全局快捷键。Windows 默认右 Alt;其它平台默认关闭。 #[serde(default = "default_selection_polish_hotkey")] pub selection_polish_hotkey: Option, @@ -686,6 +691,9 @@ pub struct UserPreferences { /// Android: vertical swipe direction that cancels recording. #[serde(default = "default_android_overlay_cancel_swipe_direction")] pub android_overlay_cancel_swipe_direction: AndroidOverlayCancelSwipeDirection, + /// Android: action assigned to each overlay swipe direction. + #[serde(default = "default_android_overlay_gesture_actions")] + pub android_overlay_gesture_actions: AndroidOverlayGestureActions, /// Android: floating overlay control diameter in dp. #[serde(default = "default_android_overlay_size_dp")] pub android_overlay_size_dp: u32, @@ -829,6 +837,8 @@ struct UserPreferencesWire { #[serde(default)] output_language_preference: OutputLanguagePreference, qa_hotkey: Option, + #[serde(default)] + quick_note_hotkey: Option, /// Outer `None` means the field was absent in a pre-Selection-Polish file; /// `Some(None)` means the user explicitly disabled it. #[serde(default, deserialize_with = "deserialize_selection_polish_hotkey")] @@ -955,6 +965,8 @@ struct UserPreferencesWire { android_overlay_left_swipe_action: AndroidOverlayLeftSwipeAction, #[serde(default = "default_android_overlay_cancel_swipe_direction")] android_overlay_cancel_swipe_direction: AndroidOverlayCancelSwipeDirection, + #[serde(default)] + android_overlay_gesture_actions: Option, #[serde(default = "default_android_overlay_size_dp")] android_overlay_size_dp: u32, #[serde(default)] @@ -1039,6 +1051,7 @@ impl Default for UserPreferencesWire { chinese_script_preference: prefs.chinese_script_preference, output_language_preference: prefs.output_language_preference, qa_hotkey: prefs.qa_hotkey, + quick_note_hotkey: prefs.quick_note_hotkey, selection_polish_hotkey: None, selection_polish_style_pack_id: prefs.selection_polish_style_pack_id, selection_polish_output_mode: prefs.selection_polish_output_mode, @@ -1106,6 +1119,7 @@ impl Default for UserPreferencesWire { android_overlay_activation_mode: prefs.android_overlay_activation_mode, android_overlay_left_swipe_action: prefs.android_overlay_left_swipe_action, android_overlay_cancel_swipe_direction: prefs.android_overlay_cancel_swipe_direction, + android_overlay_gesture_actions: None, android_overlay_size_dp: prefs.android_overlay_size_dp, splash_seen_version: prefs.splash_seen_version, } @@ -1155,6 +1169,33 @@ impl<'de> Deserialize<'de> for UserPreferences { let update_channel_explicit = wire .update_channel_explicit .unwrap_or(matches!(wire.update_channel, UpdateChannel::Beta)); + let android_overlay_gesture_actions = + wire.android_overlay_gesture_actions + .unwrap_or_else(|| AndroidOverlayGestureActions { + up: if wire.android_overlay_cancel_swipe_direction + == AndroidOverlayCancelSwipeDirection::Up + { + AndroidOverlayGestureAction::Cancel + } else { + AndroidOverlayGestureAction::None + }, + down: if wire.android_overlay_cancel_swipe_direction + == AndroidOverlayCancelSwipeDirection::Down + { + AndroidOverlayGestureAction::Cancel + } else { + AndroidOverlayGestureAction::None + }, + left: match wire.android_overlay_left_swipe_action { + AndroidOverlayLeftSwipeAction::Translation => { + AndroidOverlayGestureAction::Translation + } + AndroidOverlayLeftSwipeAction::StylePack => { + AndroidOverlayGestureAction::StylePack + } + }, + right: AndroidOverlayGestureAction::Qa, + }); Ok(Self { hotkey: wire.hotkey, @@ -1203,6 +1244,7 @@ impl<'de> Deserialize<'de> for UserPreferences { chinese_script_preference: wire.chinese_script_preference, output_language_preference: wire.output_language_preference, qa_hotkey: wire.qa_hotkey, + quick_note_hotkey: wire.quick_note_hotkey, selection_polish_hotkey, selection_polish_style_pack_id: wire.selection_polish_style_pack_id, selection_polish_output_mode: wire.selection_polish_output_mode, @@ -1277,6 +1319,7 @@ impl<'de> Deserialize<'de> for UserPreferences { android_overlay_activation_mode: wire.android_overlay_activation_mode, android_overlay_left_swipe_action: wire.android_overlay_left_swipe_action, android_overlay_cancel_swipe_direction: wire.android_overlay_cancel_swipe_direction, + android_overlay_gesture_actions, android_overlay_size_dp: normalize_android_overlay_size_dp( wire.android_overlay_size_dp, ), @@ -1555,6 +1598,7 @@ impl Default for UserPreferences { chinese_script_preference: ChineseScriptPreference::Auto, output_language_preference: OutputLanguagePreference::Auto, qa_hotkey: default_qa_hotkey(), + quick_note_hotkey: None, selection_polish_hotkey: default_selection_polish_hotkey(), selection_polish_style_pack_id: default_active_style_pack_id(), selection_polish_output_mode: SelectionPolishOutputMode::default(), @@ -1620,6 +1664,7 @@ impl Default for UserPreferences { android_overlay_left_swipe_action: default_android_overlay_left_swipe_action(), android_overlay_cancel_swipe_direction: default_android_overlay_cancel_swipe_direction( ), + android_overlay_gesture_actions: default_android_overlay_gesture_actions(), android_overlay_size_dp: default_android_overlay_size_dp(), splash_seen_version: String::new(), } diff --git a/openless-all/app/crates/openless-core/src/shortcut_types.rs b/openless-all/app/crates/openless-core/src/shortcut_types.rs index f56c66d69..a300ce3e9 100644 --- a/openless-all/app/crates/openless-core/src/shortcut_types.rs +++ b/openless-all/app/crates/openless-core/src/shortcut_types.rs @@ -438,6 +438,7 @@ pub fn reject_non_dictation_side_specific_shortcuts( preferences.switch_style_hotkey.as_ref(), preferences.open_app_hotkey.as_ref(), preferences.coding_agent_voice_hotkey.as_ref(), + preferences.quick_note_hotkey.as_ref(), ] .into_iter() .flatten() @@ -494,6 +495,13 @@ pub fn reject_selection_polish_hotkey_collisions( "选区润色快捷键不能和 Less Computer 快捷键相同", )?; } + if let Some(binding) = preferences.quick_note_hotkey.as_ref() { + reject_overlap( + selection_polish, + binding, + "选区润色快捷键不能和速记快捷键相同", + )?; + } Ok(()) } @@ -532,6 +540,10 @@ fn reject_style_pack_hotkey_overlap_with_others( preferences.selection_polish_hotkey.as_ref(), "风格快捷键不能和选区润色快捷键相同", ), + ( + preferences.quick_note_hotkey.as_ref(), + "风格快捷键不能和速记快捷键相同", + ), ]; for (other, message) in optional_bindings { if let Some(other) = other { @@ -705,6 +717,34 @@ pub fn reject_hotkey_collisions(preferences: &UserPreferences) -> Result<(), Str let switch_style = preferences.switch_style_hotkey.as_ref(); let open_app = preferences.open_app_hotkey.as_ref(); let less_computer = preferences.coding_agent_voice_hotkey.as_ref(); + let quick_note = preferences.quick_note_hotkey.as_ref(); + if let Some(binding) = quick_note { + reject_overlap( + &preferences.dictation_hotkey, + binding, + "速记快捷键不能和听写快捷键相同", + )?; + reject_overlap( + &preferences.translation_hotkey, + binding, + "速记快捷键不能和翻译快捷键相同", + )?; + if let Some(other) = preferences.qa_hotkey.as_ref() { + reject_overlap(other, binding, "速记快捷键不能和 QA 快捷键相同")?; + } + if let Some(other) = switch_style { + reject_overlap(other, binding, "速记快捷键不能和切换风格快捷键相同")?; + } + if let Some(other) = open_app { + reject_overlap(other, binding, "速记快捷键不能和打开应用快捷键相同")?; + } + if let Some(other) = less_computer { + reject_overlap(other, binding, "速记快捷键不能和 Less Computer 快捷键相同")?; + } + if let Some(other) = preferences.selection_polish_hotkey.as_ref() { + reject_overlap(other, binding, "速记快捷键不能和选区润色快捷键相同")?; + } + } if let Some(qa) = preferences.qa_hotkey.as_ref() { reject_dictation_qa_hotkey_overlap(&preferences.dictation_hotkey, qa)?; reject_qa_translation_hotkey_overlap(qa, &preferences.translation_hotkey)?; diff --git a/openless-all/app/crates/openless-core/src/types.rs b/openless-all/app/crates/openless-core/src/types.rs index 8b33cc455..f67b6f00f 100644 --- a/openless-all/app/crates/openless-core/src/types.rs +++ b/openless-all/app/crates/openless-core/src/types.rs @@ -56,6 +56,7 @@ impl PolishMode { pub enum HistorySource { #[default] Voice, + QuickNote, SelectionPolish, SelectionVoiceEdit, } diff --git a/openless-all/app/src-tauri/src/android/native_bridge.rs b/openless-all/app/src-tauri/src/android/native_bridge.rs index 499bfcc89..5558f4555 100644 --- a/openless-all/app/src-tauri/src/android/native_bridge.rs +++ b/openless-all/app/src-tauri/src/android/native_bridge.rs @@ -238,6 +238,18 @@ fn spawn_stop_dictation_with_translation(translation: bool) { }); } +fn spawn_stop_dictation_as_quick_note() { + let Some(backend) = CORE_BACKEND.get().cloned() else { + log::warn!("[android-native] core backend unavailable"); + return; + }; + tauri::async_runtime::spawn(async move { + if let Err(error) = stop_core_dictation_as_quick_note(&backend).await { + log::warn!("[android-native] stop_quick_note failed: {error}"); + } + }); +} + fn spawn_cancel_dictation() { let Some(backend) = CORE_BACKEND.get().cloned() else { log::warn!("[android-native] core backend unavailable"); @@ -265,6 +277,7 @@ async fn start_core_dictation( backend .start_dictation_with_options(DictationStartOptions { translation_requested: translation, + output_target: openless_core::DictationOutputTarget::Undecided, ..DictationStartOptions::default() }) .await @@ -279,6 +292,18 @@ async fn stop_core_dictation( backend .stop_dictation_with_options(DictationStopOptions { translation_requested: translation, + quick_note: Some(false), + }) + .await + .map(|_| ()) +} + +async fn stop_core_dictation_as_quick_note(backend: &OpenLessBackend) -> Result<(), BackendError> { + ensure_core_started(backend).await?; + backend + .stop_dictation_with_options(DictationStopOptions { + translation_requested: None, + quick_note: Some(true), }) .await .map(|_| ()) @@ -388,6 +413,14 @@ mod jni_exports { spawn_stop_dictation_with_translation(translation != 0); } + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeStopDictationAsQuickNote( + _env: *mut JNIEnv, + _class: JClass, + ) { + spawn_stop_dictation_as_quick_note(); + } + #[no_mangle] pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeCancelDictation( _env: *mut JNIEnv, diff --git a/openless-all/app/src-tauri/src/commands/history.rs b/openless-all/app/src-tauri/src/commands/history.rs index eeb88f6d5..2a38e1a05 100644 --- a/openless-all/app/src-tauri/src/commands/history.rs +++ b/openless-all/app/src-tauri/src/commands/history.rs @@ -8,12 +8,32 @@ pub fn list_history(core: CoreState<'_>) -> Result, String #[tauri::command] pub fn delete_history_entry(core: CoreState<'_>, id: String) -> Result<(), String> { - core.delete_history(&id).map_err(|e| e.to_string()) + if core + .snapshot() + .dictation + .session_id + .is_some_and(|active| active.to_string() == id) + { + return Err("cannot delete the active recording; stop or cancel it first".into()); + } + core.delete_history(&id).map_err(|e| e.to_string())?; + remove_recording_files(&id); + Ok(()) } #[tauri::command] pub fn clear_history(core: CoreState<'_>) -> Result<(), String> { - core.clear_history().map_err(|e| e.to_string()) + if core.snapshot().dictation.session_id.is_some() { + return Err("cannot clear history while a recording is active".into()); + } + let entries = core.list_history().map_err(|e| e.to_string())?; + core.clear_history().map_err(|e| e.to_string())?; + for entry in entries { + if entry.source != openless_core::HistorySource::QuickNote { + remove_recording_files(&entry.id); + } + } + Ok(()) } /// 每日活动汇总(日期升序),概览页年度热力图与「近 7 天 / 近 30 天」指标的数据源。 @@ -25,6 +45,30 @@ pub fn get_activity_stats(core: CoreState<'_>) -> Vec { .expect("activity snapshot should only fail after a poisoned lock") } +fn recording_path_candidates(session_id: &str) -> Result<[std::path::PathBuf; 2], String> { + Ok([ + crate::persistence::recording_path_for_session(session_id).map_err(|e| e.to_string())?, + crate::persistence::quick_note_recording_path_for_session(session_id) + .map_err(|e| e.to_string())?, + ]) +} + +fn existing_recording_path(session_id: &str) -> Result { + let candidates = recording_path_candidates(session_id)?; + candidates + .into_iter() + .find(|path| path.is_file()) + .ok_or_else(|| "recording not found".to_string()) +} + +fn remove_recording_files(session_id: &str) { + if let Ok(candidates) = recording_path_candidates(session_id) { + for path in candidates { + let _ = std::fs::remove_file(path); + } + } +} + /// 读取某次会话的原始麦克风 wav 字节流。文件存在的条件:debug 用户的任意会话,或任意 /// 「转录失败 / empty」会话(失败保留)——成功的非 debug 会话录音会在插入后删掉。 /// 文件名规约:`/recordings/.wav`,与 DictationSession.id 同名。 @@ -48,8 +92,7 @@ pub async fn read_audio_recording(session_id: String) -> Result if !is_valid_session_id(&session_id) { return Err("invalid session id".into()); } - let path = - crate::persistence::recording_path_for_session(&session_id).map_err(|e| e.to_string())?; + let path = existing_recording_path(&session_id)?; let data = tokio::fs::read(&path).await.map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { "recording not found".into() @@ -96,8 +139,7 @@ pub async fn export_audio_recording( return Err("user cancelled".into()); }; - let src = crate::persistence::recording_path_for_session(&session_id) - .map_err(|e| e.to_string())?; + let src = existing_recording_path(&session_id)?; export_recording_to_destination(&app, file_path, &src) }) @@ -235,14 +277,23 @@ pub async fn retranscribe_recording( .into_iter() .find(|entry| entry.id == session_id) .ok_or_else(|| "history entry not found".to_string())?; + if core + .snapshot() + .dictation + .session_id + .is_some_and(|active| active.to_string() == session_id) + { + return Err("recording is still active; stop it before retranscribing".into()); + } if entry.has_audio_recording != Some(true) { return Err("history entry has no archived recording".into()); } - if entry.pipeline_mode.as_deref() == Some("multimodal") { + if entry.pipeline_mode.as_deref() == Some("multimodal") + && entry.source != openless_core::HistorySource::QuickNote + { return Err("multimodal history does not support retranscription".into()); } - let path = - crate::persistence::recording_path_for_session(&session_id).map_err(|e| e.to_string())?; + let path = existing_recording_path(&session_id)?; let wav = tokio::fs::read(&path).await.map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { "recording not found".into() @@ -270,7 +321,10 @@ pub async fn retranscribe_recording( } let retranscribe_ms = retranscribe_started.elapsed().as_millis() as u64; - let updated_entry = if should_replace_failed_history(entry.error_code.as_deref()) { + let updated_entry = if should_replace_failed_history(entry.error_code.as_deref()) + || entry.error_code.as_deref() == Some("recording") + || entry.source == openless_core::HistorySource::QuickNote + { Some( core.apply_history_retranscription( &session_id, @@ -289,6 +343,20 @@ pub async fn retranscribe_recording( }) } +#[tauri::command] +pub fn apply_quick_note_repolish( + core: CoreState<'_>, + session_id: String, + text: String, + style_pack_id: Option, +) -> Result { + if !is_valid_session_id(&session_id) { + return Err("invalid session id".into()); + } + core.apply_history_repolish(&session_id, text, style_pack_id) + .map_err(|e| e.to_string()) +} + #[cfg(test)] mod retranscription_tests { use super::should_replace_failed_history; diff --git a/openless-all/app/src-tauri/src/commands/hotkeys.rs b/openless-all/app/src-tauri/src/commands/hotkeys.rs index aff774813..8ebbad2fa 100644 --- a/openless-all/app/src-tauri/src/commands/hotkeys.rs +++ b/openless-all/app/src-tauri/src/commands/hotkeys.rs @@ -74,6 +74,23 @@ pub fn set_open_app_hotkey( super::settings::persist_strict_settings(&coord, prefs) } +/// 设置独立速记快捷键。None = 停用。 +#[tauri::command] +pub fn set_quick_note_hotkey( + coord: CoordinatorState<'_>, + binding: Option, +) -> Result<(), String> { + if let Some(binding) = binding.as_ref() { + crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?; + crate::shortcut_binding::reject_side_specific_non_dictation(binding)?; + reject_modifier_only_action_shortcut(binding)?; + } + let mut prefs = coord.backend().get_preferences(); + prefs.quick_note_hotkey = binding; + reject_hotkey_collisions(&prefs)?; + super::settings::persist_strict_settings(&coord, prefs) +} + /// 设置 Selection Polish 全局快捷键。Core 先产生显式 effect target;Tauri /// 注册成功后才持久化,失败则按 receipt 恢复旧监听器且不写偏好。 /// 选区润色为桌面(Windows-first)工作流,mobile 不注册。 diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index cf7aca06f..71df28a6c 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -469,6 +469,7 @@ struct Inner { translation_hotkey: Mutex>, switch_style_hotkey: Mutex>, open_app_hotkey: Mutex>, + quick_note_hotkey: Mutex>, /// 风格包直达快捷键监听器(issue #759):pack_id → 实际绑定 + monitor。 /// 绑定元数据让 supervisor 能区分「同一 pack_id 但按键已变化」,并在任何 /// 非事务设置路径注册失败后继续重试到实际状态与 prefs 一致。 @@ -517,6 +518,7 @@ struct Inner { enum ActionHotkeyKind { SwitchStyle, OpenApp, + QuickNote, } impl Coordinator { @@ -621,6 +623,7 @@ impl Coordinator { translation_hotkey: Mutex::new(None), switch_style_hotkey: Mutex::new(None), open_app_hotkey: Mutex::new(None), + quick_note_hotkey: Mutex::new(None), style_pack_hotkeys: Mutex::new(std::collections::HashMap::new()), #[cfg(not(mobile))] selection_polish_hotkey: Mutex::new(None), @@ -730,6 +733,7 @@ impl Coordinator { translation_hotkey: Mutex::new(None), switch_style_hotkey: Mutex::new(None), open_app_hotkey: Mutex::new(None), + quick_note_hotkey: Mutex::new(None), style_pack_hotkeys: Mutex::new(std::collections::HashMap::new()), #[cfg(not(mobile))] selection_polish_hotkey: Mutex::new(None), @@ -1036,6 +1040,18 @@ impl Coordinator { take_action_hotkey_on_main_thread(&self.inner, ActionHotkeyKind::OpenApp); } + pub fn start_quick_note_hotkey_listener(&self) { + let inner = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-quick-note-hotkey-supervisor".into()) + .spawn(move || action_hotkey_supervisor_loop(inner, ActionHotkeyKind::QuickNote)) + .ok(); + } + + pub fn stop_quick_note_hotkey_listener(&self) { + take_action_hotkey_on_main_thread(&self.inner, ActionHotkeyKind::QuickNote); + } + /// 启动风格包直达快捷键监听(issue #759)。supervisor 线程等 AppHandle 就绪后 /// 按 prefs 全量注册,个别注册失败按 action hotkey 的节奏重试。 pub fn start_style_pack_hotkey_listeners(&self) { @@ -1256,6 +1272,10 @@ impl Coordinator { self.update_action_hotkey_binding(ActionHotkeyKind::OpenApp); } + pub(crate) fn update_quick_note_hotkey_binding(&self) { + self.update_action_hotkey_binding(ActionHotkeyKind::QuickNote); + } + fn update_action_hotkey_binding(&self, kind: ActionHotkeyKind) { // None = 用户主动停用:反注册全局键,立即生效。 let Some(binding) = action_hotkey_binding(&self.inner, kind) else { @@ -1493,6 +1513,9 @@ impl Coordinator { if previous.open_app != next.open_app { self.update_open_app_hotkey_binding(); } + if previous.quick_note != next.quick_note { + self.update_quick_note_hotkey_binding(); + } if previous.coding_agent_enabled != next.coding_agent_enabled || previous.coding_agent_voice != next.coding_agent_voice { diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 63af12fdb..5fb0ab995 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -1548,6 +1548,46 @@ pub(super) fn handle_action_hotkey_pressed(inner: &Arc, kind: ActionHotke match kind { ActionHotkeyKind::SwitchStyle => switch_to_previous_style(inner), ActionHotkeyKind::OpenApp => inner.host.show_main_window(), + ActionHotkeyKind::QuickNote => { + let backend = Arc::clone(&inner.backend); + inner.host.spawn(async move { + let phase = backend.snapshot().dictation.phase; + let result = match phase { + openless_core::DictationPhase::Idle => backend + .start_dictation_with_options(openless_core::DictationStartOptions { + insert_text: false, + output_target: openless_core::DictationOutputTarget::QuickNote, + ..openless_core::DictationStartOptions::default() + }) + .await + .map(|_| ()), + openless_core::DictationPhase::Starting + | openless_core::DictationPhase::Recording + if matches!( + backend.dictation_output_target(), + Some( + openless_core::DictationOutputTarget::QuickNote + | openless_core::DictationOutputTarget::Undecided + ) + ) => + { + backend + .stop_dictation_with_options( + openless_core::DictationStopOptions { + quick_note: Some(true), + ..openless_core::DictationStopOptions::default() + }, + ) + .await + .map(|_| ()) + } + _ => Ok(()), + }; + if let Err(error) = result { + log::warn!("[coord] quick note hotkey failed: {error}"); + } + }); + } } } @@ -1629,6 +1669,7 @@ pub(super) fn action_hotkey_slot( match kind { ActionHotkeyKind::SwitchStyle => &inner.switch_style_hotkey, ActionHotkeyKind::OpenApp => &inner.open_app_hotkey, + ActionHotkeyKind::QuickNote => &inner.quick_note_hotkey, } } @@ -1640,6 +1681,7 @@ pub(super) fn action_hotkey_binding( match kind { ActionHotkeyKind::SwitchStyle => target.switch_style, ActionHotkeyKind::OpenApp => target.open_app, + ActionHotkeyKind::QuickNote => target.quick_note, } } @@ -1661,6 +1703,7 @@ pub(super) fn action_hotkey_bridge_thread_name(kind: ActionHotkeyKind) -> &'stat match kind { ActionHotkeyKind::SwitchStyle => "openless-switch-style-hotkey-bridge", ActionHotkeyKind::OpenApp => "openless-open-app-hotkey-bridge", + ActionHotkeyKind::QuickNote => "openless-quick-note-hotkey-bridge", } } diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index 69fa20b9a..bc06aa210 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -2423,14 +2423,14 @@ struct TauriActiveRecording { } struct TauriRecordingArchive { - path: PathBuf, + path: Arc>, available: Arc, } impl TauriRecordingArchive { fn new(path: PathBuf, available: bool) -> Self { Self { - path, + path: Arc::new(Mutex::new(path)), available: Arc::new(AtomicBool::new(available)), } } @@ -2442,7 +2442,7 @@ impl RecordingArchive for TauriRecordingArchive { } fn read_pcm(&self) -> BoxFuture<'static, Result, BackendError>> { - let path = self.path.clone(); + let path = self.path.lock().clone(); Box::pin(async move { let wav = tokio::fs::read(&path).await.map_err(|error| { BackendError::new( @@ -2465,7 +2465,7 @@ impl RecordingArchive for TauriRecordingArchive { } fn discard(&self) -> BoxFuture<'static, Result<(), BackendError>> { - let path = self.path.clone(); + let path = self.path.lock().clone(); let available = Arc::clone(&self.available); Box::pin(async move { if !available.load(Ordering::Acquire) { @@ -2493,6 +2493,60 @@ impl RecordingArchive for TauriRecordingArchive { } }) } + + fn promote_to_quick_note(&self) -> BoxFuture<'static, Result<(), BackendError>> { + let path = Arc::clone(&self.path); + Box::pin(async move { + let current = path.lock().clone(); + let Some(file_name) = current.file_name().map(|name| name.to_owned()) else { + return Err(BackendError::new( + BackendErrorCode::Persistence, + "quick-note archive has no file name", + )); + }; + let target = crate::persistence::quick_note_recordings_root() + .map_err(|error| BackendError::new(BackendErrorCode::Persistence, error.to_string()))? + .join(file_name); + if current == target { + return Ok(()); + } + tokio::fs::rename(¤t, &target).await.map_err(|error| { + BackendError::new( + BackendErrorCode::Persistence, + format!("promote quick-note recording archive: {error}"), + ) + })?; + *path.lock() = target; + Ok(()) + }) + } + + fn demote_to_ordinary_recording(&self) -> BoxFuture<'static, Result<(), BackendError>> { + let path = Arc::clone(&self.path); + Box::pin(async move { + let current = path.lock().clone(); + let Some(file_name) = current.file_name().map(|name| name.to_owned()) else { + return Err(BackendError::new( + BackendErrorCode::Persistence, + "recording archive has no file name", + )); + }; + let target = crate::persistence::recordings_root() + .map_err(|error| BackendError::new(BackendErrorCode::Persistence, error.to_string()))? + .join(file_name); + if current == target { + return Ok(()); + } + tokio::fs::rename(¤t, &target).await.map_err(|error| { + BackendError::new( + BackendErrorCode::Persistence, + format!("move recording archive to ordinary storage: {error}"), + ) + })?; + *path.lock() = target; + Ok(()) + }) + } } impl ActiveRecording for TauriActiveRecording { @@ -2566,17 +2620,30 @@ impl AudioRecorder for TauriAudioRecorder { preview.stop(); } } - // QA/划词语音沿用1.x不落盘语义;不要先创建WAV,再依赖停止时删除。 + let permanent_archive = !matches!( + context.output_target, + openless_core::DictationOutputTarget::ForegroundApp + ); + // Undecided Android captures use the permanent quick-note spool + // until the terminal tap/gesture classifies the session. let archive_path = context .recording .archive_enabled - .then(|| crate::persistence::recording_path_for_session(&session_id.to_string())) + .then(|| { + if permanent_archive { + crate::persistence::quick_note_recording_path_for_session( + &session_id.to_string(), + ) + } else { + crate::persistence::recording_path_for_session(&session_id.to_string()) + } + }) .transpose(); let microphone = context.recording.microphone_device_name.clone(); let recording_plan = context.recording.clone(); let fault_progress = Arc::clone(&progress); let (recording, runtime_errors) = tauri::async_runtime::spawn_blocking(move || { - if recording_plan.archive_enabled { + if recording_plan.archive_enabled && !recording_plan.archive_required { if let Err(error) = crate::persistence::prune_recordings( recording_plan.retention_days, recording_plan.max_entries, @@ -2612,6 +2679,16 @@ impl AudioRecorder for TauriAudioRecorder { return Err(map_recorder_error(error)); } }; + if recording_plan.archive_required && !archive_active { + recorder.stop(); + if let Some(path) = &archive_path { + let _ = std::fs::remove_file(path); + } + return Err(BackendError::new( + BackendErrorCode::Persistence, + "速记录音文件无法创建,已阻止开始录音以避免丢失内容", + )); + } let recording = Box::new(TauriActiveRecording { recorder: Some(recorder), archive: archive_path @@ -3129,6 +3206,7 @@ impl openless_core::HostContextAdapter for TauriHostContextAdapter { }) }) } + } pub(crate) struct TauriHostActions { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 6a8438546..eccdc898d 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -220,6 +220,7 @@ macro_rules! app_invoke_handler_desktop { commands::read_audio_recording, commands::export_audio_recording, commands::retranscribe_recording, + commands::apply_quick_note_repolish, commands::marketplace_list, commands::marketplace_detail, commands::marketplace_install, @@ -325,6 +326,7 @@ macro_rules! app_invoke_handler_desktop { commands::set_translation_hotkey, commands::set_switch_style_hotkey, commands::set_open_app_hotkey, + commands::set_quick_note_hotkey, commands::set_style_pack_hotkeys, commands::qa_window_dismiss, commands::qa_toggle_recording, @@ -468,6 +470,7 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::read_audio_recording, $crate::commands::export_audio_recording, $crate::commands::retranscribe_recording, + $crate::commands::apply_quick_note_repolish, $crate::commands::marketplace_list, $crate::commands::marketplace_detail, $crate::commands::marketplace_install, @@ -885,6 +888,7 @@ fn run_desktop() { coordinator.start_translation_hotkey_listener(); coordinator.start_switch_style_hotkey_listener(); coordinator.start_open_app_hotkey_listener(); + coordinator.start_quick_note_hotkey_listener(); coordinator.start_style_pack_hotkey_listeners(); } #[cfg(target_os = "macos")] @@ -908,6 +912,7 @@ fn run_desktop() { coordinator.stop_translation_hotkey_listener(); coordinator.stop_switch_style_hotkey_listener(); coordinator.stop_open_app_hotkey_listener(); + coordinator.stop_quick_note_hotkey_listener(); coordinator.stop_style_pack_hotkey_listeners(); let backend = coordinator.backend(); tauri::async_runtime::spawn(async move { diff --git a/openless-all/app/src-tauri/src/persistence/paths.rs b/openless-all/app/src-tauri/src/persistence/paths.rs index 19d118e70..01a242075 100644 --- a/openless-all/app/src-tauri/src/persistence/paths.rs +++ b/openless-all/app/src-tauri/src/persistence/paths.rs @@ -64,6 +64,14 @@ pub fn recordings_root() -> Result { Ok(dir) } +/// Permanent quick-note archives live outside the ordinary debug-recording +/// directory so the normal WAV count/retention prune can never remove them. +pub fn quick_note_recordings_root() -> Result { + let dir = data_dir()?.join("quick-notes").join("recordings"); + ensure_dir(&dir)?; + Ok(dir) +} + /// 双重 cap 清理 `recordings/*.wav`: /// - `retention_days > 0` → 把超过 N 天的删掉(沿用 history 的 retention 逻辑)。 /// - `max_entries == Some(n)` → 按 mtime 倒序保留最新的 n 条(clamp 到 1..=HISTORY_CAP); @@ -139,6 +147,10 @@ pub fn recording_path_for_session(session_id: &str) -> Result { Ok(recordings_root()?.join(format!("{session_id}.wav"))) } +pub fn quick_note_recording_path_for_session(session_id: &str) -> Result { + Ok(quick_note_recordings_root()?.join(format!("{session_id}.wav"))) +} + /// Foundry Local 下载与缓存根目录。DLL 和模型都不打进安装包,和 Qwen3-ASR /// 一样放在 OpenLess 的 models 目录下,卸载清理用户数据时可以一起删除。 #[cfg(target_os = "windows")] diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index f5ee9dde9..9513c1c88 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -772,6 +772,7 @@ fn update_peak(slot: &AtomicUsize, current: f32) { struct WavArchiver { file: std::fs::File, bytes_written: u32, + last_checkpoint_bytes: u32, } impl WavArchiver { @@ -785,6 +786,7 @@ impl WavArchiver { Ok(Self { file, bytes_written: 0, + last_checkpoint_bytes: 0, }) } @@ -794,21 +796,43 @@ impl WavArchiver { self.bytes_written = self .bytes_written .saturating_add(pcm_bytes.len().min(u32::MAX as usize) as u32); + // Keep the header usable during a long meeting. Drop still does + // the final sync, but a process kill should not leave a WAV with + // data_size=0 for the entire recording. + const CHECKPOINT_INTERVAL_BYTES: u32 = 160_000; + if self + .bytes_written + .saturating_sub(self.last_checkpoint_bytes) + >= CHECKPOINT_INTERVAL_BYTES + { + self.checkpoint_header(); + } } } -} -impl Drop for WavArchiver { - fn drop(&mut self) { + fn checkpoint_header(&mut self) { use std::io::{Seek, SeekFrom, Write}; - let header = build_wav_header(self.bytes_written); if self.file.seek(SeekFrom::Start(0)).is_ok() { - let _ = self.file.write_all(&header); - let _ = self.file.sync_all(); + if self + .file + .write_all(&build_wav_header(self.bytes_written)) + .is_ok() + { + let _ = self.file.seek(SeekFrom::End(0)); + let _ = self.file.sync_data(); + self.last_checkpoint_bytes = self.bytes_written; + } } } } +impl Drop for WavArchiver { + fn drop(&mut self) { + self.checkpoint_header(); + let _ = self.file.sync_all(); + } +} + fn build_wav_header(data_size: u32) -> [u8; 44] { // RIFF/WAVE PCM 标准 44-byte header,16 kHz / mono / 16-bit 写死。 let total_size = data_size.saturating_add(36); diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index d74b7c4de..114462417 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -15,6 +15,7 @@ import { Style } from '../pages/Style'; import { Marketplace } from '../pages/Marketplace'; import { Translation } from '../pages/Translation'; import { SelectionAsk } from '../pages/SelectionAsk'; +import { QuickNote } from '../pages/QuickNote'; import { Corrections } from '../pages/Corrections'; import { APP_VERSION_LABEL, IS_BETA_BUILD } from '../lib/appVersion'; import { @@ -39,7 +40,7 @@ import { useMobileLayout, useConservativeLayout } from '../lib/useMobileLayout'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { useAppState, type AppTab } from '../state/useAppState'; -const MORE_TAB_IDS: AppTab[] = ['vocab', 'translation', 'selectionAsk', 'corrections']; +const MORE_TAB_IDS: AppTab[] = ['vocab', 'translation', 'selectionAsk', 'quickNote', 'corrections']; const STYLE_TAB_IDS: AppTab[] = ['style', 'marketplace']; /** Reserve the native traffic-light strip before the sidebar's version row. */ @@ -55,6 +56,7 @@ const PAGE_CMP: Record, ComponentType> = { marketplace: Marketplace, translation: Translation, selectionAsk: SelectionAsk, + quickNote: QuickNote, corrections: Corrections, }; @@ -77,7 +79,12 @@ const NAV_TREE: NavNode[] = [ kind: 'group', key: 'tools', icon: 'selectionAsk', - children: [{ id: 'translation' }, { id: 'selectionAsk' }, { id: 'corrections' }], + children: [ + { id: 'translation' }, + { id: 'selectionAsk' }, + { id: 'quickNote' }, + { id: 'corrections' }, + ], }, ]; diff --git a/openless-all/app/src/components/MobileMoreSheet.tsx b/openless-all/app/src/components/MobileMoreSheet.tsx index 8bfc483ce..f0388608c 100644 --- a/openless-all/app/src/components/MobileMoreSheet.tsx +++ b/openless-all/app/src/components/MobileMoreSheet.tsx @@ -7,6 +7,7 @@ const MORE_TABS: Array<{ id: AppTab; icon: string }> = [ { id: 'vocab', icon: 'vocab' }, { id: 'translation', icon: 'translate' }, { id: 'selectionAsk', icon: 'selectionAsk' }, + { id: 'quickNote', icon: 'history' }, { id: 'corrections', icon: 'filter' }, ]; diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 4e04956f3..b491394f6 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -171,6 +171,7 @@ export const de: typeof zhCN = { marketplace: 'Marktplatz', translation: 'Übersetzung', selectionAsk: 'Nachfragen', + quickNote: 'Schnellnotizen', corrections: 'Korrekturen', polishMode: 'Überarbeitungsmodus', group: { @@ -302,6 +303,7 @@ export const de: typeof zhCN = { translation: 'Übersetzung: Beim Sprechen Shift gedrückt halten, um Text in der Zielsprache einzufügen', selectionAsk: 'Zum ausgewählten Text fragen: Text auswählen und eine Frage dazu sprechen', + quickNote: 'Schnellnotizen: Audio speichern und später wiedergeben', settings: 'Einstellungen: Kurzbefehle, Dienste, Datenschutz und Updates', }, footer: { @@ -528,6 +530,7 @@ export const de: typeof zhCN = { inserted: 'Eingefügt', pasteSent: 'Einfügebefehl gesendet', copiedFallback: 'Kopiert (mit {{shortcut}} einfügen)', + notRequested: 'Nicht eingefügt', insertFailed: 'Einfügen fehlgeschlagen', confirmClear: 'Alle {{count}} Verlaufseinträge löschen? Dies kann nicht rückgängig gemacht werden.', @@ -898,6 +901,21 @@ export const de: typeof zhCN = { step2: 'Wähle Text in einer beliebigen App aus.', }, }, + quickNote: { + kicker: 'Schnellnotizen', + title: 'Schnellnotizen', + desc: 'Audio dauerhaft behalten, mit Wiedergabe, Export, Neu-Transkription und Überarbeitung.', + recording: 'Aufnahme läuft …', + failedTitle: 'Aufnahme benötigt Aufmerksamkeit', + emptyTitle: 'Unbenannte Aufnahme', + noTranscript: 'Noch keine Transkription.', + applyResult: 'Auf Notiz anwenden', + applying: 'Wird angewendet …', + shortcutTitle: 'Schnellnotizen-Kurzbefehl', + shortcutDesc: 'Einmal drücken zum Aufnehmen, erneut drücken zum Speichern.', + repolishNeedsTranscript: 'Bitte zuerst die Audiodatei neu transkribieren.', + shareRecording: 'Audio teilen', + }, settings: { selectionWorkspace: { title: 'Assistent für Textauswahl', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index cd55ef226..b6b54b560 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -171,6 +171,7 @@ export const en: typeof zhCN = { marketplace: 'Marketplace', translation: 'Translation', selectionAsk: 'Ask', + quickNote: 'Quick notes', corrections: 'Corrections', polishMode: 'Polish mode', group: { @@ -298,6 +299,7 @@ export const en: typeof zhCN = { style: 'Polish styles: manage output styles and custom prompts', translation: 'Translation: hold Shift while speaking to insert in a target language', selectionAsk: 'Selection ask: select text, then ask about it by voice', + quickNote: 'Quick notes: keep audio and revisit the transcript', settings: 'Preferences: shortcuts, providers, privacy and updates', }, footer: { @@ -520,6 +522,7 @@ export const en: typeof zhCN = { inserted: 'Inserted', pasteSent: 'Paste sent', copiedFallback: 'Copied (use {{shortcut}})', + notRequested: 'Not inserted', insertFailed: 'Insert failed', confirmClear: 'Delete all {{count}} history entries? This cannot be undone.', backToList: 'Back to list', @@ -879,6 +882,21 @@ export const en: typeof zhCN = { step2: 'Select text in any app.', }, }, + quickNote: { + kicker: 'Quick notes', + title: 'Quick notes', + desc: 'Permanent audio with playback, export, retranscription, and repolish.', + recording: 'Recording…', + failedTitle: 'Recording needs attention', + emptyTitle: 'Untitled recording', + noTranscript: 'No transcript yet.', + applyResult: 'Apply to note', + applying: 'Applying…', + shortcutTitle: 'Quick note shortcut', + shortcutDesc: 'Press once to start a permanent capture, then press again to finish.', + repolishNeedsTranscript: 'Re-transcribe the audio before repolishing.', + shareRecording: 'Share audio', + }, settings: { selectionWorkspace: { title: 'Selection Assistant', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index ec43ba5cf..8451733ca 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -171,6 +171,7 @@ export const es: typeof zhCN = { marketplace: 'Catálogo', translation: 'Traducción', selectionAsk: 'Preguntar', + quickNote: 'Notas rápidas', corrections: 'Correcciones', polishMode: 'Modo de redacción', group: { @@ -301,6 +302,7 @@ export const es: typeof zhCN = { translation: 'Traducción: mantén pulsada Mayús mientras hablas para insertar el texto en otro idioma', selectionAsk: 'Preguntar sobre una selección: selecciona texto y pregunta por voz', + quickNote: 'Notas rápidas: conserva el audio y vuelve al texto cuando quieras', settings: 'Preferencias: atajos, proveedores, privacidad y actualizaciones', }, footer: { @@ -527,6 +529,7 @@ export const es: typeof zhCN = { inserted: 'Insertado', pasteSent: 'Pegado enviado', copiedFallback: 'Copiado (usa {{shortcut}})', + notRequested: 'No se solicitó insertar', insertFailed: 'No se pudo insertar', confirmClear: '¿Eliminar los {{count}} registros del historial? Esta acción no se puede deshacer.', @@ -893,6 +896,21 @@ export const es: typeof zhCN = { step2: 'Selecciona texto en cualquier aplicación.', }, }, + quickNote: { + kicker: 'Notas rápidas', + title: 'Notas rápidas', + desc: 'Audio permanente con reproducción, exportación, retranscripción y redacción.', + recording: 'Grabando…', + failedTitle: 'La grabación necesita atención', + emptyTitle: 'Grabación sin título', + noTranscript: 'Aún no hay transcripción.', + applyResult: 'Aplicar a la nota', + applying: 'Aplicando…', + shortcutTitle: 'Atajo de nota rápida', + shortcutDesc: 'Pulsa una vez para grabar y otra vez para guardar.', + repolishNeedsTranscript: 'Primero vuelve a transcribir el audio.', + shareRecording: 'Compartir audio', + }, settings: { selectionWorkspace: { title: 'Asistente de selección', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 7a1850fe8..452eb3d2f 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -171,6 +171,7 @@ export const fr: typeof zhCN = { marketplace: 'Catalogue', translation: 'Traduction', selectionAsk: 'Questions', + quickNote: 'Notes vocales', corrections: 'Corrections', polishMode: 'Mode de rédaction', group: { @@ -304,6 +305,7 @@ export const fr: typeof zhCN = { 'Traduction : maintenez Maj pendant que vous parlez pour insérer le texte dans une autre langue', selectionAsk: 'Questions sur la sélection : sélectionnez du texte, puis posez une question à voix haute', + quickNote: 'Notes vocales : conservez l’audio et relisez la transcription', settings: 'Préférences : raccourcis, fournisseurs, confidentialité et mises à jour', }, footer: { @@ -533,6 +535,7 @@ export const fr: typeof zhCN = { inserted: 'Inséré', pasteSent: 'Collage envoyé', copiedFallback: 'Copié (utilisez {{shortcut}})', + notRequested: 'Insertion non demandée', insertFailed: 'Échec de l’insertion', confirmClear: 'Supprimer les {{count}} entrées de l’historique ? Cette action est irréversible.', @@ -905,6 +908,21 @@ export const fr: typeof zhCN = { step2: 'Sélectionnez du texte dans une application.', }, }, + quickNote: { + kicker: 'Notes vocales', + title: 'Notes vocales', + desc: 'Audio conservé durablement avec lecture, export, retranscription et réécriture.', + recording: 'Enregistrement…', + failedTitle: 'La note nécessite une intervention', + emptyTitle: 'Enregistrement sans titre', + noTranscript: 'Aucune transcription pour le moment.', + applyResult: 'Appliquer à la note', + applying: 'Application…', + shortcutTitle: 'Raccourci de note vocale', + shortcutDesc: 'Appuyez une fois pour enregistrer, puis à nouveau pour sauvegarder.', + repolishNeedsTranscript: 'Retranscrivez d’abord l’audio avant la réécriture.', + shareRecording: 'Partager l’audio', + }, settings: { selectionWorkspace: { title: 'Assistant de sélection', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 74f7ae579..db66ef865 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -162,6 +162,7 @@ export const ja: typeof zhCN = { marketplace: 'マーケット', translation: '翻訳', selectionAsk: '選択追問', + quickNote: '速記', corrections: '修正ルール', polishMode: '推敲モード', group: { @@ -289,6 +290,7 @@ export const ja: typeof zhCN = { style: 'スタイル:出力スタイルとカスタムプロンプトを管理', translation: '翻訳:Shift を押しながら話すと目標言語で挿入', selectionAsk: '選択質問:テキストを選択して音声で質問', + quickNote: '速記:音声を保存して後から確認', settings: '環境設定:ショートカット・プロバイダー・プライバシー・更新', }, footer: { @@ -508,6 +510,7 @@ export const ja: typeof zhCN = { inserted: '入力済み', pasteSent: '貼り付けを試行', copiedFallback: 'コピー済み(要 {{shortcut}})', + notRequested: '入力なし', insertFailed: '入力失敗', confirmClear: '全 {{count}} 件の記録を削除しますか?この操作は取り消せません。', backToList: '一覧に戻る', @@ -866,6 +869,21 @@ export const ja: typeof zhCN = { step2: '任意のアプリでテキストを選択。', }, }, + quickNote: { + kicker: '速記', + title: '速記', + desc: '音声を保持し、再生・書き出し・再文字起こし・再推敲に対応します。', + recording: '録音中…', + failedTitle: '録音の確認が必要です', + emptyTitle: '無題の録音', + noTranscript: 'まだ文字起こしがありません。', + applyResult: '速記に適用', + applying: '適用中…', + shortcutTitle: '速記ショートカット', + shortcutDesc: '一度押して録音を開始し、もう一度押して保存します。', + repolishNeedsTranscript: '先に音声を再文字起こししてください。', + shareRecording: '音声を共有', + }, settings: { selectionWorkspace: { title: '選択範囲アシスタント', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index b8b7cf6a7..865edaec0 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -160,6 +160,7 @@ export const ko: typeof zhCN = { marketplace: '마켓', translation: '번역', selectionAsk: '선택 질문', + quickNote: '속기', corrections: '교정 규칙', polishMode: '다듬기 모드', group: { @@ -287,6 +288,7 @@ export const ko: typeof zhCN = { style: '스타일: 출력 스타일과 사용자 지정 프롬프트 관리', translation: '번역: Shift를 누른 채 말하면 대상 언어로 삽입', selectionAsk: '선택 질문: 텍스트를 선택한 뒤 음성으로 질문', + quickNote: '속기: 오디오를 저장하고 나중에 다시 확인', settings: '환경설정: 단축키, 제공자, 개인정보 및 업데이트', }, footer: { @@ -506,6 +508,7 @@ export const ko: typeof zhCN = { inserted: '입력됨', pasteSent: '붙여넣기 시도됨', copiedFallback: '복사됨({{shortcut}} 필요)', + notRequested: '입력하지 않음', insertFailed: '입력 실패', confirmClear: '전체 {{count}}건의 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.', backToList: '목록으로', @@ -864,6 +867,21 @@ export const ko: typeof zhCN = { step2: '아무 앱에서 텍스트 선택.', }, }, + quickNote: { + kicker: '속기', + title: '속기', + desc: '오디오를 영구 보관하고 재생·내보내기·재전사·다시 다듬기를 지원합니다.', + recording: '녹음 중…', + failedTitle: '녹음을 확인해야 합니다', + emptyTitle: '제목 없는 녹음', + noTranscript: '아직 전사 내용이 없습니다.', + applyResult: '속기에 적용', + applying: '적용 중…', + shortcutTitle: '속기 단축키', + shortcutDesc: '한 번 눌러 녹음하고 다시 눌러 저장합니다.', + repolishNeedsTranscript: '먼저 오디오를 다시 전사해 주세요.', + shareRecording: '오디오 공유', + }, settings: { selectionWorkspace: { title: '선택 영역 도우미', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 2753f4b19..f9bc45a61 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -162,6 +162,7 @@ export const zhCN = { marketplace: '风格市场', translation: '翻译', selectionAsk: '划词追问', + quickNote: '速记', corrections: '纠正规则', polishMode: '润色模式', group: { @@ -287,6 +288,7 @@ export const zhCN = { style: '润色风格:管理输出风格与自定义提示词', translation: '翻译:按住 Shift 说话,译成目标语言插入', selectionAsk: '划词追问:选中文字后语音提问', + quickNote: '速记:保存录音并整理为可回看的记录', settings: '偏好设置:快捷键、提供商、隐私与更新', }, footer: { @@ -499,6 +501,7 @@ export const zhCN = { inserted: '已插入', pasteSent: '已尝试粘贴', copiedFallback: '已复制(需 {{shortcut}})', + notRequested: '未请求插入', insertFailed: '插入失败', confirmClear: '确定清空全部 {{count}} 条记录?此操作不可恢复。', backToList: '返回列表', @@ -849,6 +852,21 @@ export const zhCN = { step2: '在任意 app 选中文字。', }, }, + quickNote: { + kicker: '速记', + title: '速记', + desc: '永久保留录音,支持回放、导出、重新转录和重新润色。', + recording: '录音中…', + failedTitle: '录音需要处理', + emptyTitle: '未命名录音', + noTranscript: '还没有转写内容。', + applyResult: '应用到速记', + applying: '应用中…', + shortcutTitle: '速记快捷键', + shortcutDesc: '按一次开始永久录音,再按一次结束并保存。', + repolishNeedsTranscript: '请先重新转录录音,再进行润色。', + shareRecording: '分享录音', + }, settings: { selectionWorkspace: { title: '选区助手', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index dedc72735..3a0d0e2d2 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -164,6 +164,7 @@ export const zhTW: typeof zhCN = { marketplace: '風格市場', translation: '翻譯', selectionAsk: '劃詞追問', + quickNote: '速記', corrections: '糾正規則', polishMode: '潤色模式', group: { @@ -289,6 +290,7 @@ export const zhTW: typeof zhCN = { style: '潤色風格:管理輸出風格與自訂提示詞', translation: '翻譯:按住 Shift 說話,譯成目標語言插入', selectionAsk: '劃詞追問:選取文字後語音提問', + quickNote: '速記:保存錄音並整理成可回看的記錄', settings: '偏好設定:快捷鍵、提供商、隱私與更新', }, footer: { @@ -501,6 +503,7 @@ export const zhTW: typeof zhCN = { inserted: '已插入', pasteSent: '已嘗試粘貼', copiedFallback: '已複製(需 {{shortcut}})', + notRequested: '未要求插入', insertFailed: '插入失敗', confirmClear: '確定清空全部 {{count}} 條記錄?此操作不可恢復。', backToList: '返回列表', @@ -851,6 +854,21 @@ export const zhTW: typeof zhCN = { step2: '在任意 app 選中文字。', }, }, + quickNote: { + kicker: '速記', + title: '速記', + desc: '永久保留錄音,支援回放、匯出、重新轉錄與重新潤色。', + recording: '錄音中…', + failedTitle: '錄音需要處理', + emptyTitle: '未命名錄音', + noTranscript: '尚未有轉錄內容。', + applyResult: '套用到速記', + applying: '套用中…', + shortcutTitle: '速記快捷鍵', + shortcutDesc: '按一下開始永久錄音,再按一下結束並保存。', + repolishNeedsTranscript: '請先重新轉錄錄音,再進行潤色。', + shareRecording: '分享錄音', + }, settings: { selectionWorkspace: { title: '選區助手', diff --git a/openless-all/app/src/lib/history-retranscribe.test.ts b/openless-all/app/src/lib/history-retranscribe.test.ts index 8c2a4a19f..948a7428a 100644 --- a/openless-all/app/src/lib/history-retranscribe.test.ts +++ b/openless-all/app/src/lib/history-retranscribe.test.ts @@ -44,5 +44,13 @@ assert( }), 'multimodal entries should not show an unsupported retranscription action', ); +assert( + canRetranscribeHistoryEntry({ + ...archivedEntry, + pipelineMode: 'multimodal', + source: 'quick_note', + }), + 'quick notes should retain retranscription even when their original pipeline was multimodal', +); console.log('history-retranscribe: all assertions passed'); diff --git a/openless-all/app/src/lib/history-retranscribe.ts b/openless-all/app/src/lib/history-retranscribe.ts index 2bd5ee872..cdad4d4ca 100644 --- a/openless-all/app/src/lib/history-retranscribe.ts +++ b/openless-all/app/src/lib/history-retranscribe.ts @@ -1,14 +1,14 @@ import type { DictationSession } from './types'; /** - * 重新转录需要一份仍存在的 WAV 归档。多模态历史目前没有对应的重转录 - * provider 通道,避免展示一个点击后必然失败的按钮。 - * - * 成功转录、润色失败和转录失败的条目都可能有可用录音,用户都应能用同一份 - * 音频重新验证当前 ASR provider。是否回写失败记录由后端决定。 + * 重新转录需要一份仍存在的 WAV 归档。成功转录、润色失败、转录失败和速记 + * 都可以用同一份音频重新验证当前 ASR provider。是否回写失败记录由后端决定。 */ export function canRetranscribeHistoryEntry( - session: Pick, + session: Pick, ): boolean { - return session.hasAudioRecording === true && session.pipelineMode !== 'multimodal'; + return ( + session.hasAudioRecording === true && + (session.pipelineMode !== 'multimodal' || session.source === 'quick_note') + ); } diff --git a/openless-all/app/src/lib/ipc/history.ts b/openless-all/app/src/lib/ipc/history.ts index ac1f869b0..da8098e34 100644 --- a/openless-all/app/src/lib/ipc/history.ts +++ b/openless-all/app/src/lib/ipc/history.ts @@ -41,3 +41,15 @@ export function retranscribeRecording(sessionId: string): Promise; } + +export function applyQuickNoteRepolish( + sessionId: string, + text: string, + stylePackId?: string, +): Promise { + return invokeOrMock( + 'apply_quick_note_repolish', + { sessionId, text, stylePackId: stylePackId ?? null }, + () => mockHistory[0], + ) as Promise; +} diff --git a/openless-all/app/src/lib/ipc/hotkeys.ts b/openless-all/app/src/lib/ipc/hotkeys.ts index 0ba4dcbba..0df1e1a18 100644 --- a/openless-all/app/src/lib/ipc/hotkeys.ts +++ b/openless-all/app/src/lib/ipc/hotkeys.ts @@ -85,6 +85,13 @@ export function setOpenAppHotkey(binding: ShortcutBinding | null): Promise return invokeOrMock('set_open_app_hotkey', { binding }, () => undefined); } +export function setQuickNoteHotkey(binding: ShortcutBinding | null): Promise { + return invokeOrMock('set_quick_note_hotkey', { binding }, () => { + mockSetSettings({ ...mockSettings, quickNoteHotkey: binding }); + return undefined; + }); +} + // 风格包直达快捷键:整表替换(前端任何增删改都发全量列表,issue #759)。 export function setStylePackHotkeys(hotkeys: StylePackHotkey[]): Promise { return invokeOrMock('set_style_pack_hotkeys', { hotkeys }, () => { diff --git a/openless-all/app/src/lib/ipc/index.ts b/openless-all/app/src/lib/ipc/index.ts index 5f984a570..971f257c8 100644 --- a/openless-all/app/src/lib/ipc/index.ts +++ b/openless-all/app/src/lib/ipc/index.ts @@ -65,6 +65,7 @@ export { getActivityStats, readAudioRecording, retranscribeRecording, + applyQuickNoteRepolish, } from './history'; // vocab @@ -140,6 +141,7 @@ export { setTranslationHotkey, setSwitchStyleHotkey, setOpenAppHotkey, + setQuickNoteHotkey, setStylePackHotkeys, setShortcutRecordingActive, } from './hotkeys'; diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index eefc40e39..24a2480bb 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -70,6 +70,7 @@ export let mockSettings: UserPreferences = { workingLanguages: ['简体中文'], translationTargetLanguage: '', qaHotkey: defaultQaShortcut(), + quickNoteHotkey: null, selectionPolishStylePackId: 'builtin.light', selectionPolishOutputMode: 'directReplace', selectionPolishHotkey: defaultSelectionPolishShortcut(), @@ -138,6 +139,12 @@ export let mockSettings: UserPreferences = { androidOverlayActivationMode: 'tap', androidOverlayLeftSwipeAction: 'translation', androidOverlayCancelSwipeDirection: 'up', + androidOverlayGestureActions: { + up: 'cancel', + down: 'none', + left: 'translation', + right: 'qa', + }, androidOverlaySizeDp: 72, }; diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 701706543..d072149ef 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -7,6 +7,8 @@ import type { AndroidInsertStrategy, AndroidOverlayActivationMode, AndroidOverlayCancelSwipeDirection, + AndroidOverlayGestureAction, + AndroidOverlayGestureActions, AndroidOverlayLeftSwipeAction, AndroidOverlayStatus, AndroidOverlayTrigger, @@ -17,6 +19,8 @@ export type { AndroidInsertStrategy, AndroidOverlayActivationMode, AndroidOverlayCancelSwipeDirection, + AndroidOverlayGestureAction, + AndroidOverlayGestureActions, AndroidOverlayLeftSwipeAction, AndroidOverlayStatus, AndroidOverlayTrigger, @@ -29,7 +33,14 @@ export type PolishMode = 'raw' | 'light' | 'structured' | 'formal'; * 两套配置在凭据库中完全隔离,运行时只读当前模式。 */ export type PipelineMode = 'traditional' | 'multimodal'; -export type InsertStatus = 'inserted' | 'pasteSent' | 'copiedFallback' | 'failed'; +export type InsertStatus = + | 'inserted' + | 'pasteSent' + | 'copiedFallback' + | 'failed' + | 'notRequested'; + +export type HistorySource = 'voice' | 'quick_note' | 'selection_polish' | 'selection_voice_edit'; /** 概览页年度活动热力图的单日计数(date = 本地日期 YYYY-MM-DD)。 */ export interface ActivityDay { @@ -44,6 +55,7 @@ export interface ActivityDay { export interface DictationSession { id: string; createdAt: string; // ISO-8601 + source?: HistorySource; rawTranscript: string; /** 纠正规则**之前**的 ASR 原文。`rawTranscript` 存的是规则跑完之后的版本, * 两者相同时后端不写这个字段(null)。用于归因:一次误识别到底是 ASR 听错还是 @@ -394,6 +406,8 @@ export interface UserPreferences { outputLanguagePreference: 'auto' | 'zhCn' | 'zhTw' | 'en' | 'ja' | 'ko'; /** 划词语音问答快捷键。null = 未启用。详见 issue #118。 */ qaHotkey: QaHotkeyBinding | null; + /** 独立速记快捷键。null = 未配置。 */ + quickNoteHotkey: ShortcutBinding | null; /** 选区润色快捷键。null = 已停用。 */ selectionPolishHotkey: ShortcutBinding | null; /** The style pack used only by selected written-text polishing. */ @@ -538,6 +552,8 @@ export interface UserPreferences { androidOverlayLeftSwipeAction: AndroidOverlayLeftSwipeAction; /** Android: vertical swipe direction that cancels recording. */ androidOverlayCancelSwipeDirection: AndroidOverlayCancelSwipeDirection; + /** Android: action assigned to each overlay swipe direction. */ + androidOverlayGestureActions: AndroidOverlayGestureActions; /** Android: floating overlay control diameter in dp. */ androidOverlaySizeDp: number; /** 开屏 PV 的主版本世代标记(如 '2')。空 = 从未播过;由 Rust 侧 diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index f91c7d20f..c44144751 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -9,6 +9,7 @@ import { detectOS } from '../components/WindowChrome'; import { formatComboLabel } from '../lib/hotkey'; import { clearHistory, + applyQuickNoteRepolish, deleteHistoryEntry, listHistory, listStylePacks, @@ -68,7 +69,7 @@ function styleLabelFor( return pack ? packDisplayName(pack, modeLabel) : modeLabel[session.mode]; } -export function History() { +export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } = {}) { const { t, i18n } = useTranslation(); const locale = i18n.resolvedLanguage || i18n.language; const os = detectOS(); @@ -123,10 +124,11 @@ export function History() { setLoadError(null); try { const data = await listHistory(); - setItems(data); + const visible = quickNotesOnly ? data.filter((entry) => entry.source === 'quick_note') : data; + setItems(visible); setActionError(null); setSelectedId((prev) => - prev && data.some((s) => s.id === prev) ? prev : (data[0]?.id ?? null), + prev && visible.some((s) => s.id === prev) ? prev : (visible[0]?.id ?? null), ); } catch (error) { console.error('[history] failed to load history', error); @@ -134,7 +136,7 @@ export function History() { } finally { setLoading(false); } - }, []); + }, [quickNotesOnly]); useEffect(() => { void refresh(); @@ -203,13 +205,19 @@ export function History() { ); const onClear = async () => { - if (items.length === 0) return; - if (!confirm(t('history.confirmClear', { count: items.length }))) return; + const clearable = items.filter((entry) => entry.source !== 'quick_note'); + if (clearable.length === 0) return; + if (!confirm(t('history.confirmClear', { count: clearable.length }))) return; setActionError(null); try { await clearHistory(); - setItems([]); - setSelectedId(null); + setItems((prev) => prev.filter((entry) => entry.source === 'quick_note')); + setSelectedId((current) => { + const remaining = items.filter((entry) => entry.source === 'quick_note'); + return current && remaining.some((entry) => entry.id === current) + ? current + : (remaining[0]?.id ?? null); + }); } catch (error) { console.error('[history] failed to clear history', error); setActionError(t('history.clearFailed', { err: errorMessage(error) })); @@ -305,6 +313,36 @@ export function History() { } }; + const onShareAudio = async () => { + if (!item?.hasAudioRecording) return; + try { + const dataUrl = await readAudioRecording(item.id); + const comma = dataUrl.indexOf(','); + const b64 = comma >= 0 ? dataUrl.slice(comma + 1) : ''; + if (!b64) throw new Error('empty recording'); + const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + const file = new File([bin], `openless-recording-${item.id}.wav`, { + type: 'audio/wav', + }); + if ( + !navigator.share || + (navigator.canShare && !navigator.canShare({ files: [file] })) + ) { + await onExportAudio(); + return; + } + await navigator.share({ + title: historyTitle(item, t), + files: [file], + }); + } catch (error) { + const msg = errorMessage(error); + if (!isUserCancelled(msg)) { + setActionError(t('history.exportFailed', { err: msg })); + } + } + }; + // 失败记录沿用 #613 的原地修复;已经插入过文字的完成 / 润色失败记录只显示临时结果, // 避免把事后重转文本伪装成当时实际插入的历史事实。 const onRetranscribe = async () => { @@ -340,17 +378,19 @@ export function History() { return (
void refresh()}> {t('common.refresh')} - - {t('common.clear')} - + {!quickNotesOnly && ( + + {t('common.clear')} + + )}
} /> @@ -459,7 +499,14 @@ export function History() { {debouncedQuery.trim() ? t('history.searchNoMatch', { query: debouncedQuery.trim() }) : t('history.empty', { - trigger: prefs ? formatComboLabel(prefs.dictationHotkey) : '', + trigger: prefs + ? formatComboLabel( + (quickNotesOnly ? prefs.quickNoteHotkey : prefs.dictationHotkey) ?? { + primary: '', + modifiers: [], + }, + ) + : '', })} )} @@ -531,7 +578,7 @@ export function History() { overflow: 'hidden', }} > - {s.finalText.split('\n')[0]} + {historyTitle(s, t)} {/* tone 仍按 baseMode 走:颜色保留原来的粗分类信息,文字换成实际风格包名。 */}
@@ -616,6 +663,13 @@ export function History() { {t('history.exportRecording')} )} + {os === 'android' && + item.hasAudioRecording && + !audioMissingIds.has(item.id) && ( + void onShareAudio()}> + {t('quickNote.shareRecording', 'Share audio')} + + )} {canRetranscribeHistoryEntry(item) && !audioMissingIds.has(item.id) && (
@@ -860,7 +916,7 @@ export function History() { whiteSpace: 'pre-line', }} > - {item.finalText} + {item.finalText || item.rawTranscript || t('quickNote.noTranscript', 'No transcript yet.')}

@@ -868,12 +924,18 @@ export function History() { 此时整块不渲染;QA 记录的原文是问题而不是待润色文本,同样不渲染。 key 让切换记录时结果与状态一起重置,避免把上一条的结果留在新条目下面; 前缀是为了跟上面播放器的 key 区分开(同层重复 key 会残留旧节点)。 */} - {item.rawTranscript.trim() && item.errorCode !== 'qaSession' && ( + {(item.rawTranscript.trim() || quickNotesOnly) && item.errorCode !== 'qaSession' && ( + setItems((prev) => + prev.map((entry) => (entry.id === updated.id ? updated : entry)), + ) + } key={`repolish-${item.id}`} /> )} @@ -896,6 +958,17 @@ export function History() { ); } +function historyTitle( + session: DictationSession, + t: ReturnType['t'], +): string { + const text = (session.finalText || session.rawTranscript).trim(); + if (text) return text.split(/\r?\n/, 1)[0]; + if (session.errorCode === 'recording') return t('quickNote.recording', 'Recording…'); + if (session.errorCode) return t('quickNote.failedTitle', 'Recording needs attention'); + return t('quickNote.emptyTitle', 'Untitled recording'); +} + /** 后端超时错误在 IPC 边界退化成裸字符串(LLMError::Timeout → "timeout")。 * 只匹配整串的常见超时形态,避免其它含 "timeout" 字样的错误被误判成超时。 */ function isTimeout(message: string): boolean { @@ -936,19 +1009,25 @@ function RepolishPanel({ mobile, allPacks, packsError, + persistOnApply, + onApplied, }: { session: DictationSession; mobile: boolean; /** History 顶层加载的**全部**风格包(含已禁用);null 表示还在加载。 */ allPacks: StylePack[] | null; packsError: string | null; + persistOnApply: boolean; + onApplied: (updated: DictationSession) => void; }) { const { t } = useTranslation(); const MODE_LABEL = useModeLabel(); const [selectedPackId, setSelectedPackId] = useState(''); const [running, setRunning] = useState<'retry' | 'apply' | null>(null); + const [applyingKey, setApplyingKey] = useState(null); const [error, setError] = useState(null); const [results, setResults] = useState([]); + const canRun = session.rawTranscript.trim().length > 0; // 只列启用的包:禁用的包在别处也不参与润色,这里列出来会让「应用」得到 // 一个用户以为已经关掉的风格。 @@ -966,7 +1045,7 @@ function RepolishPanel({ kind === 'apply' ? selectedPackId : resolveRepolishRetryPackIdWithFallback(session, allPacks, packs ?? []); - if (kind === 'apply' && !packId) return; + if (!canRun || (kind === 'apply' && !packId)) return; setRunning(kind); setError(null); try { @@ -996,6 +1075,24 @@ function RepolishPanel({ } }; + const applyResult = async (result: RepolishResult) => { + if (!persistOnApply) return; + setApplyingKey(result.key); + setError(null); + try { + const updated = await applyQuickNoteRepolish( + session.id, + result.text, + result.key === '__retry__' ? session.stylePackId ?? undefined : result.key, + ); + onApplied(updated); + } catch (err) { + setError(errorMessage(err)); + } finally { + setApplyingKey(null); + } + }; + return (
0 ? 14 : 0, }} > + {!canRun && ( +
+ {t('quickNote.repolishNeedsTranscript', 'Re-transcribe the audio before repolishing.')} +
+ )} void run('retry')} > {running === 'retry' ? t('history.repolish.retrying') : t('history.repolish.retry')} @@ -1089,7 +1191,7 @@ function RepolishPanel({ void run('apply')} > {running === 'apply' ? t('history.repolish.applying') : t('history.repolish.apply')} @@ -1117,7 +1219,14 @@ function RepolishPanel({ {results.length > 0 && (
{results.map((result) => ( - + void applyResult(result) : undefined} + /> ))}
)} @@ -1125,7 +1234,19 @@ function RepolishPanel({ ); } -function HistoryResultCard({ title, text }: { title: string; text: string }) { +function HistoryResultCard({ + title, + text, + applyLabel, + applying = false, + onApply, +}: { + title: string; + text: string; + applyLabel?: string; + applying?: boolean; + onApply?: () => void; +}) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); @@ -1176,6 +1297,16 @@ function HistoryResultCard({ title, text }: { title: string; text: string }) { {copied ? t('common.copied') : t('common.copy')}
)} + {onApply && text.trim() && ( + + {applying ? t('quickNote.applying', 'Applying…') : applyLabel} + + )}

+ {isDesktop() && ( + +

+ {t('quickNote.shortcutTitle', 'Quick note shortcut')} +
+
+ {t( + 'quickNote.shortcutDesc', + 'Press once to start a permanent audio capture, then press again to finish.', + )} +
+ {prefs && ( + { + await setQuickNoteHotkey(binding); + await updatePrefs({ ...prefs, quickNoteHotkey: binding }); + }} + onDisable={async () => { + await setQuickNoteHotkey(null); + await updatePrefs({ ...prefs, quickNoteHotkey: null }); + }} + /> + )} + + )} +
+ +
+
+ ); +} diff --git a/openless-all/app/src/pages/settings/ShortcutsSection.tsx b/openless-all/app/src/pages/settings/ShortcutsSection.tsx index 13b553e9c..b61077abc 100644 --- a/openless-all/app/src/pages/settings/ShortcutsSection.tsx +++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx @@ -16,6 +16,7 @@ import { setDictationHotkey, setOpenAppHotkey, setQaHotkey, + setQuickNoteHotkey, setStylePackHotkeys, setSwitchStyleHotkey, setTranslationHotkey, @@ -152,6 +153,25 @@ export function ShortcutsSection() { }} />
+ + { + await setQuickNoteHotkey(binding); + await savePrefs({ ...prefs, quickNoteHotkey: binding }); + }} + onDisable={async () => { + await setQuickNoteHotkey(null); + await savePrefs({ ...prefs, quickNoteHotkey: null }); + }} + /> + Date: Fri, 18 Sep 2026 20:14:09 +0800 Subject: [PATCH 2/5] fix: harden quick note recovery and archive lifecycle --- openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 5fb0ab995..cac95d42c 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -2299,6 +2299,7 @@ pub(crate) mod windows_less_computer_tests { translation_hotkey: Mutex::new(None), switch_style_hotkey: Mutex::new(None), open_app_hotkey: Mutex::new(None), + quick_note_hotkey: Mutex::new(None), style_pack_hotkeys: Mutex::new(std::collections::HashMap::new()), selection_polish_hotkey: Mutex::new(None), selection_voice_host: Arc::new(Mutex::new( From 6d296b5942c88bb9a04933e3e5d55468d60cb7a8 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Fri, 18 Sep 2026 22:07:28 +0800 Subject: [PATCH 3/5] fix: close quick note lifecycle races --- .../app/crates/openless-core/src/api.rs | 42 +++++++++++++++---- openless-all/app/src/i18n/de.ts | 1 + openless-all/app/src/i18n/en.ts | 1 + openless-all/app/src/i18n/es.ts | 1 + openless-all/app/src/i18n/fr.ts | 1 + openless-all/app/src/i18n/ja.ts | 1 + openless-all/app/src/i18n/ko.ts | 1 + openless-all/app/src/i18n/zh-CN.ts | 1 + openless-all/app/src/i18n/zh-TW.ts | 1 + openless-all/app/src/pages/History.tsx | 3 ++ 10 files changed, 46 insertions(+), 7 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 0a945cdff..ce5c76dee 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -4990,6 +4990,21 @@ impl OpenLessBackend { } if context.output_target != DictationOutputTarget::ForegroundApp { self.persist_recording_started(&context, session_id); + let still_recording = { + let state = self.state.read().expect("backend state lock poisoned"); + // A concurrent stop may already have moved the session to + // Transcribing/Polishing. The session id is the ownership + // guard; requiring Recording here would turn a valid stop + // race into a false cancellation. + state.dictation.session_id == Some(session_id) + }; + if !still_recording { + self.remove_recording_draft(session_id); + return Err(BackendError::new( + BackendErrorCode::Cancelled, + "dictation was cancelled while recording history was being persisted", + )); + } } Ok(session_id) } @@ -5438,6 +5453,18 @@ impl OpenLessBackend { } } + fn remove_recording_draft(&self, session_id: SessionId) { + let id = session_id.to_string(); + if self + .list_history() + .ok() + .and_then(|entries| entries.into_iter().find(|entry| entry.id == id)) + .is_some_and(|entry| entry.error_code.as_deref() == Some("recording")) + { + let _ = self.delete_history(&id); + } + } + fn history_created_at(&self, session_id: &str) -> String { self.list_history() .ok() @@ -5799,12 +5826,6 @@ impl OpenLessBackend { self.phase_changed.notify_waiters(); (active, preserve_quick_note) }; - let cancel_result = self.cancel_session_adapters(active).await; - // The state can already display cancellation, but native audio/input - // cleanup still owns the shared resource. Reject new capture until that - // cleanup finishes, including on its error path. - self.voice_sessions.release(active); - let host_result = self.hide_dictation_feedback(active); if preserve_quick_note { if let Some(mut entry) = self .list_history()? @@ -5815,7 +5836,14 @@ impl OpenLessBackend { entry.has_audio_recording = Some(true); let _ = self.update_history_entry(entry); } - } else { + } + let cancel_result = self.cancel_session_adapters(active).await; + // The state can already display cancellation, but native audio/input + // cleanup still owns the shared resource. Reject new capture until that + // cleanup finishes, including on its error path. + self.voice_sessions.release(active); + let host_result = self.hide_dictation_feedback(active); + if !preserve_quick_note { // Undecided captures that are explicitly cancelled are not notes; // remove their provisional row after native archive cleanup. let _ = self.delete_history(&active.to_string()); diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index b491394f6..2095c530b 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -915,6 +915,7 @@ export const de: typeof zhCN = { shortcutDesc: 'Einmal drücken zum Aufnehmen, erneut drücken zum Speichern.', repolishNeedsTranscript: 'Bitte zuerst die Audiodatei neu transkribieren.', shareRecording: 'Audio teilen', + cancelledTitle: 'Aufnahme abgebrochen', }, settings: { selectionWorkspace: { diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index b6b54b560..faba279e0 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -896,6 +896,7 @@ export const en: typeof zhCN = { shortcutDesc: 'Press once to start a permanent capture, then press again to finish.', repolishNeedsTranscript: 'Re-transcribe the audio before repolishing.', shareRecording: 'Share audio', + cancelledTitle: 'Recording cancelled', }, settings: { selectionWorkspace: { diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 8451733ca..f743f76b8 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -910,6 +910,7 @@ export const es: typeof zhCN = { shortcutDesc: 'Pulsa una vez para grabar y otra vez para guardar.', repolishNeedsTranscript: 'Primero vuelve a transcribir el audio.', shareRecording: 'Compartir audio', + cancelledTitle: 'Grabación cancelada', }, settings: { selectionWorkspace: { diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 452eb3d2f..329f56d4c 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -922,6 +922,7 @@ export const fr: typeof zhCN = { shortcutDesc: 'Appuyez une fois pour enregistrer, puis à nouveau pour sauvegarder.', repolishNeedsTranscript: 'Retranscrivez d’abord l’audio avant la réécriture.', shareRecording: 'Partager l’audio', + cancelledTitle: 'Enregistrement annulé', }, settings: { selectionWorkspace: { diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index db66ef865..87ad1dc2f 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -883,6 +883,7 @@ export const ja: typeof zhCN = { shortcutDesc: '一度押して録音を開始し、もう一度押して保存します。', repolishNeedsTranscript: '先に音声を再文字起こししてください。', shareRecording: '音声を共有', + cancelledTitle: '録音をキャンセルしました', }, settings: { selectionWorkspace: { diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 865edaec0..81f5d8b0a 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -881,6 +881,7 @@ export const ko: typeof zhCN = { shortcutDesc: '한 번 눌러 녹음하고 다시 눌러 저장합니다.', repolishNeedsTranscript: '먼저 오디오를 다시 전사해 주세요.', shareRecording: '오디오 공유', + cancelledTitle: '녹음이 취소됨', }, settings: { selectionWorkspace: { diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index f9bc45a61..2dc379070 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -866,6 +866,7 @@ export const zhCN = { shortcutDesc: '按一次开始永久录音,再按一次结束并保存。', repolishNeedsTranscript: '请先重新转录录音,再进行润色。', shareRecording: '分享录音', + cancelledTitle: '已取消录音', }, settings: { selectionWorkspace: { diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 3a0d0e2d2..9da1ef7be 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -868,6 +868,7 @@ export const zhTW: typeof zhCN = { shortcutDesc: '按一下開始永久錄音,再按一下結束並保存。', repolishNeedsTranscript: '請先重新轉錄錄音,再進行潤色。', shareRecording: '分享錄音', + cancelledTitle: '已取消錄音', }, settings: { selectionWorkspace: { diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index c44144751..6e7e9541a 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -965,6 +965,9 @@ function historyTitle( const text = (session.finalText || session.rawTranscript).trim(); if (text) return text.split(/\r?\n/, 1)[0]; if (session.errorCode === 'recording') return t('quickNote.recording', 'Recording…'); + if (session.errorCode === 'cancelled') { + return t('quickNote.cancelledTitle', 'Recording cancelled'); + } if (session.errorCode) return t('quickNote.failedTitle', 'Recording needs attention'); return t('quickNote.emptyTitle', 'Untitled recording'); } From 16338cc78788e20557b7cb4e0995aa1ee739f7b8 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sat, 19 Sep 2026 01:25:01 +0800 Subject: [PATCH 4/5] fix: polish quick note history UI and abort draft cleanup Harden History/QuickNote UX, export-directory prefs, and WAV archive queueing. Restore abort-safe recording-draft reconciliation so cancelled Quick Notes keep history while orphan Undecided drafts are removed. --- .../app/crates/openless-core/src/api.rs | 384 ++++++++++-- .../openless-core/src/dictation_engine.rs | 78 ++- .../crates/openless-core/src/shared_types.rs | 8 + .../openless-core/src/shortcut_types.rs | 22 +- .../app/src-tauri/src/commands/history.rs | 64 +- .../app/src-tauri/src/core_adapters.rs | 7 +- openless-all/app/src-tauri/src/recorder.rs | 157 ++++- openless-all/app/src/i18n/de.ts | 31 + openless-all/app/src/i18n/en.ts | 30 + openless-all/app/src/i18n/es.ts | 31 + openless-all/app/src/i18n/fr.ts | 31 + openless-all/app/src/i18n/ja.ts | 30 + openless-all/app/src/i18n/ko.ts | 30 + openless-all/app/src/i18n/zh-CN.ts | 30 + openless-all/app/src/i18n/zh-TW.ts | 30 + openless-all/app/src/lib/ipc/mock-data.ts | 1 + openless-all/app/src/lib/types.ts | 2 + openless-all/app/src/pages/History.tsx | 591 +++++++++++++----- openless-all/app/src/pages/QuickNote.tsx | 100 ++- openless-all/app/src/pages/_atoms.tsx | 9 + 20 files changed, 1372 insertions(+), 294 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index ce5c76dee..76c17e224 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -1362,6 +1362,10 @@ struct MutableState { running: bool, dictation: DictationStateSnapshot, dictation_context: Option>, + /// The requested output target is published before async context capture + /// completes, so a second quick-note hotkey edge can still resolve the + /// Starting session instead of being swallowed. + dictation_start_output_target: Option, /// Session-local intent, including a modifier pressed while AX/credentials /// are still being captured. The accepted request is applied before finish /// by every stop entry; no Host latch survives into the next session. @@ -2233,6 +2237,7 @@ impl OpenLessBackend { running: false, dictation: DictationStateSnapshot::default(), dictation_context: None, + dictation_start_output_target: None, dictation_translation_requested: None, credentials: CredentialsStatus::default(), transcripts: HashMap::new(), @@ -3105,6 +3110,7 @@ impl OpenLessBackend { state.running = false; state.dictation = DictationStateSnapshot::default(); state.dictation_context = None; + state.dictation_start_output_target = None; state.silence_monitor = None; state.transcripts.clear(); self.phase_changed.notify_waiters(); @@ -3216,12 +3222,12 @@ impl OpenLessBackend { } pub fn dictation_output_target(&self) -> Option { - self.state - .read() - .expect("backend state lock poisoned") + let state = self.state.read().expect("backend state lock poisoned"); + state .dictation_context .as_ref() .map(|context| context.output_target) + .or(state.dictation_start_output_target) } /// Dispatch a launcher/single-instance intent through the same state @@ -3347,8 +3353,12 @@ impl OpenLessBackend { // Bind an accepted physical press to its actual Starting session // before releasing the interpreter lock. An older CLI/button stop // must not clear this press between its Start decision and claim. - let reservation = matches!(intent, HotkeyIntent::Start { .. }) - .then(|| self.reserve_dictation_session(options.start.insert_text)); + let reservation = matches!(intent, HotkeyIntent::Start { .. }).then(|| { + self.reserve_dictation_session( + options.start.insert_text, + options.start.output_target, + ) + }); (intent, reservation) }; let (intent, reservation) = if let HotkeyIntent::WaitForModifierGrace { press_id } = intent @@ -3364,8 +3374,12 @@ impl OpenLessBackend { .lock() .expect("hotkey interpreter lock poisoned"); let intent = hotkey.after_modifier_grace(press_id, self.snapshot().dictation.phase); - let reservation = matches!(intent, HotkeyIntent::Start { .. }) - .then(|| self.reserve_dictation_session(options.start.insert_text)); + let reservation = matches!(intent, HotkeyIntent::Start { .. }).then(|| { + self.reserve_dictation_session( + options.start.insert_text, + options.start.output_target, + ) + }); (intent, reservation) } else { (intent, reservation) @@ -4714,6 +4728,7 @@ impl OpenLessBackend { fn reserve_dictation_session( &self, insert_text: bool, + output_target: DictationOutputTarget, ) -> Result { { let state = self.state.read().expect("backend state lock poisoned"); @@ -4752,6 +4767,7 @@ impl OpenLessBackend { session_id: Some(session_id), ..DictationStateSnapshot::default() }; + state.dictation_start_output_target = Some(output_target); state.dictation_translation_requested = None; self.events.publish( Some(session_id), @@ -4770,7 +4786,8 @@ impl OpenLessBackend { &self, options: DictationStartOptions, ) -> Result { - let reservation = self.reserve_dictation_session(options.insert_text)?; + let reservation = + self.reserve_dictation_session(options.insert_text, options.output_target)?; self.start_reserved_dictation(reservation, options).await } @@ -4827,6 +4844,7 @@ impl OpenLessBackend { }); state.dictation.translation_active = context.polish.translation_active; state.dictation_context = Some(Arc::clone(&context)); + state.dictation_start_output_target = None; context }; @@ -4919,6 +4937,9 @@ impl OpenLessBackend { } } } + if context.output_target != DictationOutputTarget::ForegroundApp { + self.persist_recording_started_if_starting(&context, session_id)?; + } let engine = Arc::clone(&self.deps.dictation_engine); let engine_context = Arc::clone(&context); let progress = self.engine_progress_sink(); @@ -4959,6 +4980,8 @@ impl OpenLessBackend { None, None, ); + } else { + self.reconcile_aborted_recording_draft(session_id, &context); } let _ = self.cancel_session_adapters(session_id).await; let _ = self.hide_dictation_feedback(session_id); @@ -4983,29 +5006,12 @@ impl OpenLessBackend { }; if !started { let _ = self.cancel_session_adapters(session_id).await; + self.reconcile_aborted_recording_draft(session_id, &context); return Err(BackendError::new( BackendErrorCode::Cancelled, "dictation session was cancelled while the engine was starting", )); } - if context.output_target != DictationOutputTarget::ForegroundApp { - self.persist_recording_started(&context, session_id); - let still_recording = { - let state = self.state.read().expect("backend state lock poisoned"); - // A concurrent stop may already have moved the session to - // Transcribing/Polishing. The session id is the ownership - // guard; requiring Recording here would turn a valid stop - // race into a false cancellation. - state.dictation.session_id == Some(session_id) - }; - if !still_recording { - self.remove_recording_draft(session_id); - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "dictation was cancelled while recording history was being persisted", - )); - } - } Ok(session_id) } @@ -5453,6 +5459,27 @@ impl OpenLessBackend { } } + fn persist_recording_started_if_starting( + &self, + context: &DictationContext, + session_id: SessionId, + ) -> Result<(), BackendError> { + let state = self.state.write().expect("backend state lock poisoned"); + if state.dictation.session_id != Some(session_id) + || state.dictation.phase != DictationPhase::Starting + { + return Err(BackendError::new( + BackendErrorCode::Cancelled, + "dictation session was cancelled before recording history was persisted", + )); + } + // Keep the state write lock across the draft write. Cancellation also + // needs this lock before it can update the same history row, so it + // cannot overwrite a terminal result with a late recording draft. + self.persist_recording_started(context, session_id); + Ok(()) + } + fn remove_recording_draft(&self, session_id: SessionId) { let id = session_id.to_string(); if self @@ -5465,6 +5492,32 @@ impl OpenLessBackend { } } + fn reconcile_aborted_recording_draft( + &self, + session_id: SessionId, + context: &DictationContext, + ) { + // Quick Note cancellations must keep the provisional row (rewritten to + // cancelled) so playback/export still work. A racing cancel_dictation may + // already have done that rewrite; only touch still-provisional rows. + if context.output_target == DictationOutputTarget::QuickNote { + let id = session_id.to_string(); + if let Some(mut entry) = self + .list_history() + .ok() + .and_then(|entries| entries.into_iter().find(|entry| entry.id == id)) + { + if entry.error_code.as_deref() == Some("recording") { + entry.error_code = Some("cancelled".to_string()); + entry.has_audio_recording = Some(true); + let _ = self.update_history_entry(entry); + } + } + return; + } + self.remove_recording_draft(session_id); + } + fn history_created_at(&self, session_id: &str) -> String { self.list_history() .ok() @@ -5598,7 +5651,11 @@ impl OpenLessBackend { polish_source, app_bundle_id: front_app.bundle_id, app_name: front_app.name, - insert_status: HistoryInsertStatus::Failed, + insert_status: if context.insertion.enabled { + HistoryInsertStatus::Failed + } else { + HistoryInsertStatus::NotRequested + }, error_code: Some(error_code.to_string()), duration_ms, dictionary_entry_count: None, @@ -5653,6 +5710,7 @@ impl OpenLessBackend { } state.dictation = DictationStateSnapshot::default(); state.dictation_context = None; + state.dictation_start_output_target = None; state.silence_monitor = None; state.transcripts.remove(&session_id); hotkey.terminal(std::time::Instant::now()); @@ -5811,9 +5869,7 @@ impl OpenLessBackend { let preserve_quick_note = state .dictation_context .as_ref() - .is_some_and(|context| { - context.output_target == DictationOutputTarget::QuickNote - }); + .is_some_and(|context| context.output_target == DictationOutputTarget::QuickNote); state.dictation.phase = DictationPhase::Cancelled; self.events.publish( Some(active), @@ -5821,36 +5877,46 @@ impl OpenLessBackend { ); state.dictation = DictationStateSnapshot::default(); state.dictation_context = None; + state.dictation_start_output_target = None; state.silence_monitor = None; state.transcripts.remove(&active); self.phase_changed.notify_waiters(); (active, preserve_quick_note) }; - if preserve_quick_note { - if let Some(mut entry) = self - .list_history()? - .into_iter() - .find(|entry| entry.id == active.to_string()) - { - entry.error_code = Some("cancelled".to_string()); - entry.has_audio_recording = Some(true); - let _ = self.update_history_entry(entry); - } - } + // Settle history before tearing down adapters. The abandoned starter can + // observe Cancelled as soon as cancel_session_adapters runs; rewriting or + // deleting the draft first prevents it from racing remove_recording_draft + // against a still-provisional Quick Note row. + let history_result = if preserve_quick_note { + self.list_history().map(|entries| { + if let Some(mut entry) = entries + .into_iter() + .find(|entry| entry.id == active.to_string()) + { + entry.error_code = Some("cancelled".to_string()); + entry.has_audio_recording = Some(true); + let _ = self.update_history_entry(entry); + } + }) + } else { + // Undecided captures that are explicitly cancelled are not notes. + let _ = self.delete_history(&active.to_string()); + Ok(()) + }; let cancel_result = self.cancel_session_adapters(active).await; // The state can already display cancellation, but native audio/input // cleanup still owns the shared resource. Reject new capture until that // cleanup finishes, including on its error path. self.voice_sessions.release(active); let host_result = self.hide_dictation_feedback(active); - if !preserve_quick_note { - // Undecided captures that are explicitly cancelled are not notes; - // remove their provisional row after native archive cleanup. - let _ = self.delete_history(&active.to_string()); + let first_error = cancel_result + .err() + .or_else(|| host_result.err()) + .or_else(|| history_result.err()); + match first_error { + Some(error) => Err(error), + None => Ok(()), } - cancel_result?; - host_result?; - Ok(()) } async fn capture_dictation_context( @@ -11527,6 +11593,228 @@ mod tests { ); } + #[tokio::test] + async fn aborted_startup_removes_orphan_recording_draft() { + struct DelayedStartEngine { + entered: Arc, + release: Arc, + } + + impl DictationEngine for DelayedStartEngine { + fn start( + &self, + _session_id: SessionId, + _context: Arc, + _progress: Arc, + ) -> BoxFuture<'static, Result<(), BackendError>> { + let entered = Arc::clone(&self.entered); + let release = Arc::clone(&self.release); + boxed(async move { + entered.notify_waiters(); + release.acquire().await.unwrap().forget(); + Ok(()) + }) + } + + fn finish( + &self, + _session_id: SessionId, + _progress: Arc, + ) -> BoxFuture<'static, Result> { + boxed(async { + Ok(EngineResult { + raw_text: String::new(), + asr_transcript: None, + polished_text: String::new(), + polish_source: None, + duration_ms: 0, + polish_failed: false, + asr_ms: None, + polish_ms: None, + has_audio_recording: None, + asr_call_label: None, + llm_call_label: None, + }) + }) + } + + fn cancel(&self, _session_id: SessionId) -> BoxFuture<'static, Result<(), BackendError>> { + boxed(async { Ok(()) }) + } + } + + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Semaphore::new(0)); + let data_dir = TestDataDir::new("abort-startup-orphan-draft"); + let backend = OpenLessBackend::new( + BackendConfig { + data_dir: data_dir.path().to_path_buf(), + ..BackendConfig::default() + }, + BackendDependencies { + host_actions: Arc::new(FakeHost::default()), + text_inserter: Arc::new(FakeInserter), + dictation_engine: Arc::new(DelayedStartEngine { + entered: Arc::clone(&entered), + release: Arc::clone(&release), + }), + credential_store: Arc::new(crate::credentials::InMemoryCredentialStore::default()), + task_spawner: Arc::new(TokioTaskSpawner), + ..BackendDependencies::unsupported() + }, + ) + .unwrap(); + backend.start().await.unwrap(); + + let mut starting = Box::pin(backend.start_dictation_with_options(DictationStartOptions { + output_target: DictationOutputTarget::Undecided, + insert_text: false, + ..DictationStartOptions::default() + })); + // Drive the starter until the provisional recording draft is written and + // the delayed engine is waiting inside start(). + let wait_entered = entered.notified(); + tokio::pin!(wait_entered); + loop { + tokio::select! { + biased; + _ = &mut wait_entered => break, + _ = &mut starting => panic!("start settled before delayed engine entered"), + } + } + let draft = backend + .list_history() + .unwrap() + .into_iter() + .find(|entry| entry.error_code.as_deref() == Some("recording")); + assert!( + draft.is_some(), + "undecided startup must persist a provisional recording draft" + ); + + // Shutdown clears backend state without rewriting history. The abandoned + // starter must still drop the orphan recording row. + backend.shutdown().await.unwrap(); + release.add_permits(1); + let error = starting.await.unwrap_err(); + assert_eq!(error.code, BackendErrorCode::Cancelled); + assert!( + backend + .list_history() + .unwrap() + .into_iter() + .all(|entry| entry.error_code.as_deref() != Some("recording")), + "aborted startup must not leave an orphan recording draft" + ); + } + + #[tokio::test] + async fn quick_note_cancel_during_startup_preserves_cancelled_history() { + struct DelayedStartEngine { + entered: Arc, + release: Arc, + } + + impl DictationEngine for DelayedStartEngine { + fn start( + &self, + _session_id: SessionId, + _context: Arc, + _progress: Arc, + ) -> BoxFuture<'static, Result<(), BackendError>> { + let entered = Arc::clone(&self.entered); + let release = Arc::clone(&self.release); + boxed(async move { + entered.notify_waiters(); + release.acquire().await.unwrap().forget(); + Ok(()) + }) + } + + fn finish( + &self, + _session_id: SessionId, + _progress: Arc, + ) -> BoxFuture<'static, Result> { + boxed(async { + Ok(EngineResult { + raw_text: String::new(), + asr_transcript: None, + polished_text: String::new(), + polish_source: None, + duration_ms: 0, + polish_failed: false, + asr_ms: None, + polish_ms: None, + has_audio_recording: None, + asr_call_label: None, + llm_call_label: None, + }) + }) + } + + fn cancel(&self, _session_id: SessionId) -> BoxFuture<'static, Result<(), BackendError>> { + boxed(async { Ok(()) }) + } + } + + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Semaphore::new(0)); + let data_dir = TestDataDir::new("quick-note-cancel-startup-preserve"); + let backend = OpenLessBackend::new( + BackendConfig { + data_dir: data_dir.path().to_path_buf(), + ..BackendConfig::default() + }, + BackendDependencies { + host_actions: Arc::new(FakeHost::default()), + text_inserter: Arc::new(FakeInserter), + dictation_engine: Arc::new(DelayedStartEngine { + entered: Arc::clone(&entered), + release: Arc::clone(&release), + }), + credential_store: Arc::new(crate::credentials::InMemoryCredentialStore::default()), + task_spawner: Arc::new(TokioTaskSpawner), + ..BackendDependencies::unsupported() + }, + ) + .unwrap(); + backend.start().await.unwrap(); + + let mut starting = Box::pin(backend.start_dictation_with_options(DictationStartOptions { + output_target: DictationOutputTarget::QuickNote, + insert_text: false, + ..DictationStartOptions::default() + })); + let wait_entered = entered.notified(); + tokio::pin!(wait_entered); + loop { + tokio::select! { + biased; + _ = &mut wait_entered => break, + _ = &mut starting => panic!("start settled before delayed engine entered"), + } + } + let session_id = backend.snapshot().dictation.session_id.expect("active session"); + assert_eq!( + backend.list_history().unwrap()[0].error_code.as_deref(), + Some("recording") + ); + + let cancel = backend.cancel_dictation(Some(session_id)); + release.add_permits(1); + let (start_result, cancel_result) = tokio::join!(starting, cancel); + assert_eq!(start_result.unwrap_err().code, BackendErrorCode::Cancelled); + cancel_result.unwrap(); + + let history = backend.list_history().unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].id, session_id.to_string()); + assert_eq!(history[0].error_code.as_deref(), Some("cancelled")); + assert_eq!(history[0].source, HistorySource::QuickNote); + assert_eq!(history[0].has_audio_recording, Some(true)); + } + #[tokio::test] async fn engine_receives_start_finish_and_cancel_lifecycle_calls() { use crate::testing::{ diff --git a/openless-all/app/crates/openless-core/src/dictation_engine.rs b/openless-all/app/crates/openless-core/src/dictation_engine.rs index 09cd11482..456017f19 100644 --- a/openless-all/app/crates/openless-core/src/dictation_engine.rs +++ b/openless-all/app/crates/openless-core/src/dictation_engine.rs @@ -10,13 +10,13 @@ use std::sync::{Arc, Mutex, RwLock}; use futures_util::future::BoxFuture; -use crate::dictation_context::DictationContext; +use crate::dictation_context::{DictationContext, DictationOutputTarget}; use crate::errors::{BackendError, BackendErrorCode}; use crate::ports::{ ActiveRecording, AudioCapture, AudioConsumer, AudioRecorder, CapturedPcm, DictationEngine, EngineFailure, EngineFailureStage, EngineProgress, EngineProgressSink, EngineResult, - EngineStage, RecordingProgressSink, TextPolisher, TextStreamChunk, TextStreamSink, - TranscriptionEngine, TranscriptionSession, VoiceCapture, + EngineStage, RecordingArchive, RecordingProgressSink, TextPolisher, TextStreamChunk, + TextStreamSink, TranscriptionEngine, TranscriptionSession, VoiceCapture, }; use crate::types::{PolishDelta, SessionId, TranscriptDelta}; @@ -350,6 +350,12 @@ impl DictationEngine for PipelineDictationEngine { let archive = recording.archive(); let mut has_audio_recording = archive.as_ref().map(|archive| archive.is_available()); if let Err(error) = recording.stop().await { + demote_failed_archive( + archive.as_ref(), + context.output_target, + &mut has_audio_recording, + ) + .await; let _ = cancel_transcription_once(&session, transcription).await; remove_session(&sessions, session_id, &session); let mut failure = EngineFailure::new(error, EngineFailureStage::Transcribing); @@ -437,6 +443,12 @@ impl DictationEngine for PipelineDictationEngine { } Err((error, label)) => { asr_call_label = label.or(asr_call_label); + demote_failed_archive( + archive.as_ref(), + context.output_target, + &mut has_audio_recording, + ) + .await; remove_session(&sessions, session_id, &session); let mut failure = EngineFailure::new(error, EngineFailureStage::Transcribing); @@ -447,6 +459,12 @@ impl DictationEngine for PipelineDictationEngine { } }, None => { + demote_failed_archive( + archive.as_ref(), + context.output_target, + &mut has_audio_recording, + ) + .await; remove_session(&sessions, session_id, &session); let error = if cancelled { cancelled_error( @@ -467,6 +485,9 @@ impl DictationEngine for PipelineDictationEngine { }; let asr_ms = Some(asr_started.elapsed().as_millis() as u64); if session.cancelled.load(Ordering::Acquire) { + if !context.recording.archive_successful_recording { + discard_ephemeral_archive(archive.as_ref(), &mut has_audio_recording).await; + } remove_session(&sessions, session_id, &session); return Err(cancelled_error( "dictation was cancelled after transcription finished", @@ -480,13 +501,16 @@ impl DictationEngine for PipelineDictationEngine { ); let asr_transcript = (transcript.text != original_asr_text).then_some(original_asr_text); - if !context.recording.archive_successful_recording && !transcript.text.trim().is_empty() - { - if let Some(archive) = archive.as_ref() { - if archive.is_available() { - let _ = archive.discard().await; - } - has_audio_recording = Some(archive.is_available()); + if !context.recording.archive_successful_recording { + if transcript.text.trim().is_empty() { + demote_failed_archive( + archive.as_ref(), + context.output_target, + &mut has_audio_recording, + ) + .await; + } else { + discard_ephemeral_archive(archive.as_ref(), &mut has_audio_recording).await; } } publish_progress( @@ -692,6 +716,40 @@ impl DictationEngine for PipelineDictationEngine { } } +async fn discard_ephemeral_archive( + archive: Option<&Arc>, + has_audio_recording: &mut Option, +) { + let Some(archive) = archive else { + return; + }; + if archive.is_available() { + if let Err(error) = archive.discard().await { + log::warn!("[recording] failed to discard ephemeral archive: {error}"); + } + } + *has_audio_recording = Some(archive.is_available()); +} + +async fn demote_failed_archive( + archive: Option<&Arc>, + output_target: DictationOutputTarget, + has_audio_recording: &mut Option, +) { + if output_target == DictationOutputTarget::QuickNote { + return; + } + let Some(archive) = archive else { + return; + }; + if archive.is_available() { + if let Err(error) = archive.demote_to_ordinary_recording().await { + log::warn!("[recording] failed to demote failed archive: {error}"); + } + } + *has_audio_recording = Some(archive.is_available()); +} + fn find_session( sessions: &Arc>>>, session_id: SessionId, diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index ab4400997..89755528a 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -669,6 +669,9 @@ pub struct UserPreferences { /// 这种「文本档案多 + 录音不占盘」组合下精确控制。 #[serde(default)] pub audio_recording_max_entries: Option, + /// 速记导出的录音文件保存目录。空字符串表示每次导出时弹出保存对话框。 + #[serde(default)] + pub quick_note_export_directory: String, /// Style Pack Marketplace HTTP 基地址。空 = 本地开发默认 http://127.0.0.1:8090; /// 用户在 Settings 里填生产 URL (如 https://api.openless-marketplace.com)。 #[serde(default)] @@ -952,6 +955,8 @@ struct UserPreferencesWire { #[serde(default)] audio_recording_max_entries: Option, #[serde(default)] + quick_note_export_directory: String, + #[serde(default)] marketplace_base_url: String, #[serde(default)] marketplace_dev_login: String, @@ -1112,6 +1117,7 @@ impl Default for UserPreferencesWire { history_max_entries: prefs.history_max_entries, record_audio_for_debug: prefs.record_audio_for_debug, audio_recording_max_entries: prefs.audio_recording_max_entries, + quick_note_export_directory: prefs.quick_note_export_directory.clone(), marketplace_base_url: prefs.marketplace_base_url, marketplace_dev_login: prefs.marketplace_dev_login, android_insert_strategy: prefs.android_insert_strategy, @@ -1310,6 +1316,7 @@ impl<'de> Deserialize<'de> for UserPreferences { history_max_entries: wire.history_max_entries, record_audio_for_debug: wire.record_audio_for_debug, audio_recording_max_entries: wire.audio_recording_max_entries, + quick_note_export_directory: wire.quick_note_export_directory, marketplace_base_url: wire.marketplace_base_url, marketplace_dev_login: wire.marketplace_dev_login, android_insert_strategy: normalize_android_insert_strategy( @@ -1656,6 +1663,7 @@ impl Default for UserPreferences { history_max_entries: None, record_audio_for_debug: false, audio_recording_max_entries: None, + quick_note_export_directory: String::new(), marketplace_base_url: String::new(), marketplace_dev_login: String::new(), android_insert_strategy: default_android_insert_strategy(), diff --git a/openless-all/app/crates/openless-core/src/shortcut_types.rs b/openless-all/app/crates/openless-core/src/shortcut_types.rs index a300ce3e9..c62c7b88e 100644 --- a/openless-all/app/crates/openless-core/src/shortcut_types.rs +++ b/openless-all/app/crates/openless-core/src/shortcut_types.rs @@ -597,6 +597,7 @@ pub fn reconcile_hotkey_collisions( Qa, SwitchStyle, OpenApp, + QuickNote, SelectionPolish, LessComputer, } @@ -608,6 +609,7 @@ pub fn reconcile_hotkey_collisions( Self::Qa => preferences.qa_hotkey.clone(), Self::SwitchStyle => preferences.switch_style_hotkey.clone(), Self::OpenApp => preferences.open_app_hotkey.clone(), + Self::QuickNote => preferences.quick_note_hotkey.clone(), Self::SelectionPolish => preferences.selection_polish_hotkey.clone(), Self::LessComputer => preferences.coding_agent_voice_hotkey.clone(), } @@ -623,6 +625,7 @@ pub fn reconcile_hotkey_collisions( Self::Qa => preferences.qa_hotkey = value, Self::SwitchStyle => preferences.switch_style_hotkey = value, Self::OpenApp => preferences.open_app_hotkey = value, + Self::QuickNote => preferences.quick_note_hotkey = value, Self::SelectionPolish => preferences.selection_polish_hotkey = value, Self::LessComputer => preferences.coding_agent_voice_hotkey = value, } @@ -642,11 +645,12 @@ pub fn reconcile_hotkey_collisions( } } - const ORDER: [NonCoreHotkey; 6] = [ + const ORDER: [NonCoreHotkey; 7] = [ NonCoreHotkey::Translation, NonCoreHotkey::Qa, NonCoreHotkey::SwitchStyle, NonCoreHotkey::OpenApp, + NonCoreHotkey::QuickNote, NonCoreHotkey::SelectionPolish, NonCoreHotkey::LessComputer, ]; @@ -924,6 +928,22 @@ mod tests { assert!(reject_hotkey_collisions(&next).is_ok()); } + #[test] + fn settings_reconciliation_includes_quick_note_conflicts() { + let previous = UserPreferences { + quick_note_hotkey: Some(combo("N", &["ctrl", "shift"])), + ..UserPreferences::default() + }; + let mut next = previous.clone(); + next.quick_note_hotkey = Some(next.dictation_hotkey.clone()); + + let adjusted = reconcile_hotkey_collisions(&mut next, &previous); + + assert_eq!(adjusted, 1); + assert_eq!(next.quick_note_hotkey, previous.quick_note_hotkey); + assert!(reject_hotkey_collisions(&next).is_ok()); + } + #[test] fn settings_reconciliation_treats_style_pack_shortcuts_as_lowest_priority() { let previous = UserPreferences::default(); diff --git a/openless-all/app/src-tauri/src/commands/history.rs b/openless-all/app/src-tauri/src/commands/history.rs index 2a38e1a05..67be47523 100644 --- a/openless-all/app/src-tauri/src/commands/history.rs +++ b/openless-all/app/src-tauri/src/commands/history.rs @@ -8,6 +8,9 @@ pub fn list_history(core: CoreState<'_>) -> Result, String #[tauri::command] pub fn delete_history_entry(core: CoreState<'_>, id: String) -> Result<(), String> { + if !is_valid_session_id(&id) { + return Err("invalid session id".into()); + } if core .snapshot() .dictation @@ -46,6 +49,9 @@ pub fn get_activity_stats(core: CoreState<'_>) -> Vec { } fn recording_path_candidates(session_id: &str) -> Result<[std::path::PathBuf; 2], String> { + if !is_valid_session_id(session_id) { + return Err("invalid session id".into()); + } Ok([ crate::persistence::recording_path_for_session(session_id).map_err(|e| e.to_string())?, crate::persistence::quick_note_recording_path_for_session(session_id) @@ -121,27 +127,55 @@ pub async fn read_audio_recording(session_id: String) -> Result #[tauri::command] pub async fn export_audio_recording( app: tauri::AppHandle, + core: CoreState<'_>, session_id: String, ) -> Result { if !is_valid_session_id(&session_id) { return Err("invalid session id".into()); } + // 速记的导出目录只作用于 quick_note,普通历史记录继续沿用每次选择保存文件的 + // 对话框,避免用户在速记里配置的目录意外改变其它历史记录的导出行为。 + let is_quick_note = core + .list_history() + .map(|entries| { + entries.into_iter().any(|entry| { + entry.id == session_id && entry.source == openless_core::HistorySource::QuickNote + }) + }) + .unwrap_or(false); + let configured_directory = if is_quick_note { + core.get_preferences() + .quick_note_export_directory + .trim() + .to_string() + } else { + String::new() + }; + tokio::task::spawn_blocking(move || -> Result { - let file_path = app - .dialog() - .file() - .add_filter("WAV audio", &["wav"]) - .set_file_name(format!("openless-recording-{session_id}.wav")) - .blocking_save_file(); + let src = existing_recording_path(&session_id)?; - let Some(file_path) = file_path else { - return Err("user cancelled".into()); - }; + if configured_directory.is_empty() { + let file_path = app + .dialog() + .file() + .add_filter("WAV audio", &["wav"]) + .set_file_name(format!("openless-recording-{session_id}.wav")) + .blocking_save_file(); - let src = existing_recording_path(&session_id)?; + let Some(file_path) = file_path else { + return Err("user cancelled".into()); + }; + + return export_recording_to_destination(&app, file_path, &src); + } - export_recording_to_destination(&app, file_path, &src) + let directory = std::path::PathBuf::from(configured_directory); + std::fs::create_dir_all(&directory).map_err(export_recording_failed)?; + let destination = directory.join(format!("openless-recording-{session_id}.wav")); + copy_recording_to_path(&src, &destination)?; + Ok(destination.to_string_lossy().into_owned()) }) .await .map_err(|e| format!("internal error: {e}"))? @@ -359,7 +393,13 @@ pub fn apply_quick_note_repolish( #[cfg(test)] mod retranscription_tests { - use super::should_replace_failed_history; + use super::{recording_path_candidates, should_replace_failed_history}; + + #[test] + fn recording_paths_reject_non_session_ids() { + assert!(recording_path_candidates("../../victim").is_err()); + assert!(recording_path_candidates("not-a-session").is_err()); + } #[test] fn only_failed_transcriptions_are_replaced() { diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index bc06aa210..c432c40dc 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -953,6 +953,7 @@ impl TauriLocalAsrRuntimeAdapter { )) }) } + } #[derive(Clone)] @@ -2641,9 +2642,12 @@ impl AudioRecorder for TauriAudioRecorder { .transpose(); let microphone = context.recording.microphone_device_name.clone(); let recording_plan = context.recording.clone(); + let prune_recordings_before_capture = recording_plan.archive_enabled + && (!recording_plan.archive_required + || context.output_target == openless_core::DictationOutputTarget::Undecided); let fault_progress = Arc::clone(&progress); let (recording, runtime_errors) = tauri::async_runtime::spawn_blocking(move || { - if recording_plan.archive_enabled && !recording_plan.archive_required { + if prune_recordings_before_capture { if let Err(error) = crate::persistence::prune_recordings( recording_plan.retention_days, recording_plan.max_entries, @@ -3206,7 +3210,6 @@ impl openless_core::HostContextAdapter for TauriHostContextAdapter { }) }) } - } pub(crate) struct TauriHostActions { diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index 9513c1c88..1466cf0e1 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::mpsc::{channel, Receiver, Sender}; +use std::sync::mpsc::{channel, sync_channel, Receiver, Sender, SyncSender, TrySendError}; use std::sync::Arc; use std::thread::{self, JoinHandle}; @@ -28,6 +28,9 @@ const TARGET_SAMPLE_RATE: u32 = 16_000; const LOG_EVERY_N_CALLBACKS: usize = 50; /// RMS → UI 电平的放大系数,与 Swift 端 `min(1.0, rms * 4)` 一致。 const LEVEL_RMS_GAIN: f32 = 4.0; +/// 归档写线程的最大待写 PCM 块数。满载时丢弃新的归档块而不阻塞实时回调; +/// 识别链路仍继续收到完整 PCM,停止时已接受的归档消息会被完整刷完。 +const WAV_ARCHIVE_QUEUE_CAPACITY: usize = 256; /// 接收已重采样 Int16 PCM 字节流(小端)的下游。 pub trait AudioConsumer: Send + Sync { @@ -79,17 +82,86 @@ impl RecorderError { } } +enum WavArchiveMessage { + Pcm(Vec), + Finish, +} + +/// 非实时音频回调中的 WAV 归档入口。回调只负责复制 PCM 并把消息放进 +/// 有界 channel;文件写入、seek 和 sync 全部在专用线程执行。 +struct WavArchiveWriter { + sender: SyncSender, + join_handle: Mutex>>, + queue_full_warned: AtomicBool, +} + +impl WavArchiveWriter { + fn create(path: &Path) -> std::io::Result { + let archiver = WavArchiver::create(path)?; + let (sender, receiver) = sync_channel::(WAV_ARCHIVE_QUEUE_CAPACITY); + let join_handle = thread::Builder::new() + .name("openless-wav-archive".into()) + .spawn(move || run_wav_archive_writer(archiver, receiver))?; + Ok(Self { + sender, + join_handle: Mutex::new(Some(join_handle)), + queue_full_warned: AtomicBool::new(false), + }) + } + + fn append(&self, pcm_bytes: &[u8]) { + match self + .sender + .try_send(WavArchiveMessage::Pcm(pcm_bytes.to_vec())) + { + Ok(()) => {} + Err(TrySendError::Full(_)) => { + if !self.queue_full_warned.swap(true, Ordering::Relaxed) { + log::warn!( + "[recorder] wav archive queue is full; dropping archive PCM until the writer catches up" + ); + } + } + Err(TrySendError::Disconnected(_)) => { + if !self.queue_full_warned.swap(true, Ordering::Relaxed) { + log::warn!("[recorder] wav archive writer stopped before PCM was queued"); + } + } + } + } + + fn finish(&self) { + let _ = self.sender.send(WavArchiveMessage::Finish); + if let Some(handle) = self.join_handle.lock().take() { + if let Err(error) = handle.join() { + log::warn!("[recorder] wav archive writer join failed: {error:?}"); + } + } + } +} + +fn run_wav_archive_writer(mut archiver: WavArchiver, receiver: Receiver) { + while let Ok(message) = receiver.recv() { + match message { + WavArchiveMessage::Pcm(pcm_bytes) => archiver.append(&pcm_bytes), + WavArchiveMessage::Finish => break, + } + } +} + /// 采集器句柄。Drop 时不会自动停止——必须显式调用 `stop`。 pub struct Recorder { stop_flag: Arc, join_handle: Mutex>>, + archive_writer: Option>, } impl Recorder { /// 启动采集。`consumer` 收到 16 kHz/Mono/Int16-LE 的 PCM; /// `level_handler` 收到 0..1 的 RMS 电平。 /// `audio_archive_path` 不为 None 时,同样的 16 kHz/Mono/Int16-LE 旁路写入 WAV 文件, - /// 用于 debug 麦克风灵敏度 / ASR 误识别。Drop 时自动回填 RIFF / data 长度。 + /// 用于 debug 麦克风灵敏度 / ASR 误识别。专用写线程负责落盘,Drop 时自动回填 + /// RIFF / data 长度。 /// /// 返回值第三个 `bool` = "archive 实际成功创建":caller 写 history 时应当用这个值 /// 决定 `has_audio_recording`,而不是 prefs 开关。开关打开但写盘失败(路径不存在 / @@ -109,43 +181,68 @@ impl Recorder { let stop_flag = Arc::new(AtomicBool::new(false)); let stop_for_thread = Arc::clone(&stop_flag); - // 同步路径上尝试创建 WavArchiver——成功 / 失败都立刻知道,传给 caller 决定 + // 同步路径上尝试创建 WavArchiveWriter——成功 / 失败都立刻知道,传给 caller 决定 // 是否在 history 标 has_audio_recording。失败仅 log::warn 不抛错,主路径继续。 - let archiver = audio_archive_path.and_then(|path| match WavArchiver::create(&path) { - Ok(arch) => Some(Arc::new(Mutex::new(arch))), - Err(err) => { - log::warn!("[recorder] wav archive create failed at {path:?}: {err}"); - None - } - }); - let archive_active = archiver.is_some(); + let archive_writer = + audio_archive_path.and_then(|path| match WavArchiveWriter::create(&path) { + Ok(writer) => Some(Arc::new(writer)), + Err(err) => { + log::warn!("[recorder] wav archive create failed at {path:?}: {err}"); + None + } + }); + let archive_active = archive_writer.is_some(); + let archive_for_thread = archive_writer.clone(); - let join_handle = thread::Builder::new() + let join_handle = match thread::Builder::new() .name("openless-recorder".into()) .spawn(move || { run_audio_thread( microphone_device_name, consumer, level_handler, - archiver, + archive_for_thread, stop_for_thread, startup_tx, runtime_error_tx, ); - }) - .map_err(|e| RecorderError::EngineFailed(format!("spawn audio thread: {e}")))?; + }) { + Ok(handle) => handle, + Err(error) => { + if let Some(archive) = archive_writer.as_ref() { + archive.finish(); + } + return Err(RecorderError::EngineFailed(format!( + "spawn audio thread: {error}" + ))); + } + }; // 等待子线程报告启动结果。子线程要么 Send Ok 后继续 park, // 要么 Send Err 后立即退出——两种情况都保证 recv 能解锁。 - let startup_result = startup_rx - .recv() - .map_err(|e| RecorderError::EngineFailed(format!("audio thread vanished: {e}")))?; - startup_result?; + let startup_result = match startup_rx.recv() { + Ok(result) => result, + Err(error) => { + if let Some(archive) = archive_writer.as_ref() { + archive.finish(); + } + return Err(RecorderError::EngineFailed(format!( + "audio thread vanished: {error}" + ))); + } + }; + if let Err(error) = startup_result { + if let Some(archive) = archive_writer.as_ref() { + archive.finish(); + } + return Err(error); + } Ok(( Self { stop_flag, join_handle: Mutex::new(Some(join_handle)), + archive_writer, }, runtime_error_rx, archive_active, @@ -156,12 +253,20 @@ impl Recorder { /// /// 用 `self`(消费)签名,与 Swift API 语义一致——一次性资源。 pub fn stop(self) { - self.stop_flag.store(true, Ordering::SeqCst); - if let Some(handle) = self.join_handle.lock().take() { + let Recorder { + stop_flag, + join_handle, + archive_writer, + } = self; + stop_flag.store(true, Ordering::SeqCst); + if let Some(handle) = join_handle.lock().take() { if let Err(err) = handle.join() { log::warn!("recorder 线程 join 失败: {:?}", err); } } + if let Some(archive) = archive_writer { + archive.finish(); + } } } @@ -198,7 +303,7 @@ fn run_audio_thread( microphone_device_name: Option, consumer: Arc, level_handler: Arc, - archiver: Option>>, + archiver: Option>, stop_flag: Arc, startup_tx: Sender>, runtime_error_tx: Sender, @@ -348,7 +453,7 @@ fn build_input_stream( microphone_device_name: Option, consumer: Arc, level_handler: Arc, - archiver: Option>>, + archiver: Option>, runtime_error_tx: Sender, ) -> Result<(cpal::Stream, Arc), RecorderError> { let host = cpal::default_host(); @@ -507,7 +612,7 @@ fn build_stream_for_format( sample_format: SampleFormat, consumer: Arc, level_handler: Arc, - archiver: Option>>, + archiver: Option>, state: Arc, input_sr: u32, channels: usize, @@ -603,7 +708,7 @@ fn process_callback( input_sr: u32, consumer: &dyn AudioConsumer, level_handler: &(dyn Fn(f32) + Send + Sync), - archiver: Option<&Mutex>, + archiver: Option<&WavArchiveWriter>, state: &StreamState, ) { if interleaved.is_empty() || channels == 0 { @@ -623,7 +728,7 @@ fn process_callback( consumer.consume_pcm_chunk(&pcm_bytes); if let Some(arch) = archiver { - arch.lock().append(&pcm_bytes); + arch.append(&pcm_bytes); } level_handler(level); diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 2095c530b..d432157b6 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -507,14 +507,24 @@ export const de: typeof zhCN = { clearFailed: 'Verlauf konnte nicht geleert werden: {{err}}', deleteFailed: 'Eintrag konnte nicht gelöscht werden: {{err}}', copyFailed: 'Kopieren fehlgeschlagen: {{err}}', + actionMenu: 'Aufnahmeaktionen', playRecording: 'Aufnahme abspielen', audioLoading: 'Wird geladen…', audioDecodeFailed: 'Audio konnte nicht dekodiert werden: {{err}}', exportRecording: 'Aufnahme exportieren', exportFailed: 'Export fehlgeschlagen: {{err}}', + chooseSaveDirectory: 'Speicherort für Transkriptdateien wählen', + saveDirectoryPrompt: 'Ordner für Transkriptdateien eingeben', + saveDirectory: 'Speicherort für Transkriptdateien festlegen', + changeSaveDirectory: 'Speicherort für Transkriptdateien ändern', + resetSaveDirectory: 'Standard-Speicherort verwenden', + defaultSaveDirectory: 'Bei jedem Export wählen', + saveDirectoryFailed: 'Speicherort konnte nicht aktualisiert werden: {{err}}', retranscribe: 'Erneut transkribieren', retranscribing: 'Wird transkribiert…', retranscribeFailed: 'Erneute Transkription fehlgeschlagen: {{err}}', + showRaw: 'Rohtext anzeigen', + hideRaw: 'Rohtext ausblenden', rawLabel: 'Rohtext', rawEmpty: '(leer)', selectHint: 'Wähle links einen Eintrag aus, um die Details anzuzeigen.', @@ -913,6 +923,7 @@ export const de: typeof zhCN = { applying: 'Wird angewendet …', shortcutTitle: 'Schnellnotizen-Kurzbefehl', shortcutDesc: 'Einmal drücken zum Aufnehmen, erneut drücken zum Speichern.', + showShortcut: 'Schnellnotizen-Kurzbefehl anzeigen', repolishNeedsTranscript: 'Bitte zuerst die Audiodatei neu transkribieren.', shareRecording: 'Audio teilen', cancelledTitle: 'Aufnahme abgebrochen', @@ -1530,6 +1541,9 @@ export const de: typeof zhCN = { descNoAcc: 'Alle Kurzbefehle gelten global. Falls sie nicht reagieren, prüfe ihren Status unter „Berechtigungen“.', startStop: 'Aufnahme starten / beenden', + quickNote: 'Schnellnotiz', + quickNoteDesc: + 'Einmal drücken, um eine dauerhafte Audionotiz zu starten, und erneut drücken, um sie zu beenden.', cancel: 'Aktuelle Aufnahme abbrechen', confirm: 'Einfügen über die Kapsel bestätigen', switchStyle: 'Zum vorherigen Stil wechseln', @@ -1748,6 +1762,23 @@ export const de: typeof zhCN = { up: 'Während der Aufnahme nach oben wischen, um ohne Transkription oder Einfügen abzubrechen.', down: 'Während der Aufnahme nach unten wischen, um ohne Transkription oder Einfügen abzubrechen.', }, + androidOverlayGestureActionsLabel: 'Wischaktionen des schwebenden Fensters', + androidOverlayGestureActionsDesc: + 'Diese Aktionen gelten während der Aufnahme. Ein normaler Tipp beendet das Diktat; ein Schnellnotiz-Wisch speichert das Audio dauerhaft.', + androidOverlayGestureDirection: { + up: 'Nach oben', + down: 'Nach unten', + left: 'Nach links', + right: 'Nach rechts', + }, + androidOverlayGestureAction: { + none: 'Keine Aktion', + quick_note: 'Schnellnotiz', + translation: 'Übersetzung', + style_pack: 'Stil wechseln', + cancel: 'Abbrechen', + qa: 'Fragen', + }, windowsIme: { installed: 'Installiert. Die Spracheingabe wechselt vorübergehend zur OpenLess-Eingabemethode.', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index faba279e0..479433bee 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -499,14 +499,24 @@ export const en: typeof zhCN = { clearFailed: 'Failed to clear history: {{err}}', deleteFailed: 'Failed to delete entry: {{err}}', copyFailed: 'Failed to copy: {{err}}', + actionMenu: 'Recording actions', playRecording: 'Play recording', audioLoading: 'Loading…', audioDecodeFailed: 'Audio decode failed: {{err}}', exportRecording: 'Export recording', exportFailed: 'Failed to export: {{err}}', + chooseSaveDirectory: 'Choose transcript file location', + saveDirectoryPrompt: 'Enter the transcript file directory', + saveDirectory: 'Set transcript file location', + changeSaveDirectory: 'Change transcript file location', + resetSaveDirectory: 'Use the default save location', + defaultSaveDirectory: 'Choose each time you export', + saveDirectoryFailed: 'Failed to update save location: {{err}}', retranscribe: 'Retranscribe', retranscribing: 'Transcribing…', retranscribeFailed: 'Retranscribe failed: {{err}}', + showRaw: 'Show raw transcript', + hideRaw: 'Hide raw transcript', rawLabel: 'Raw', rawEmpty: '(empty)', selectHint: 'Select an entry on the left to see details.', @@ -894,6 +904,7 @@ export const en: typeof zhCN = { applying: 'Applying…', shortcutTitle: 'Quick note shortcut', shortcutDesc: 'Press once to start a permanent capture, then press again to finish.', + showShortcut: 'Show quick note shortcut', repolishNeedsTranscript: 'Re-transcribe the audio before repolishing.', shareRecording: 'Share audio', cancelledTitle: 'Recording cancelled', @@ -1494,6 +1505,8 @@ export const en: typeof zhCN = { descNoAcc: 'All shortcuts apply globally. If unresponsive, check the global hotkey status in Permissions.', startStop: 'Start / Stop recording', + quickNote: 'Quick note', + quickNoteDesc: 'Press once to start a permanent audio note, and again to finish it.', cancel: 'Cancel current recording', confirm: 'Confirm capsule insertion', switchStyle: 'Switch to previous style', @@ -1698,6 +1711,23 @@ export const en: typeof zhCN = { up: 'Swipe up while recording to cancel without transcription or insertion.', down: 'Swipe down while recording to cancel without transcription or insertion.', }, + androidOverlayGestureActionsLabel: 'Overlay swipe actions', + androidOverlayGestureActionsDesc: + 'These actions apply while recording. A normal tap finishes ordinary dictation; a Quick note swipe keeps the audio permanently.', + androidOverlayGestureDirection: { + up: 'Up', + down: 'Down', + left: 'Left', + right: 'Right', + }, + androidOverlayGestureAction: { + none: 'No action', + quick_note: 'Quick note', + translation: 'Translation', + style_pack: 'Switch style', + cancel: 'Cancel', + qa: 'Ask', + }, windowsIme: { installed: 'Installed. Voice input temporarily switches to the OpenLess IME.', notInstalled: 'Not installed. OpenLess is using the clipboard/WM_PASTE fallback.', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index f743f76b8..8e5cdfbcc 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -506,14 +506,24 @@ export const es: typeof zhCN = { clearFailed: 'No se pudo borrar el historial: {{err}}', deleteFailed: 'No se pudo eliminar el registro: {{err}}', copyFailed: 'No se pudo copiar: {{err}}', + actionMenu: 'Acciones de grabación', playRecording: 'Reproducir grabación', audioLoading: 'Cargando…', audioDecodeFailed: 'No se pudo decodificar el audio: {{err}}', exportRecording: 'Exportar grabación', exportFailed: 'No se pudo exportar: {{err}}', + chooseSaveDirectory: 'Elegir ubicación de los archivos transcritos', + saveDirectoryPrompt: 'Introduce la carpeta de los archivos transcritos', + saveDirectory: 'Configurar ubicación de archivos transcritos', + changeSaveDirectory: 'Cambiar ubicación de archivos transcritos', + resetSaveDirectory: 'Usar la ubicación predeterminada', + defaultSaveDirectory: 'Elegir cada vez que exportes', + saveDirectoryFailed: 'No se pudo actualizar la ubicación: {{err}}', retranscribe: 'Volver a transcribir', retranscribing: 'Transcribiendo…', retranscribeFailed: 'No se pudo volver a transcribir: {{err}}', + showRaw: 'Mostrar transcripción original', + hideRaw: 'Ocultar transcripción original', rawLabel: 'Original', rawEmpty: '(vacío)', selectHint: 'Selecciona un registro de la izquierda para ver sus detalles.', @@ -908,6 +918,7 @@ export const es: typeof zhCN = { applying: 'Aplicando…', shortcutTitle: 'Atajo de nota rápida', shortcutDesc: 'Pulsa una vez para grabar y otra vez para guardar.', + showShortcut: 'Mostrar el atajo de nota rápida', repolishNeedsTranscript: 'Primero vuelve a transcribir el audio.', shareRecording: 'Compartir audio', cancelledTitle: 'Grabación cancelada', @@ -1522,6 +1533,9 @@ export const es: typeof zhCN = { descNoAcc: 'Todos los atajos funcionan globalmente. Si no responden, comprueba el estado de los atajos globales en Permisos.', startStop: 'Iniciar / detener grabación', + quickNote: 'Nota rápida', + quickNoteDesc: + 'Pulsa una vez para iniciar una nota de audio permanente y otra vez para finalizarla.', cancel: 'Cancelar la grabación actual', confirm: 'Confirmar inserción de la cápsula', switchStyle: 'Cambiar al estilo anterior', @@ -1735,6 +1749,23 @@ export const es: typeof zhCN = { up: 'Durante la grabación, desliza hacia arriba para cancelar sin transcribir ni insertar.', down: 'Durante la grabación, desliza hacia abajo para cancelar sin transcribir ni insertar.', }, + androidOverlayGestureActionsLabel: 'Acciones de deslizamiento de la superposición', + androidOverlayGestureActionsDesc: + 'Estas acciones se aplican durante la grabación. Un toque normal termina el dictado; un deslizamiento de nota rápida conserva el audio.', + androidOverlayGestureDirection: { + up: 'Arriba', + down: 'Abajo', + left: 'Izquierda', + right: 'Derecha', + }, + androidOverlayGestureAction: { + none: 'Sin acción', + quick_note: 'Nota rápida', + translation: 'Traducción', + style_pack: 'Cambiar estilo', + cancel: 'Cancelar', + qa: 'Preguntar', + }, windowsIme: { installed: 'Instalado. La entrada de voz cambia temporalmente al IME de OpenLess.', notInstalled: 'Sin instalar. OpenLess usa la alternativa de portapapeles / WM_PASTE.', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 329f56d4c..156737eee 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -512,14 +512,24 @@ export const fr: typeof zhCN = { clearFailed: 'Impossible d’effacer l’historique : {{err}}', deleteFailed: 'Impossible de supprimer l’entrée : {{err}}', copyFailed: 'Impossible de copier : {{err}}', + actionMenu: 'Actions d’enregistrement', playRecording: 'Lire l’enregistrement', audioLoading: 'Chargement…', audioDecodeFailed: 'Impossible de décoder l’audio : {{err}}', exportRecording: 'Exporter l’enregistrement', exportFailed: 'Échec de l’exportation : {{err}}', + chooseSaveDirectory: 'Choisir l’emplacement des fichiers transcrits', + saveDirectoryPrompt: 'Saisir le dossier des fichiers transcrits', + saveDirectory: 'Définir l’emplacement des fichiers transcrits', + changeSaveDirectory: 'Modifier l’emplacement des fichiers transcrits', + resetSaveDirectory: 'Utiliser l’emplacement par défaut', + defaultSaveDirectory: 'Choisir à chaque export', + saveDirectoryFailed: 'Impossible de modifier l’emplacement : {{err}}', retranscribe: 'Retranscrire', retranscribing: 'Transcription…', retranscribeFailed: 'Échec de la nouvelle transcription : {{err}}', + showRaw: 'Afficher la transcription brute', + hideRaw: 'Masquer la transcription brute', rawLabel: 'Brut', rawEmpty: '(vide)', selectHint: 'Sélectionnez une entrée à gauche pour afficher ses détails.', @@ -920,6 +930,7 @@ export const fr: typeof zhCN = { applying: 'Application…', shortcutTitle: 'Raccourci de note vocale', shortcutDesc: 'Appuyez une fois pour enregistrer, puis à nouveau pour sauvegarder.', + showShortcut: 'Afficher le raccourci de note vocale', repolishNeedsTranscript: 'Retranscrivez d’abord l’audio avant la réécriture.', shareRecording: 'Partager l’audio', cancelledTitle: 'Enregistrement annulé', @@ -1542,6 +1553,9 @@ export const fr: typeof zhCN = { descNoAcc: 'Tous les raccourcis sont globaux. S’ils ne répondent pas, vérifiez l’état du raccourci global dans Autorisations.', startStop: 'Démarrer / arrêter l’enregistrement', + quickNote: 'Note rapide', + quickNoteDesc: + 'Appuyez une fois pour démarrer une note audio permanente, puis une seconde fois pour la terminer.', cancel: 'Annuler l’enregistrement actuel', confirm: 'Confirmer l’insertion de la capsule', switchStyle: 'Passer au style précédent', @@ -1755,6 +1769,23 @@ export const fr: typeof zhCN = { up: 'Balayez vers le haut pendant l’enregistrement pour annuler sans transcription ni insertion.', down: 'Balayez vers le bas pendant l’enregistrement pour annuler sans transcription ni insertion.', }, + androidOverlayGestureActionsLabel: 'Actions de balayage de la superposition', + androidOverlayGestureActionsDesc: + 'Ces actions s’appliquent pendant l’enregistrement. Un appui normal termine la dictée ; un balayage de note rapide conserve l’audio.', + androidOverlayGestureDirection: { + up: 'Haut', + down: 'Bas', + left: 'Gauche', + right: 'Droite', + }, + androidOverlayGestureAction: { + none: 'Aucune action', + quick_note: 'Note rapide', + translation: 'Traduction', + style_pack: 'Changer de style', + cancel: 'Annuler', + qa: 'Demander', + }, windowsIme: { installed: 'Installé. La saisie vocale passe temporairement à l’IME d’OpenLess.', notInstalled: diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 87ad1dc2f..47c484670 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -487,14 +487,24 @@ export const ja: typeof zhCN = { clearFailed: '履歴の消去に失敗:{{err}}', deleteFailed: '記録の削除に失敗:{{err}}', copyFailed: 'コピーに失敗:{{err}}', + actionMenu: '録音の操作', playRecording: '録音を再生', audioLoading: '読み込み中…', audioDecodeFailed: '音声デコード失敗:{{err}}', exportRecording: '録音をエクスポート', exportFailed: 'エクスポート失敗:{{err}}', + chooseSaveDirectory: '文字起こしファイルの保存場所を選択', + saveDirectoryPrompt: '文字起こしファイルの保存フォルダーを入力', + saveDirectory: '文字起こしファイルの保存場所を設定', + changeSaveDirectory: '文字起こしファイルの保存場所を変更', + resetSaveDirectory: '既定の保存場所に戻す', + defaultSaveDirectory: 'エクスポート時に毎回選択', + saveDirectoryFailed: '保存場所の更新に失敗:{{err}}', retranscribe: '再認識', retranscribing: '認識中…', retranscribeFailed: '再認識に失敗:{{err}}', + showRaw: '原文を表示', + hideRaw: '原文を隠す', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左側から 1 件選択して詳細を表示。', @@ -881,6 +891,7 @@ export const ja: typeof zhCN = { applying: '適用中…', shortcutTitle: '速記ショートカット', shortcutDesc: '一度押して録音を開始し、もう一度押して保存します。', + showShortcut: '速記ショートカットを表示', repolishNeedsTranscript: '先に音声を再文字起こししてください。', shareRecording: '音声を共有', cancelledTitle: '録音をキャンセルしました', @@ -1479,6 +1490,8 @@ export const ja: typeof zhCN = { descNoAcc: 'すべてのショートカットはグローバルで有効。応答がない場合は権限ページでグローバルショートカット監視の状態を確認してください。', startStop: '録音開始 / 停止', + quickNote: '速記', + quickNoteDesc: '1回押して音声メモを開始し、もう一度押して終了します。', cancel: '本回の録音をキャンセル', confirm: 'カプセル入力を確定', switchStyle: '前のスタイルに切り替え', @@ -1668,6 +1681,23 @@ export const ja: typeof zhCN = { up: '録音中に上へスワイプすると、文字起こしや挿入をせずにキャンセルします。', down: '録音中に下へスワイプすると、文字起こしや挿入をせずにキャンセルします。', }, + androidOverlayGestureActionsLabel: 'オーバーレイのスワイプ操作', + androidOverlayGestureActionsDesc: + '録音中に適用されます。通常のタップで通常の音声入力を終了し、速記スワイプで音声を永続保存します。', + androidOverlayGestureDirection: { + up: '上', + down: '下', + left: '左', + right: '右', + }, + androidOverlayGestureAction: { + none: '操作なし', + quick_note: '速記', + translation: '翻訳', + style_pack: 'スタイル切替', + cancel: 'キャンセル', + qa: '質問', + }, windowsIme: { installed: 'インストール済み。音声入力時に OpenLess IME へ一時的に切り替えます。', notInstalled: diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 81f5d8b0a..7277eb060 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -485,14 +485,24 @@ export const ko: typeof zhCN = { clearFailed: '기록 비우기 실패: {{err}}', deleteFailed: '항목 삭제 실패: {{err}}', copyFailed: '복사 실패: {{err}}', + actionMenu: '녹음 작업', playRecording: '녹음 재생', audioLoading: '로딩 중…', audioDecodeFailed: '오디오 디코딩 실패: {{err}}', exportRecording: '녹음 내보내기', exportFailed: '내보내기 실패: {{err}}', + chooseSaveDirectory: '전사 파일 저장 위치 선택', + saveDirectoryPrompt: '전사 파일 저장 폴더 입력', + saveDirectory: '전사 파일 저장 위치 설정', + changeSaveDirectory: '전사 파일 저장 위치 변경', + resetSaveDirectory: '기본 저장 위치 사용', + defaultSaveDirectory: '내보낼 때마다 선택', + saveDirectoryFailed: '저장 위치 업데이트 실패: {{err}}', retranscribe: '다시 인식', retranscribing: '인식 중…', retranscribeFailed: '다시 인식 실패: {{err}}', + showRaw: '원문 보기', + hideRaw: '원문 숨기기', rawLabel: '원문', rawEmpty: '(비어 있음)', selectHint: '왼쪽에서 하나를 선택하여 자세히 보기.', @@ -879,6 +889,7 @@ export const ko: typeof zhCN = { applying: '적용 중…', shortcutTitle: '속기 단축키', shortcutDesc: '한 번 눌러 녹음하고 다시 눌러 저장합니다.', + showShortcut: '속기 단축키 표시', repolishNeedsTranscript: '먼저 오디오를 다시 전사해 주세요.', shareRecording: '오디오 공유', cancelledTitle: '녹음이 취소됨', @@ -1469,6 +1480,8 @@ export const ko: typeof zhCN = { descNoAcc: '모든 단축키는 전역에서 작동. 응답이 없으면 권한 페이지에서 전역 단축키 감지 상태를 확인해 주세요.', startStop: '녹음 시작 / 정지', + quickNote: '속기', + quickNoteDesc: '한 번 눌러 영구 오디오 메모를 시작하고, 다시 눌러 종료합니다.', cancel: '이번 녹음 취소', confirm: '캡슐 입력 확정', switchStyle: '이전 스타일로 전환', @@ -1657,6 +1670,23 @@ export const ko: typeof zhCN = { up: '녹음 중 위로 밀면 전사와 삽입 없이 취소합니다.', down: '녹음 중 아래로 밀면 전사와 삽입 없이 취소합니다.', }, + androidOverlayGestureActionsLabel: '오버레이 스와이프 동작', + androidOverlayGestureActionsDesc: + '녹음 중 적용됩니다. 일반 탭은 일반 받아쓰기를 종료하고, 속기 스와이프는 오디오를 영구 보관합니다.', + androidOverlayGestureDirection: { + up: '위', + down: '아래', + left: '왼쪽', + right: '오른쪽', + }, + androidOverlayGestureAction: { + none: '동작 없음', + quick_note: '속기', + translation: '번역', + style_pack: '스타일 전환', + cancel: '취소', + qa: '질문', + }, windowsIme: { installed: '설치됨. 음성 입력 시 OpenLess 입력기로 일시 전환됩니다.', notInstalled: '설치되지 않음. OpenLess 는 현재 클립보드 / WM_PASTE 폴백을 사용합니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 2dc379070..0e7a68818 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -479,14 +479,24 @@ export const zhCN = { clearFailed: '清空失败:{{err}}', deleteFailed: '删除失败:{{err}}', copyFailed: '复制失败:{{err}}', + actionMenu: '录音操作', playRecording: '播放录音', audioLoading: '加载中…', audioDecodeFailed: '音频解码失败:{{err}}', exportRecording: '导出录音', exportFailed: '导出失败:{{err}}', + chooseSaveDirectory: '选择转写文件保存位置', + saveDirectoryPrompt: '输入转写文件保存目录', + saveDirectory: '设置转写文件保存位置', + changeSaveDirectory: '更改转写文件保存位置', + resetSaveDirectory: '恢复默认保存位置', + defaultSaveDirectory: '每次导出时选择', + saveDirectoryFailed: '保存位置更新失败:{{err}}', retranscribe: '重新转录', retranscribing: '转录中…', retranscribeFailed: '重新转录失败:{{err}}', + showRaw: '查看原文', + hideRaw: '隐藏原文', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左侧选一条查看详情。', @@ -864,6 +874,7 @@ export const zhCN = { applying: '应用中…', shortcutTitle: '速记快捷键', shortcutDesc: '按一次开始永久录音,再按一次结束并保存。', + showShortcut: '显示速记快捷键', repolishNeedsTranscript: '请先重新转录录音,再进行润色。', shareRecording: '分享录音', cancelledTitle: '已取消录音', @@ -1415,6 +1426,8 @@ export const zhCN = { descAcc: '所有快捷键全局生效,需要在权限设置中开启辅助功能。', descNoAcc: '所有快捷键全局生效。若无响应,请在权限页查看全局快捷键监听状态。', startStop: '开始 / 停止录音', + quickNote: '速记', + quickNoteDesc: '按一次开始永久保留的录音,再按一次结束录音。', cancel: '取消本次录音', confirm: '胶囊确认插入', switchStyle: '切换到上一个风格', @@ -1604,6 +1617,23 @@ export const zhCN = { up: '录音中向上滑取消本次听写,不转写、不插入。', down: '录音中向下滑取消本次听写,不转写、不插入。', }, + androidOverlayGestureActionsLabel: '悬浮窗滑动动作', + androidOverlayGestureActionsDesc: + '录音时生效。普通点按结束普通听写;速记滑动会永久保留录音。', + androidOverlayGestureDirection: { + up: '上', + down: '下', + left: '左', + right: '右', + }, + androidOverlayGestureAction: { + none: '无动作', + quick_note: '速记', + translation: '翻译', + style_pack: '切换风格', + cancel: '取消', + qa: '追问', + }, windowsIme: { installed: '已安装,按需切到 OpenLess 输入法。', notInstalled: '未安装,走剪贴板 / WM_PASTE 兜底。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 9da1ef7be..aba74faf4 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -481,14 +481,24 @@ export const zhTW: typeof zhCN = { clearFailed: '清空失敗:{{err}}', deleteFailed: '刪除失敗:{{err}}', copyFailed: '複製失敗:{{err}}', + actionMenu: '錄音操作', playRecording: '播放錄音', audioLoading: '載入中…', audioDecodeFailed: '音訊解碼失敗:{{err}}', exportRecording: '匯出錄音', exportFailed: '匯出失敗:{{err}}', + chooseSaveDirectory: '選擇轉錄檔保存位置', + saveDirectoryPrompt: '輸入轉錄檔保存目錄', + saveDirectory: '設定轉錄檔保存位置', + changeSaveDirectory: '更改轉錄檔保存位置', + resetSaveDirectory: '恢復預設保存位置', + defaultSaveDirectory: '每次匯出時選擇', + saveDirectoryFailed: '保存位置更新失敗:{{err}}', retranscribe: '重新轉錄', retranscribing: '轉錄中…', retranscribeFailed: '重新轉錄失敗:{{err}}', + showRaw: '查看原文', + hideRaw: '隱藏原文', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左側選一條查看詳情。', @@ -866,6 +876,7 @@ export const zhTW: typeof zhCN = { applying: '套用中…', shortcutTitle: '速記快捷鍵', shortcutDesc: '按一下開始永久錄音,再按一下結束並保存。', + showShortcut: '顯示速記快捷鍵', repolishNeedsTranscript: '請先重新轉錄錄音,再進行潤色。', shareRecording: '分享錄音', cancelledTitle: '已取消錄音', @@ -1417,6 +1428,8 @@ export const zhTW: typeof zhCN = { descAcc: '所有快捷鍵全局生效,需要在權限設置中開啓輔助功能。', descNoAcc: '所有快捷鍵全局生效。若無響應,請在權限頁查看全局快捷鍵監聽狀態。', startStop: '開始 / 停止錄音', + quickNote: '速記', + quickNoteDesc: '按一次開始永久保留的錄音,再按一次結束錄音。', cancel: '取消本次錄音', confirm: '膠囊確認插入', switchStyle: '切換到上一個風格', @@ -1590,6 +1603,23 @@ export const zhTW: typeof zhCN = { up: '錄音中向上滑取消本次聽寫,不轉寫、不插入。', down: '錄音中向下滑取消本次聽寫,不轉寫、不插入。', }, + androidOverlayGestureActionsLabel: '懸浮窗滑動動作', + androidOverlayGestureActionsDesc: + '錄音時生效。普通點按結束普通聽寫;速記滑動會永久保留錄音。', + androidOverlayGestureDirection: { + up: '上', + down: '下', + left: '左', + right: '右', + }, + androidOverlayGestureAction: { + none: '無動作', + quick_note: '速記', + translation: '翻譯', + style_pack: '切換風格', + cancel: '取消', + qa: '追問', + }, windowsIme: { installed: '已安裝。語音輸入時會臨時切換到 OpenLess 輸入法。', notInstalled: '未安裝。OpenLess 正在使用剪貼板 / WM_PASTE 兜底。', diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 24a2480bb..f016a753b 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -128,6 +128,7 @@ export let mockSettings: UserPreferences = { historyMaxEntries: null, recordAudioForDebug: false, audioRecordingMaxEntries: null, + quickNoteExportDirectory: '', marketplaceBaseUrl: 'https://apic.openless.top', marketplaceDevLogin: '', remoteInputEnabled: false, diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index d072149ef..d8cced07d 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -530,6 +530,8 @@ export interface UserPreferences { /** recordings/ 里保留的最近 wav 文件数。null = 跟随 200 硬上限;1..=200 之间为用户自定义。 * 跟 historyMaxEntries 解耦——「文本档案多但 wav 只留最近 5 条」是合法组合。 */ audioRecordingMaxEntries: number | null; + /** 速记导出的录音文件保存目录。空字符串 = 每次导出时弹出保存对话框。 */ + quickNoteExportDirectory: string; /** Marketplace HTTP 基地址。空 = 本地开发默认 http://127.0.0.1:8090;生产填 https://api.。 */ marketplaceBaseUrl: string; /** GitHub login 展示缓存。不用于认证;OAuth token 只存在 Rust CredentialsVault。 */ diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index 6e7e9541a..b129c4485 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { Tooltip } from '../components/Tooltip'; +import { AssistantMarkdown } from '../components/chat/markdown'; import { detectOS } from '../components/WindowChrome'; import { formatComboLabel } from '../lib/hotkey'; import { @@ -26,7 +27,6 @@ import { import { canRetranscribeHistoryEntry } from '../lib/history-retranscribe'; import { useMobileLayout } from '../lib/useMobileLayout'; import type { DictationSession, PolishMode, StylePack } from '../lib/types'; -import { countCodePoints } from '../lib/unicode'; import { formatHistoryTime, formatLocaleDecimal, formatLocaleNumber } from '../lib/localeFormat'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; @@ -83,12 +83,16 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } const [actionError, setActionError] = useState(null); const [justCopied, setJustCopied] = useState(false); const [justCopiedRaw, setJustCopiedRaw] = useState(false); + const [showRawTranscript, setShowRawTranscript] = useState(false); + const [repolishOpen, setRepolishOpen] = useState(false); // 「重新转录」进行中:禁用按钮 + 显示「转录中…」,避免重复点击发起多次 ASR。 const [retranscribing, setRetranscribing] = useState(false); const [retranscriptionResult, setRetranscriptionResult] = useState<{ sessionId: string; text: string; } | null>(null); + const [playbackRequest, setPlaybackRequest] = useState(0); + const [audioLoading, setAudioLoading] = useState(false); // 录音文件 lazily-detected missing 状态:retention / 条数 cap 清理后磁盘上 wav // 可能已被删,但 history 条目 hasAudioRecording 仍写 true。任一组件 // (播放 / 导出)首次 IPC 拿到 'recording not found' 时把 id 加进来, @@ -103,7 +107,7 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } return next; }); }, []); - const { prefs } = useHotkeySettings(); + const { prefs, updatePrefs } = useHotkeySettings(); // The list/detail split needs space after the main sidebar and page padding. const mobile = useMobileLayout(1000); const [mobileDetailOpen, setMobileDetailOpen] = useState(() => !mobile); @@ -112,6 +116,7 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } useEffect(() => { if (!mobile) setMobileDetailOpen(true); }, [mobile]); + // 风格包在本页有两个用途:给历史条目显示包名、给「重新润色」面板选风格。加载提到这里 // 一次拿全,两处共用,省掉切换条目时 RepolishPanel 重挂载带来的重复 IPC。 // 注意这里存的是**全部**包(含已禁用):历史条目可能出自后来被禁用的包,显示名字要能查到; @@ -204,6 +209,14 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } [filtered, selectedId], ); + useEffect(() => { + setShowRawTranscript(false); + setRepolishOpen(false); + }, [item?.id]); + const handleAudioMissing = useCallback(() => { + if (item?.id) markAudioMissing(item.id); + }, [item?.id, markAudioMissing]); + const onClear = async () => { const clearable = items.filter((entry) => entry.source !== 'quick_note'); if (clearable.length === 0) return; @@ -343,6 +356,45 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } } }; + const onChooseExportDirectory = async () => { + if (!quickNotesOnly || !prefs || os === 'android') return; + try { + let picked: string | null = null; + if (isTauri) { + const { open } = await import('@tauri-apps/plugin-dialog'); + const selection = await open({ + directory: true, + multiple: false, + title: t('history.chooseSaveDirectory', '选择转写文件保存位置'), + }); + picked = Array.isArray(selection) ? null : selection; + } else { + picked = window.prompt( + t('history.saveDirectoryPrompt', '输入转写文件保存目录'), + prefs.quickNoteExportDirectory, + ); + } + const directory = picked?.trim(); + if (!directory) return; + await updatePrefs({ ...prefs, quickNoteExportDirectory: directory }); + setActionError(null); + } catch (error) { + console.error('[history] failed to choose export directory', error); + setActionError(t('history.saveDirectoryFailed', { err: errorMessage(error) })); + } + }; + + const onResetExportDirectory = async () => { + if (!prefs || !prefs.quickNoteExportDirectory) return; + try { + await updatePrefs({ ...prefs, quickNoteExportDirectory: '' }); + setActionError(null); + } catch (error) { + console.error('[history] failed to reset export directory', error); + setActionError(t('history.saveDirectoryFailed', { err: errorMessage(error) })); + } + }; + // 失败记录沿用 #613 的原地修复;已经插入过文字的完成 / 润色失败记录只显示临时结果, // 避免把事后重转文本伪装成当时实际插入的历史事实。 const onRetranscribe = async () => { @@ -516,6 +568,7 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } key={s.id} onClick={() => { setSelectedId(s.id); + setPlaybackRequest(0); if (mobile) setMobileDetailOpen(true); }} // 选中项不再用蓝色左条 + 淡蓝底 —— 与渠道行同一套 @@ -652,48 +705,37 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } })} -
- {item.hasAudioRecording && !audioMissingIds.has(item.id) && ( - void onExportAudio()} - > - {t('history.exportRecording')} - - )} - {os === 'android' && - item.hasAudioRecording && - !audioMissingIds.has(item.id) && ( - void onShareAudio()}> - {t('quickNote.shareRecording', 'Share audio')} - - )} - {canRetranscribeHistoryEntry(item) && !audioMissingIds.has(item.id) && ( - void onRetranscribe()} - > - {retranscribing ? t('history.retranscribing') : t('history.retranscribe')} - - )} - - {t('common.delete')} - -
+ setPlaybackRequest((request) => request + 1)} + onExport={() => void onExportAudio()} + onShare={() => void onShareAudio()} + onRetranscribe={() => void onRetranscribe()} + onDelete={onDelete} + onRepolish={() => setRepolishOpen(true)} + onChooseSaveDirectory={() => void onChooseExportDirectory()} + onResetSaveDirectory={() => void onResetExportDirectory()} + /> {/* key 必须带组件前缀:下面的 RepolishPanel 是同一层的兄弟节点,两个都写 裸 `item.id` 会让同层出现重复 key,React 只警告不报错,但 reconcile 匹配 - 不上旧 fiber —— 每切换一次历史条目就在 DOM 里残留一个「播放录音」按钮, + 不上旧 fiber —— 每切换一次历史条目就在 DOM 里残留一个播放控件, 开着不关的窗口能叠出一整列。 */} {item.hasAudioRecording && !audioMissingIds.has(item.id) && ( markAudioMissing(item.id)} + onMissing={handleAudioMissing} + playRequest={playbackRequest} + onLoadingChange={setAudioLoading} key={`audio-${item.id}`} /> )} @@ -705,9 +747,8 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } /> )} - {/* 流水线明细:识别 / 润色 / 插入 三步各占一行 —— 左列步骤名、中列 - provider·model(或插入目标),右列该步耗时/状态。旧历史没有模型与 - 耗时字段时对应行自动隐藏,只剩插入行 = 改版前的信息量。 */} + {/* 流水线明细只保留识别 / 润色两步 —— 左列步骤名、中列 provider·model, + 右列该步耗时/状态。插入属于前台投递细节,不在速记内容区展示。 */}
)} - {t('history.stepInsert')} - - {item.appName && ( - <> - {item.appName} - {' · '} - - )} - {/* 按 Unicode 码点计(emoji / CJK 扩展 B 等增补平面字符不按 UTF-16 码元双算), - 与后端 `polished.chars().count()` 及概览页「字数」口径一致。 */} - {t('history.chars', { count: countCodePoints(item.finalText) })} - {item.dictionaryEntryCount != null && item.dictionaryEntryCount > 0 && ( - <> - {' · '} - {t('history.vocabHits', { count: item.dictionaryEntryCount })} - - )} - - - {item.insertStatus === 'inserted' - ? t('history.inserted') - : item.insertStatus === 'pasteSent' - ? t('history.pasteSent') - : item.insertStatus === 'copiedFallback' - ? t('history.copiedFallback', { - shortcut: os === 'mac' ? '⌘V' : 'Ctrl+V', - }) - : item.insertStatus === 'notRequested' - ? t('history.notRequested', 'Not inserted') - : t('history.insertFailed')} -
- {/* minWidth: 0 —— grid 子项默认 min-width: auto,任何不换行的内容(这里是风格包名 - Pill)都会把整列撑出卡片、逼出横向滚动条。两栏都要加,否则一栏撑宽另一栏跟着宽。 */} -
-
-
- - {t('history.rawLabel')} - - {item.rawTranscript && ( - void onCopyRaw()} - > - {justCopiedRaw ? t('common.copied') : t('common.copy')} - - )} -
-

- {item.rawTranscript || t('history.rawEmpty')} -

-
+ {/* 默认只显示润色结果;原文仍可按需展开,避免用户每次都面对两栏重复内容。 */} +
{/* 润色结果框同样去蓝:中性 surface-2 底 + 细线描边。 */}
- {styleLabel(item)} + {t('history.stepPolish')} · {styleLabel(item)} - {/* 「复制」不能被长包名压缩:压窄后按钮文字会竖排。 */} - + + {item.rawTranscript && ( + setShowRawTranscript((visible) => !visible)} + > + {showRawTranscript + ? t('history.hideRaw', '隐藏原文') + : t('history.showRaw', '查看原文')} + + )}
-

+ +

+
+ {showRawTranscript && ( +
- {item.finalText || item.rawTranscript || t('quickNote.noTranscript', 'No transcript yet.')} -

-
+
+ + {t('history.stepAsr')} + + {item.rawTranscript && ( + void onCopyRaw()} + > + {justCopiedRaw ? t('common.copied') : t('common.copy')} + + )} +
+

+ {item.rawTranscript || t('history.rawEmpty')} +

+ + )} {/* 重新润色:拿这条的原文再跑一次 LLM。没有原文就没得润色(转录失败条目), 此时整块不渲染;QA 记录的原文是问题而不是待润色文本,同样不渲染。 key 让切换记录时结果与状态一起重置,避免把上一条的结果留在新条目下面; 前缀是为了跟上面播放器的 key 区分开(同层重复 key 会残留旧节点)。 */} - {(item.rawTranscript.trim() || quickNotesOnly) && item.errorCode !== 'qaSession' && ( + {repolishOpen && + (item.rawTranscript.trim() || quickNotesOnly) && + item.errorCode !== 'qaSession' && ( setRepolishOpen(false)} persistOnApply={quickNotesOnly} onApplied={(updated) => setItems((prev) => @@ -958,6 +975,231 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } ); } +interface HistoryActionMenuProps { + hasAudioRecording: boolean; + audioLoading: boolean; + showShare: boolean; + canRetranscribe: boolean; + retranscribing: boolean; + canRepolish: boolean; + showSaveDirectory: boolean; + exportDirectory: string; + onPlay: () => void; + onExport: () => void; + onShare: () => void; + onRetranscribe: () => void; + onDelete: () => void | Promise; + onRepolish: () => void; + onChooseSaveDirectory: () => void; + onResetSaveDirectory: () => void; +} + +function HistoryActionMenu({ + hasAudioRecording, + audioLoading, + showShare, + canRetranscribe, + retranscribing, + canRepolish, + showSaveDirectory, + exportDirectory, + onPlay, + onExport, + onShare, + onRetranscribe, + onDelete, + onRepolish, + onChooseSaveDirectory, + onResetSaveDirectory, +}: HistoryActionMenuProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + const run = (action: () => void | Promise) => { + setOpen(false); + void action(); + }; + + return ( +
+ setOpen((visible) => !visible)} + style={{ width: 36, justifyContent: 'center', padding: '7px 8px' }} + /> + {open && ( +
+ {hasAudioRecording && ( + <> + run(onPlay)} + /> + run(onExport)} + /> + {showShare && ( + run(onShare)} + /> + )} +
+ + )} + {canRetranscribe && ( + run(onRetranscribe)} + /> + )} + run(onRepolish)} + /> + run(onDelete)} + /> + {showSaveDirectory && ( + <> +
+ run(onChooseSaveDirectory)} + /> + {exportDirectory && ( + run(onResetSaveDirectory)} + /> + )} + + )} +
+ )} +
+ ); +} + +function HistoryActionMenuItem({ + icon, + label, + detail, + disabled = false, + danger = false, + onClick, +}: { + icon: string; + label: string; + detail?: string; + disabled?: boolean; + danger?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + function historyTitle( session: DictationSession, t: ReturnType['t'], @@ -1012,6 +1254,7 @@ function RepolishPanel({ mobile, allPacks, packsError, + onClose, persistOnApply, onApplied, }: { @@ -1020,6 +1263,7 @@ function RepolishPanel({ /** History 顶层加载的**全部**风格包(含已禁用);null 表示还在加载。 */ allPacks: StylePack[] | null; packsError: string | null; + onClose: () => void; persistOnApply: boolean; onApplied: (updated: DictationSession) => void; }) { @@ -1138,11 +1382,16 @@ function RepolishPanel({ - {results.length > 0 && ( - setResults([])}> - {t('history.repolish.clear')} + + {results.length > 0 && ( + setResults([])}> + {t('history.repolish.clear')} + + )} + + {t('common.close')} - )} +
)}
-

- {text.trim() || t('history.repolish.empty')} -

+
+ +
); } @@ -1336,16 +1577,20 @@ function isUserCancelled(message: string): boolean { ); } -/** 当 session.hasAudioRecording 为 true 时渲染:一个加载按钮 + 拿到字节后切换为 - * 原生 audio controls。Blob URL 在组件 unmount 时 revoke,避免泄漏。 +/** 当 session.hasAudioRecording 为 true 时渲染:由详情操作菜单触发加载,拿到字节后切换为 + * 原生 audio controls。Blob URL 在组件 unmount 时 revoke,避免泄漏。 * `onMissing` 在后端返回 'recording not found'(wav 已被 prune)时触发,让父组件 * 把按钮永久隐藏,避免用户继续点击得到同样错误。 */ function AudioRecordingPlayer({ sessionId, onMissing, + playRequest = 0, + onLoadingChange, }: { sessionId: string; onMissing?: () => void; + playRequest?: number; + onLoadingChange?: (loading: boolean) => void; }) { const { t } = useTranslation(); const [blobUrl, setBlobUrl] = useState(null); @@ -1353,18 +1598,20 @@ function AudioRecordingPlayer({ const [errorText, setErrorText] = useState(null); const mountedRef = useRef(true); const blobUrlRef = useRef(null); + const initialPlayRequestRef = useRef(playRequest); // 组件 unmount 时释放 Blob URL,避免内存泄漏。 useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; + onLoadingChange?.(false); if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; } }; - }, []); + }, [onLoadingChange]); const clearBlobUrl = () => { if (blobUrlRef.current) { @@ -1374,9 +1621,10 @@ function AudioRecordingPlayer({ setBlobUrl(null); }; - const load = async () => { + const load = useCallback(async () => { setStatus('loading'); setErrorText(null); + onLoadingChange?.(true); try { const dataUrl = await readAudioRecording(sessionId); if (!mountedRef.current) return; @@ -1407,8 +1655,18 @@ function AudioRecordingPlayer({ } setStatus('error'); setErrorText(msg); + } finally { + onLoadingChange?.(false); } - }; + }, [onLoadingChange, onMissing, sessionId]); + + useEffect(() => { + // A newly mounted player may receive an old global counter while the user + // switches history entries. Only a request created after this instance + // mounted is allowed to trigger loading/autoplay. + if (playRequest <= initialPlayRequestRef.current) return; + void load(); + }, [load, playRequest]); if (status === 'ready' && blobUrl) { return ( @@ -1433,22 +1691,11 @@ function AudioRecordingPlayer({ ); } - return ( -
- void load()} - disabled={status === 'loading'} - > - {status === 'loading' ? t('history.audioLoading') : t('history.playRecording')} - - {status === 'error' && ( - {errorText} - )} + return status === 'loading' || status === 'error' ? ( +
+ {status === 'loading' ? t('history.audioLoading') : errorText}
- ); + ) : null; } /** 流水线单步耗时:<1s 显示整数毫秒(流式收尾常在几十 ms,0.1s 精度会把不同结果 diff --git a/openless-all/app/src/pages/QuickNote.tsx b/openless-all/app/src/pages/QuickNote.tsx index c1a5b7594..87294a366 100644 --- a/openless-all/app/src/pages/QuickNote.tsx +++ b/openless-all/app/src/pages/QuickNote.tsx @@ -1,43 +1,97 @@ +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ShortcutRecorder } from '../components/ShortcutRecorder'; import { isDesktop, setQuickNoteHotkey } from '../lib/ipc'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; -import { Card } from './_atoms'; +import { Btn, Card } from './_atoms'; import { History } from './History'; +const QUICK_NOTE_SHORTCUT_HIDDEN_KEY = 'openless.quick-note.shortcut-hidden'; + /** Quick notes share the unified history/actions surface but use permanent audio retention. */ export function QuickNote() { const { t } = useTranslation(); const { prefs, updatePrefs } = useHotkeySettings(); + const [shortcutVisible, setShortcutVisible] = useState(() => { + try { + return window.localStorage.getItem(QUICK_NOTE_SHORTCUT_HIDDEN_KEY) !== '1'; + } catch { + return true; + } + }); + + const hideShortcut = () => { + setShortcutVisible(false); + try { + window.localStorage.setItem(QUICK_NOTE_SHORTCUT_HIDDEN_KEY, '1'); + } catch { + // The layout preference is best-effort when localStorage is unavailable. + } + }; + + const showShortcut = () => { + setShortcutVisible(true); + try { + window.localStorage.removeItem(QUICK_NOTE_SHORTCUT_HIDDEN_KEY); + } catch { + // The layout preference is best-effort when localStorage is unavailable. + } + }; return (
- {isDesktop() && ( + {isDesktop() && shortcutVisible && ( -
- {t('quickNote.shortcutTitle', 'Quick note shortcut')} -
-
- {t( - 'quickNote.shortcutDesc', - 'Press once to start a permanent audio capture, then press again to finish.', - )} -
- {prefs && ( - { - await setQuickNoteHotkey(binding); - await updatePrefs({ ...prefs, quickNoteHotkey: binding }); - }} - onDisable={async () => { - await setQuickNoteHotkey(null); - await updatePrefs({ ...prefs, quickNoteHotkey: null }); +
- )} + > +
+ {t('quickNote.shortcutTitle', 'Quick note shortcut')} +
+ +
+
+ {t( + 'quickNote.shortcutDesc', + 'Press once to start a permanent audio capture, then press again to finish.', + )} +
+ {prefs && ( + { + await setQuickNoteHotkey(binding); + await updatePrefs({ ...prefs, quickNoteHotkey: binding }); + }} + onDisable={async () => { + await setQuickNoteHotkey(null); + await updatePrefs({ ...prefs, quickNoteHotkey: null }); + }} + /> + )}
)} + {isDesktop() && !shortcutVisible && ( +
+ + {t('quickNote.showShortcut', '显示速记快捷键')} + +
+ )}
diff --git a/openless-all/app/src/pages/_atoms.tsx b/openless-all/app/src/pages/_atoms.tsx index fc2487a52..4d693cb18 100644 --- a/openless-all/app/src/pages/_atoms.tsx +++ b/openless-all/app/src/pages/_atoms.tsx @@ -189,6 +189,9 @@ interface BtnProps { variant?: BtnVariant; size?: BtnSize; icon?: string; + ariaLabel?: string; + ariaExpanded?: boolean; + title?: string; style?: CSSProperties; onClick?: () => void; disabled?: boolean; @@ -199,6 +202,9 @@ export function Btn({ variant = 'ghost', size = 'md', icon, + ariaLabel, + ariaExpanded, + title, style, onClick, disabled = false, @@ -233,6 +239,9 @@ export function Btn({