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
@@ -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({