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 d75302e3c..e45e4464b 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}; @@ -1380,6 +1380,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. @@ -2274,6 +2278,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(), @@ -3130,12 +3135,17 @@ impl OpenLessBackend { } pub async fn shutdown(&self) -> Result<(), BackendError> { - let active_session = { + let (active_session, preserve_quick_note) = { let mut state = self.state.write().expect("backend state lock poisoned"); if !state.running { return Ok(()); } let active_session = state.dictation.session_id; + let preserve_quick_note = active_session.is_some() + && state + .dictation_context + .as_ref() + .is_some_and(|context| context.output_target == DictationOutputTarget::QuickNote); self.events.publish(None, BackendEventKind::BackendStopping); if active_session.is_some() { state.dictation.phase = DictationPhase::Cancelled; @@ -3147,11 +3157,33 @@ 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(); - active_session + (active_session, preserve_quick_note) }; + // Mirror cancel_dictation: settle provisional recording drafts before + // adapter teardown so shutdown cannot leave orphan `recording` rows + // (including when no abandoned starter remains to reconcile them). + if let Some(session_id) = active_session { + if preserve_quick_note { + if let Ok(entries) = self.list_history() { + if let Some(mut entry) = entries + .into_iter() + .find(|entry| entry.id == session_id.to_string()) + { + 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); + } + } + } + } else { + self.remove_recording_draft(session_id); + } + } self.disarm_edit_observation(); // Start native teardown before awaiting unrelated domain shutdown. // This also retains a cleanup hold if the shutdown caller disappears. @@ -3257,6 +3289,15 @@ impl OpenLessBackend { } } + pub fn dictation_output_target(&self) -> Option { + 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 /// machine and domain Interfaces used by normal host calls. pub async fn dispatch_cli_intent( @@ -3380,8 +3421,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 @@ -3397,8 +3442,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) @@ -4281,6 +4330,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(); @@ -4295,6 +4356,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, @@ -4318,10 +4418,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", @@ -4693,6 +4796,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"); @@ -4731,6 +4835,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), @@ -4749,7 +4854,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 } @@ -4806,6 +4912,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 }; @@ -4898,6 +5005,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(); @@ -4938,6 +5048,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); @@ -4962,6 +5074,7 @@ 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", @@ -5028,17 +5141,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; @@ -5064,6 +5187,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 @@ -5345,6 +5476,124 @@ 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 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 + .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 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() + .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, @@ -5383,8 +5632,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(), @@ -5409,7 +5658,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, @@ -5459,8 +5708,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, @@ -5470,7 +5719,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, @@ -5483,7 +5736,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, @@ -5525,6 +5778,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()); @@ -5665,7 +5919,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(|| { @@ -5680,6 +5934,10 @@ 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), @@ -5687,10 +5945,31 @@ 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 + (active, preserve_quick_note) + }; + // 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 @@ -5698,9 +5977,14 @@ impl OpenLessBackend { // cleanup finishes, including on its error path. self.voice_sessions.release(active); let host_result = self.hide_dictation_feedback(active); - cancel_result?; - host_result?; - Ok(()) + 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(()), + } } async fn capture_dictation_context( @@ -8766,7 +9050,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; @@ -9272,6 +9559,7 @@ mod tests { backend .stop_dictation_with_options(DictationStopOptions { translation_requested: Some(true), + ..DictationStopOptions::default() }) .await .unwrap(); @@ -9347,6 +9635,7 @@ mod tests { backend .stop_dictation_with_options(DictationStopOptions { translation_requested: Some(false), + ..DictationStopOptions::default() }) .await .unwrap(); @@ -9571,6 +9860,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"); @@ -10422,6 +10712,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"); @@ -11436,6 +11727,287 @@ 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 settles provisional drafts immediately; the abandoned starter + // must still observe Cancelled without leaving a recording row behind. + 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 shutdown_while_recording_settles_provisional_history() { + let data_dir = TestDataDir::new("shutdown-while-recording-settles-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(FakeEngine), + credential_store: Arc::new(crate::credentials::InMemoryCredentialStore::default()), + task_spawner: Arc::new(TokioTaskSpawner), + ..BackendDependencies::unsupported() + }, + ) + .unwrap(); + backend.start().await.unwrap(); + + let _undecided = backend + .start_dictation_with_options(DictationStartOptions { + output_target: DictationOutputTarget::Undecided, + insert_text: false, + ..DictationStartOptions::default() + }) + .await + .unwrap(); + assert_eq!( + backend.list_history().unwrap()[0].error_code.as_deref(), + Some("recording") + ); + backend.shutdown().await.unwrap(); + assert!( + backend.list_history().unwrap().is_empty(), + "shutdown must drop undecided recording drafts" + ); + + backend.start().await.unwrap(); + let _quick_note = backend + .start_dictation_with_options(DictationStartOptions { + output_target: DictationOutputTarget::QuickNote, + insert_text: false, + ..DictationStartOptions::default() + }) + .await + .unwrap(); + let history_before = backend.list_history().unwrap(); + assert_eq!(history_before.len(), 1); + assert_eq!(history_before[0].error_code.as_deref(), Some("recording")); + let quick_note_id = history_before[0].id.clone(); + backend.shutdown().await.unwrap(); + let history = backend.list_history().unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].id, quick_note_id); + assert_eq!(history[0].error_code.as_deref(), Some("cancelled")); + assert_eq!(history[0].source, HistorySource::QuickNote); + } + + #[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::{ @@ -12216,6 +12788,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 19dc58be6..3b6c85b60 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)] @@ -140,6 +160,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, @@ -148,6 +171,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, @@ -246,12 +271,21 @@ 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, transcribe_after_stop: preferences.stable_transcription_enabled, 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. @@ -287,9 +321,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, @@ -305,6 +345,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( @@ -542,4 +615,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 4e081370e..0a8bcc97a 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, PreparedTranscription, RecordingProgressSink, TextPolisher, TextStreamChunk, - TextStreamSink, TranscriptionEngine, TranscriptionSession, VoiceCapture, + EngineStage, PreparedTranscription, RecordingArchive, RecordingProgressSink, TextPolisher, + TextStreamChunk, TextStreamSink, TranscriptionEngine, TranscriptionSession, VoiceCapture, }; use crate::types::{PolishDelta, SessionId, TranscriptDelta}; @@ -461,12 +461,36 @@ 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); 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); @@ -536,6 +560,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); @@ -546,6 +576,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( @@ -566,6 +602,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", @@ -579,13 +618,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( @@ -749,6 +791,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, buffered) = { let mut resources = session @@ -763,7 +808,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(buffered) = buffered { retain_first_error( @@ -791,6 +842,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/history.rs b/openless-all/app/crates/openless-core/src/history.rs index c72218037..e7abc7d59 100644 --- a/openless-all/app/crates/openless-core/src/history.rs +++ b/openless-all/app/crates/openless-core/src/history.rs @@ -5,7 +5,7 @@ use std::sync::Mutex; use crate::errors::{BackendError, BackendErrorCode}; use crate::persistence::{atomic_write, persistence_error, read_or_default}; -use crate::types::DictationSession; +use crate::types::{DictationSession, HistorySource}; pub const HISTORY_CAP: usize = 200; @@ -40,21 +40,46 @@ impl HistoryStore { let _guard = self.lock_store()?; let mut sessions = self.read_locked()?; sessions.insert(0, session); - if retention_days > 0 { - let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days)); - sessions.retain(|session| { - chrono::DateTime::parse_from_rfc3339(&session.created_at) - .map(|time| time.with_timezone(&chrono::Utc) >= cutoff) - .unwrap_or(true) - }); + retain_with_policy(&mut sessions, retention_days, max_entries); + self.write_locked(&sessions) + } + + /// Replace an in-progress record when its terminal result arrives, or + /// append it when no provisional record exists (legacy/recovery path). + pub fn upsert_with_retention( + &self, + session: DictationSession, + retention_days: u32, + max_entries: Option, + ) -> Result<(), BackendError> { + let _guard = self.lock_store()?; + let mut sessions = self.read_locked()?; + if let Some(existing) = sessions.iter_mut().find(|item| item.id == session.id) { + let mut replacement = session; + if replacement.has_audio_recording.is_none() { + replacement.has_audio_recording = existing.has_audio_recording; + } + *existing = replacement; + } else { + sessions.insert(0, session); } - let cap = max_entries - .map(|count| (count as usize).clamp(5, HISTORY_CAP)) - .unwrap_or(HISTORY_CAP); - sessions.truncate(cap); + retain_with_policy(&mut sessions, retention_days, max_entries); self.write_locked(&sessions) } + pub fn contains(&self, id: &str) -> Result { + let _guard = self.lock_store()?; + Ok(self.read_locked()?.iter().any(|session| session.id == id)) + } + + pub fn read_entry(&self, id: &str) -> Result, BackendError> { + let _guard = self.lock_store()?; + Ok(self + .read_locked()? + .into_iter() + .find(|session| session.id == id)) + } + pub fn recent_within_minutes( &self, minutes: u32, @@ -99,7 +124,12 @@ impl HistoryStore { pub fn clear(&self) -> Result<(), BackendError> { let _guard = self.lock_store()?; - self.write_locked(&[]) + let quick_notes = self + .read_locked()? + .into_iter() + .filter(|session| session.source == HistorySource::QuickNote) + .collect::>(); + self.write_locked(&quick_notes) } fn lock_store(&self) -> Result, BackendError> { @@ -119,6 +149,41 @@ impl HistoryStore { } } +fn retain_with_policy( + sessions: &mut Vec, + retention_days: u32, + max_entries: Option, +) { + // Quick notes are intentionally outside the ordinary history retention + // policy. A recording-start draft is also protected until it reaches a + // terminal state, because its audio may be the only recoverable artifact + // after a process crash. + if retention_days > 0 { + let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days)); + sessions.retain(|session| { + session.source == HistorySource::QuickNote + || session.error_code.as_deref() == Some("recording") + || chrono::DateTime::parse_from_rfc3339(&session.created_at) + .map(|time| time.with_timezone(&chrono::Utc) >= cutoff) + .unwrap_or(true) + }); + } + let cap = max_entries + .map(|count| (count as usize).clamp(5, HISTORY_CAP)) + .unwrap_or(HISTORY_CAP); + let mut ordinary_seen = 0usize; + sessions.retain(|session| { + if session.source == HistorySource::QuickNote + || session.error_code.as_deref() == Some("recording") + { + true + } else { + ordinary_seen += 1; + ordinary_seen <= cap + } + }); +} + #[cfg(test)] mod tests { use super::*; @@ -200,4 +265,79 @@ mod tests { assert!(store.recent_within_minutes(0).unwrap().is_empty()); let _ = std::fs::remove_file(path); } + + #[test] + fn quick_notes_survive_ordinary_history_cap() { + let path = std::env::temp_dir().join(format!( + "openless-core-quick-note-retention-{}.json", + uuid::Uuid::new_v4().simple() + )); + let store = HistoryStore::at_path(path.clone()); + let mut note = session("quick", chrono::Utc::now().to_rfc3339()); + note.source = HistorySource::QuickNote; + store.append_with_retention(note, 1, Some(5)).unwrap(); + for index in 0..8 { + store + .append_with_retention( + session( + &format!("ordinary-{index}"), + chrono::Utc::now().to_rfc3339(), + ), + 1, + Some(5), + ) + .unwrap(); + } + let sessions = store.list().unwrap(); + assert!(sessions.iter().any(|entry| entry.id == "quick")); + assert_eq!( + sessions + .iter() + .filter(|entry| entry.source == HistorySource::Voice) + .count(), + 5 + ); + let _ = std::fs::remove_file(path); + } + + #[test] + fn clearing_ordinary_history_preserves_quick_notes() { + let path = std::env::temp_dir().join(format!( + "openless-core-quick-note-clear-{}.json", + uuid::Uuid::new_v4().simple() + )); + let store = HistoryStore::at_path(path.clone()); + store + .append_with_retention( + session("ordinary", chrono::Utc::now().to_rfc3339()), + 0, + None, + ) + .unwrap(); + let mut note = session("quick", chrono::Utc::now().to_rfc3339()); + note.source = HistorySource::QuickNote; + store.append_with_retention(note, 0, None).unwrap(); + store.clear().unwrap(); + let entries = store.list().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].id, "quick"); + let _ = std::fs::remove_file(path); + } + + #[test] + fn upsert_preserves_an_existing_archived_audio_flag_when_update_omits_it() { + let path = std::env::temp_dir().join(format!( + "openless-core-quick-note-audio-flag-{}.json", + uuid::Uuid::new_v4().simple() + )); + let store = HistoryStore::at_path(path.clone()); + let mut original = session("audio", chrono::Utc::now().to_rfc3339()); + original.has_audio_recording = Some(true); + store.append_with_retention(original, 0, None).unwrap(); + let mut replacement = session("audio", chrono::Utc::now().to_rfc3339()); + replacement.has_audio_recording = None; + store.upsert_with_retention(replacement, 0, None).unwrap(); + assert_eq!(store.list().unwrap()[0].has_audio_recording, Some(true)); + let _ = std::fs::remove_file(path); + } } diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index bf3db2275..89acdfc3d 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -181,27 +181,27 @@ pub mod contract { BackendSnapshot, CliDispatchOutcome, CliIntent, Clock, CorrectionRule, CredentialKey, CredentialMetadata, CredentialNamespace, CredentialStore, CredentialsStatus, DictationContext, DictationEngine, DictationHotkeyDispatchOptions, DictationHotkeyEdge, - DictationInsertStatus, DictationPhase, DictationResult, DictationSession, - DictationStartOptions, DictationStateSnapshot, DictionaryEntry, DirectoryResourceResolver, - DownloadProgress, EngineFailure, EngineFailureStage, EngineProgress, EngineProgressSink, - EngineResult, EngineStage, EventRecvError, EventSubscription, HistoryChange, - HistoryInsertStatus, HistorySource, HostAction, HostActions, HostContextAdapter, - HostContextCapture, HotkeyRuntimeTarget, HotkeyStatus, InMemoryCredentialStore, - InsertFallbackPayload, InsertOutcome, LessComputerEvent, LessComputerEventKind, - LessComputerHotkeyAction, LessComputerVoiceSession, LocalAsrMirror, LocalAsrModelId, - LocalAsrRuntime, LocalAsrTarget, NotificationLevel, NotificationPayload, OpenLessBackend, - PendingCorrection, PermissionSnapshot, PermissionState, PlatformCapabilities, PolishDelta, - PolishFailurePolicy, PolishMode, PolishOutput, ProviderService, QaVoiceCaptureResult, - QaVoiceCaptureSession, RecordingArchive, RecordingControlAction, RecordingControlRequest, - RecordingControlSink, RecordingEvent, RecordingPlan, RecordingProgressSink, - ResourceResolver, RuleSource, SecretValue, SelectionPolishOutputMode, - SelectionVoiceIntentMode, SelectionVoiceManualIntent, SessionId, SettingsCollisionPolicy, - SettingsEffectFailure, SettingsEffectKind, SettingsEffectPlan, SettingsEffectReceipt, - SettingsRuntime, SettingsUpdateOptions, SettingsUpdateOutcome, SettingsValueChange, - StartupSnapshot, StylePack, StylePackChange, StylePackExample, StylePackKind, TaskSpawner, - TextInserter, TextPolisher, TextStreamChunk, TextStreamSink, TokioTaskSpawner, - TranscriptAccumulator, TranscriptDelta, TranscriptOutput, TranscriptionEngine, - TranscriptionSession, VocabPreset, VocabPresetStore, VocabularyChange, + DictationInsertStatus, DictationOutputTarget, DictationPhase, DictationResult, + DictationSession, DictationStartOptions, DictationStateSnapshot, DictionaryEntry, + DirectoryResourceResolver, DownloadProgress, EngineFailure, EngineFailureStage, + EngineProgress, EngineProgressSink, EngineResult, EngineStage, EventRecvError, + EventSubscription, HistoryChange, HistoryInsertStatus, HistorySource, HostAction, + HostActions, HostContextAdapter, HostContextCapture, HotkeyRuntimeTarget, HotkeyStatus, + InMemoryCredentialStore, InsertFallbackPayload, InsertOutcome, LessComputerEvent, + LessComputerEventKind, LessComputerHotkeyAction, LessComputerVoiceSession, LocalAsrMirror, + LocalAsrModelId, LocalAsrRuntime, LocalAsrTarget, NotificationLevel, NotificationPayload, + OpenLessBackend, PendingCorrection, PermissionSnapshot, PermissionState, + PlatformCapabilities, PolishDelta, PolishFailurePolicy, PolishMode, PolishOutput, + ProviderService, QaVoiceCaptureResult, QaVoiceCaptureSession, RecordingArchive, + RecordingControlAction, RecordingControlRequest, RecordingControlSink, RecordingEvent, + RecordingPlan, RecordingProgressSink, ResourceResolver, RuleSource, SecretValue, + SelectionPolishOutputMode, SelectionVoiceIntentMode, SelectionVoiceManualIntent, SessionId, + SettingsCollisionPolicy, SettingsEffectFailure, SettingsEffectKind, SettingsEffectPlan, + SettingsEffectReceipt, SettingsRuntime, SettingsUpdateOptions, SettingsUpdateOutcome, + SettingsValueChange, StartupSnapshot, StylePack, StylePackChange, StylePackExample, + StylePackKind, TaskSpawner, TextInserter, TextPolisher, TextStreamChunk, TextStreamSink, + TokioTaskSpawner, TranscriptAccumulator, TranscriptDelta, TranscriptOutput, + TranscriptionEngine, TranscriptionSession, VocabPreset, VocabPresetStore, VocabularyChange, VoiceTranscriptionSession, BACKEND_CONTRACT_VERSION, DICTATION_SAMPLE_RATE, }; } @@ -238,8 +238,9 @@ pub use credentials::{ }; pub use dictation_context::{ build_asr_prompt, eligible_polish_context_turns, DictationAudioSource, DictationContext, - DictationInsertionContext, DictationPolishContext, DictationStartOptions, DictationStopOptions, - PolishHistoryTurn, ProviderInvocation, RecordingPlan, ASR_PROMPT_CHAR_BUDGET, + DictationInsertionContext, DictationOutputTarget, DictationPolishContext, + DictationStartOptions, DictationStopOptions, PolishHistoryTurn, ProviderInvocation, + RecordingPlan, ASR_PROMPT_CHAR_BUDGET, }; pub use dictation_engine::{PipelineDictationEngine, PolishFailurePolicy}; pub use domains::*; diff --git a/openless-all/app/crates/openless-core/src/ports.rs b/openless-all/app/crates/openless-core/src/ports.rs index 0ba837b9d..38bb6ccf4 100644 --- a/openless-all/app/crates/openless-core/src/ports.rs +++ b/openless-all/app/crates/openless-core/src/ports.rs @@ -424,6 +424,17 @@ pub trait RecordingControlSink: Send + Sync { pub trait RecordingArchive: Send + Sync { fn is_available(&self) -> bool; + /// Move an undecided capture into the permanent quick-note archive. + /// Hosts that do not need separate storage can keep the default no-op. + fn promote_to_quick_note(&self) -> BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { Ok(()) }) + } + + /// Move a retained undecided capture back into ordinary debug storage. + fn demote_to_ordinary_recording(&self) -> BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { Ok(()) }) + } + fn read_pcm(&self) -> BoxFuture<'static, Result, BackendError>> { Box::pin(async { Err(BackendError::new( diff --git a/openless-all/app/crates/openless-core/src/settings.rs b/openless-all/app/crates/openless-core/src/settings.rs index 37d0f7ccf..c3d3f53cd 100644 --- a/openless-all/app/crates/openless-core/src/settings.rs +++ b/openless-all/app/crates/openless-core/src/settings.rs @@ -46,6 +46,7 @@ pub struct HotkeyRuntimeTarget { pub dictation: ShortcutBinding, pub dictation_mode: HotkeyMode, pub qa: Option, + pub quick_note: Option, pub translation: ShortcutBinding, pub switch_style: Option, pub open_app: Option, @@ -61,6 +62,7 @@ impl From<&UserPreferences> for HotkeyRuntimeTarget { dictation: preferences.dictation_hotkey.clone(), dictation_mode: preferences.hotkey.mode, qa: preferences.qa_hotkey.clone(), + quick_note: preferences.quick_note_hotkey.clone(), translation: preferences.translation_hotkey.clone(), switch_style: preferences.switch_style_hotkey.clone(), open_app: preferences.open_app_hotkey.clone(), diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index 87c334826..ab825f04b 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -5,16 +5,18 @@ use serde::{Deserialize, Serialize}; use crate::android_types::{ default_android_insert_strategy, default_android_overlay_activation_mode, - default_android_overlay_cancel_swipe_direction, default_android_overlay_left_swipe_action, - default_android_overlay_size_dp, default_android_overlay_trigger, - normalize_android_insert_strategy, normalize_android_overlay_size_dp, + default_android_overlay_cancel_swipe_direction, default_android_overlay_gesture_actions, + default_android_overlay_left_swipe_action, default_android_overlay_size_dp, + default_android_overlay_trigger, normalize_android_insert_strategy, + normalize_android_overlay_size_dp, }; pub use crate::android_types::{ AndroidAccessibilityDiagnosis, AndroidAccessibilityRecoveryOutcome, AndroidAccessibilityRecoveryResult, AndroidAccessibilityState, AndroidAccessibilityStatus, AndroidInsertStrategy, AndroidOverlayActivationMode, AndroidOverlayCancelSwipeDirection, - AndroidOverlayLeftSwipeAction, AndroidOverlayPermissionState, AndroidOverlayStatus, - AndroidOverlayTrigger, AndroidShizukuState, AndroidShizukuStatus, + AndroidOverlayGestureAction, AndroidOverlayGestureActions, AndroidOverlayLeftSwipeAction, + AndroidOverlayPermissionState, AndroidOverlayStatus, AndroidOverlayTrigger, + AndroidShizukuState, AndroidShizukuStatus, }; pub use crate::types::{HistorySource, PolishMode}; @@ -441,6 +443,9 @@ pub struct UserPreferences { /// 默认 Cmd+Shift+; (macOS) / Ctrl+Shift+; (Windows)。详见 issue #118。 #[serde(default = "default_qa_hotkey")] pub qa_hotkey: Option, + /// 独立的速记快捷键。None = 未配置;启用后按一次开始、再按一次结束。 + #[serde(default)] + pub quick_note_hotkey: Option, /// 选区润色全局快捷键。Windows 默认右 Alt;其它平台默认关闭。 #[serde(default = "default_selection_polish_hotkey")] pub selection_polish_hotkey: Option, @@ -667,6 +672,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)] @@ -689,6 +697,9 @@ pub struct UserPreferences { /// Android: vertical swipe direction that cancels recording. #[serde(default = "default_android_overlay_cancel_swipe_direction")] pub android_overlay_cancel_swipe_direction: AndroidOverlayCancelSwipeDirection, + /// Android: action assigned to each overlay swipe direction. + #[serde(default = "default_android_overlay_gesture_actions")] + pub android_overlay_gesture_actions: AndroidOverlayGestureActions, /// Android: floating overlay control diameter in dp. #[serde(default = "default_android_overlay_size_dp")] pub android_overlay_size_dp: u32, @@ -834,6 +845,8 @@ struct UserPreferencesWire { #[serde(default)] output_language_preference: OutputLanguagePreference, qa_hotkey: Option, + #[serde(default)] + quick_note_hotkey: Option, /// Outer `None` means the field was absent in a pre-Selection-Polish file; /// `Some(None)` means the user explicitly disabled it. #[serde(default, deserialize_with = "deserialize_selection_polish_hotkey")] @@ -947,6 +960,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, @@ -960,6 +975,8 @@ struct UserPreferencesWire { android_overlay_left_swipe_action: AndroidOverlayLeftSwipeAction, #[serde(default = "default_android_overlay_cancel_swipe_direction")] android_overlay_cancel_swipe_direction: AndroidOverlayCancelSwipeDirection, + #[serde(default)] + android_overlay_gesture_actions: Option, #[serde(default = "default_android_overlay_size_dp")] android_overlay_size_dp: u32, #[serde(default)] @@ -1045,6 +1062,7 @@ impl Default for UserPreferencesWire { chinese_script_preference: prefs.chinese_script_preference, output_language_preference: prefs.output_language_preference, qa_hotkey: prefs.qa_hotkey, + quick_note_hotkey: prefs.quick_note_hotkey, selection_polish_hotkey: None, selection_polish_style_pack_id: prefs.selection_polish_style_pack_id, selection_polish_output_mode: prefs.selection_polish_output_mode, @@ -1105,6 +1123,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, @@ -1112,6 +1131,7 @@ impl Default for UserPreferencesWire { android_overlay_activation_mode: prefs.android_overlay_activation_mode, android_overlay_left_swipe_action: prefs.android_overlay_left_swipe_action, android_overlay_cancel_swipe_direction: prefs.android_overlay_cancel_swipe_direction, + android_overlay_gesture_actions: None, android_overlay_size_dp: prefs.android_overlay_size_dp, splash_seen_version: prefs.splash_seen_version, } @@ -1161,6 +1181,33 @@ impl<'de> Deserialize<'de> for UserPreferences { let update_channel_explicit = wire .update_channel_explicit .unwrap_or(matches!(wire.update_channel, UpdateChannel::Beta)); + let android_overlay_gesture_actions = + wire.android_overlay_gesture_actions + .unwrap_or_else(|| AndroidOverlayGestureActions { + up: if wire.android_overlay_cancel_swipe_direction + == AndroidOverlayCancelSwipeDirection::Up + { + AndroidOverlayGestureAction::Cancel + } else { + AndroidOverlayGestureAction::None + }, + down: if wire.android_overlay_cancel_swipe_direction + == AndroidOverlayCancelSwipeDirection::Down + { + AndroidOverlayGestureAction::Cancel + } else { + AndroidOverlayGestureAction::None + }, + left: match wire.android_overlay_left_swipe_action { + AndroidOverlayLeftSwipeAction::Translation => { + AndroidOverlayGestureAction::Translation + } + AndroidOverlayLeftSwipeAction::StylePack => { + AndroidOverlayGestureAction::StylePack + } + }, + right: AndroidOverlayGestureAction::Qa, + }); Ok(Self { hotkey: wire.hotkey, @@ -1210,6 +1257,7 @@ impl<'de> Deserialize<'de> for UserPreferences { chinese_script_preference: wire.chinese_script_preference, output_language_preference: wire.output_language_preference, qa_hotkey: wire.qa_hotkey, + quick_note_hotkey: wire.quick_note_hotkey, selection_polish_hotkey, selection_polish_style_pack_id: wire.selection_polish_style_pack_id, selection_polish_output_mode: wire.selection_polish_output_mode, @@ -1275,6 +1323,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( @@ -1284,6 +1333,7 @@ impl<'de> Deserialize<'de> for UserPreferences { android_overlay_activation_mode: wire.android_overlay_activation_mode, android_overlay_left_swipe_action: wire.android_overlay_left_swipe_action, android_overlay_cancel_swipe_direction: wire.android_overlay_cancel_swipe_direction, + android_overlay_gesture_actions, android_overlay_size_dp: normalize_android_overlay_size_dp( wire.android_overlay_size_dp, ), @@ -1563,6 +1613,7 @@ impl Default for UserPreferences { chinese_script_preference: ChineseScriptPreference::Auto, output_language_preference: OutputLanguagePreference::Auto, qa_hotkey: default_qa_hotkey(), + quick_note_hotkey: None, selection_polish_hotkey: default_selection_polish_hotkey(), selection_polish_style_pack_id: default_active_style_pack_id(), selection_polish_output_mode: SelectionPolishOutputMode::default(), @@ -1620,6 +1671,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(), @@ -1628,6 +1680,7 @@ impl Default for UserPreferences { android_overlay_left_swipe_action: default_android_overlay_left_swipe_action(), android_overlay_cancel_swipe_direction: default_android_overlay_cancel_swipe_direction( ), + android_overlay_gesture_actions: default_android_overlay_gesture_actions(), android_overlay_size_dp: default_android_overlay_size_dp(), splash_seen_version: String::new(), } diff --git a/openless-all/app/crates/openless-core/src/shortcut_types.rs b/openless-all/app/crates/openless-core/src/shortcut_types.rs index f56c66d69..c62c7b88e 100644 --- a/openless-all/app/crates/openless-core/src/shortcut_types.rs +++ b/openless-all/app/crates/openless-core/src/shortcut_types.rs @@ -438,6 +438,7 @@ pub fn reject_non_dictation_side_specific_shortcuts( preferences.switch_style_hotkey.as_ref(), preferences.open_app_hotkey.as_ref(), preferences.coding_agent_voice_hotkey.as_ref(), + preferences.quick_note_hotkey.as_ref(), ] .into_iter() .flatten() @@ -494,6 +495,13 @@ pub fn reject_selection_polish_hotkey_collisions( "选区润色快捷键不能和 Less Computer 快捷键相同", )?; } + if let Some(binding) = preferences.quick_note_hotkey.as_ref() { + reject_overlap( + selection_polish, + binding, + "选区润色快捷键不能和速记快捷键相同", + )?; + } Ok(()) } @@ -532,6 +540,10 @@ fn reject_style_pack_hotkey_overlap_with_others( preferences.selection_polish_hotkey.as_ref(), "风格快捷键不能和选区润色快捷键相同", ), + ( + preferences.quick_note_hotkey.as_ref(), + "风格快捷键不能和速记快捷键相同", + ), ]; for (other, message) in optional_bindings { if let Some(other) = other { @@ -585,6 +597,7 @@ pub fn reconcile_hotkey_collisions( Qa, SwitchStyle, OpenApp, + QuickNote, SelectionPolish, LessComputer, } @@ -596,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(), } @@ -611,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, } @@ -630,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, ]; @@ -705,6 +721,34 @@ pub fn reject_hotkey_collisions(preferences: &UserPreferences) -> Result<(), Str let switch_style = preferences.switch_style_hotkey.as_ref(); let open_app = preferences.open_app_hotkey.as_ref(); let less_computer = preferences.coding_agent_voice_hotkey.as_ref(); + let quick_note = preferences.quick_note_hotkey.as_ref(); + if let Some(binding) = quick_note { + reject_overlap( + &preferences.dictation_hotkey, + binding, + "速记快捷键不能和听写快捷键相同", + )?; + reject_overlap( + &preferences.translation_hotkey, + binding, + "速记快捷键不能和翻译快捷键相同", + )?; + if let Some(other) = preferences.qa_hotkey.as_ref() { + reject_overlap(other, binding, "速记快捷键不能和 QA 快捷键相同")?; + } + if let Some(other) = switch_style { + reject_overlap(other, binding, "速记快捷键不能和切换风格快捷键相同")?; + } + if let Some(other) = open_app { + reject_overlap(other, binding, "速记快捷键不能和打开应用快捷键相同")?; + } + if let Some(other) = less_computer { + reject_overlap(other, binding, "速记快捷键不能和 Less Computer 快捷键相同")?; + } + if let Some(other) = preferences.selection_polish_hotkey.as_ref() { + reject_overlap(other, binding, "速记快捷键不能和选区润色快捷键相同")?; + } + } if let Some(qa) = preferences.qa_hotkey.as_ref() { reject_dictation_qa_hotkey_overlap(&preferences.dictation_hotkey, qa)?; reject_qa_translation_hotkey_overlap(qa, &preferences.translation_hotkey)?; @@ -884,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/crates/openless-core/src/types.rs b/openless-all/app/crates/openless-core/src/types.rs index 8b33cc455..f67b6f00f 100644 --- a/openless-all/app/crates/openless-core/src/types.rs +++ b/openless-all/app/crates/openless-core/src/types.rs @@ -56,6 +56,7 @@ impl PolishMode { pub enum HistorySource { #[default] Voice, + QuickNote, SelectionPolish, SelectionVoiceEdit, } diff --git a/openless-all/app/src-tauri/src/android/native_bridge.rs b/openless-all/app/src-tauri/src/android/native_bridge.rs index 499bfcc89..5558f4555 100644 --- a/openless-all/app/src-tauri/src/android/native_bridge.rs +++ b/openless-all/app/src-tauri/src/android/native_bridge.rs @@ -238,6 +238,18 @@ fn spawn_stop_dictation_with_translation(translation: bool) { }); } +fn spawn_stop_dictation_as_quick_note() { + let Some(backend) = CORE_BACKEND.get().cloned() else { + log::warn!("[android-native] core backend unavailable"); + return; + }; + tauri::async_runtime::spawn(async move { + if let Err(error) = stop_core_dictation_as_quick_note(&backend).await { + log::warn!("[android-native] stop_quick_note failed: {error}"); + } + }); +} + fn spawn_cancel_dictation() { let Some(backend) = CORE_BACKEND.get().cloned() else { log::warn!("[android-native] core backend unavailable"); @@ -265,6 +277,7 @@ async fn start_core_dictation( backend .start_dictation_with_options(DictationStartOptions { translation_requested: translation, + output_target: openless_core::DictationOutputTarget::Undecided, ..DictationStartOptions::default() }) .await @@ -279,6 +292,18 @@ async fn stop_core_dictation( backend .stop_dictation_with_options(DictationStopOptions { translation_requested: translation, + quick_note: Some(false), + }) + .await + .map(|_| ()) +} + +async fn stop_core_dictation_as_quick_note(backend: &OpenLessBackend) -> Result<(), BackendError> { + ensure_core_started(backend).await?; + backend + .stop_dictation_with_options(DictationStopOptions { + translation_requested: None, + quick_note: Some(true), }) .await .map(|_| ()) @@ -388,6 +413,14 @@ mod jni_exports { spawn_stop_dictation_with_translation(translation != 0); } + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeStopDictationAsQuickNote( + _env: *mut JNIEnv, + _class: JClass, + ) { + spawn_stop_dictation_as_quick_note(); + } + #[no_mangle] pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeCancelDictation( _env: *mut JNIEnv, diff --git a/openless-all/app/src-tauri/src/commands/history.rs b/openless-all/app/src-tauri/src/commands/history.rs index eeb88f6d5..67be47523 100644 --- a/openless-all/app/src-tauri/src/commands/history.rs +++ b/openless-all/app/src-tauri/src/commands/history.rs @@ -8,12 +8,35 @@ pub fn list_history(core: CoreState<'_>) -> Result, String #[tauri::command] pub fn delete_history_entry(core: CoreState<'_>, id: String) -> Result<(), String> { - core.delete_history(&id).map_err(|e| e.to_string()) + if !is_valid_session_id(&id) { + return Err("invalid session id".into()); + } + if core + .snapshot() + .dictation + .session_id + .is_some_and(|active| active.to_string() == id) + { + return Err("cannot delete the active recording; stop or cancel it first".into()); + } + core.delete_history(&id).map_err(|e| e.to_string())?; + remove_recording_files(&id); + Ok(()) } #[tauri::command] pub fn clear_history(core: CoreState<'_>) -> Result<(), String> { - core.clear_history().map_err(|e| e.to_string()) + if core.snapshot().dictation.session_id.is_some() { + return Err("cannot clear history while a recording is active".into()); + } + let entries = core.list_history().map_err(|e| e.to_string())?; + core.clear_history().map_err(|e| e.to_string())?; + for entry in entries { + if entry.source != openless_core::HistorySource::QuickNote { + remove_recording_files(&entry.id); + } + } + Ok(()) } /// 每日活动汇总(日期升序),概览页年度热力图与「近 7 天 / 近 30 天」指标的数据源。 @@ -25,6 +48,33 @@ pub fn get_activity_stats(core: CoreState<'_>) -> Vec { .expect("activity snapshot should only fail after a poisoned lock") } +fn recording_path_candidates(session_id: &str) -> Result<[std::path::PathBuf; 2], String> { + 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) + .map_err(|e| e.to_string())?, + ]) +} + +fn existing_recording_path(session_id: &str) -> Result { + let candidates = recording_path_candidates(session_id)?; + candidates + .into_iter() + .find(|path| path.is_file()) + .ok_or_else(|| "recording not found".to_string()) +} + +fn remove_recording_files(session_id: &str) { + if let Ok(candidates) = recording_path_candidates(session_id) { + for path in candidates { + let _ = std::fs::remove_file(path); + } + } +} + /// 读取某次会话的原始麦克风 wav 字节流。文件存在的条件:debug 用户的任意会话,或任意 /// 「转录失败 / empty」会话(失败保留)——成功的非 debug 会话录音会在插入后删掉。 /// 文件名规约:`/recordings/.wav`,与 DictationSession.id 同名。 @@ -48,8 +98,7 @@ pub async fn read_audio_recording(session_id: String) -> Result if !is_valid_session_id(&session_id) { return Err("invalid session id".into()); } - let path = - crate::persistence::recording_path_for_session(&session_id).map_err(|e| e.to_string())?; + let path = existing_recording_path(&session_id)?; let data = tokio::fs::read(&path).await.map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { "recording not found".into() @@ -78,28 +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 = crate::persistence::recording_path_for_session(&session_id) - .map_err(|e| e.to_string())?; + let Some(file_path) = file_path else { + return Err("user cancelled".into()); + }; - export_recording_to_destination(&app, file_path, &src) + return 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}"))? @@ -235,14 +311,23 @@ pub async fn retranscribe_recording( .into_iter() .find(|entry| entry.id == session_id) .ok_or_else(|| "history entry not found".to_string())?; + if core + .snapshot() + .dictation + .session_id + .is_some_and(|active| active.to_string() == session_id) + { + return Err("recording is still active; stop it before retranscribing".into()); + } if entry.has_audio_recording != Some(true) { return Err("history entry has no archived recording".into()); } - if entry.pipeline_mode.as_deref() == Some("multimodal") { + if entry.pipeline_mode.as_deref() == Some("multimodal") + && entry.source != openless_core::HistorySource::QuickNote + { return Err("multimodal history does not support retranscription".into()); } - let path = - crate::persistence::recording_path_for_session(&session_id).map_err(|e| e.to_string())?; + let path = existing_recording_path(&session_id)?; let wav = tokio::fs::read(&path).await.map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { "recording not found".into() @@ -270,7 +355,10 @@ pub async fn retranscribe_recording( } let retranscribe_ms = retranscribe_started.elapsed().as_millis() as u64; - let updated_entry = if should_replace_failed_history(entry.error_code.as_deref()) { + let updated_entry = if should_replace_failed_history(entry.error_code.as_deref()) + || entry.error_code.as_deref() == Some("recording") + || entry.source == openless_core::HistorySource::QuickNote + { Some( core.apply_history_retranscription( &session_id, @@ -289,9 +377,29 @@ pub async fn retranscribe_recording( }) } +#[tauri::command] +pub fn apply_quick_note_repolish( + core: CoreState<'_>, + session_id: String, + text: String, + style_pack_id: Option, +) -> Result { + if !is_valid_session_id(&session_id) { + return Err("invalid session id".into()); + } + core.apply_history_repolish(&session_id, text, style_pack_id) + .map_err(|e| e.to_string()) +} + #[cfg(test)] mod retranscription_tests { - use super::should_replace_failed_history; + 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/commands/hotkeys.rs b/openless-all/app/src-tauri/src/commands/hotkeys.rs index aff774813..8ebbad2fa 100644 --- a/openless-all/app/src-tauri/src/commands/hotkeys.rs +++ b/openless-all/app/src-tauri/src/commands/hotkeys.rs @@ -74,6 +74,23 @@ pub fn set_open_app_hotkey( super::settings::persist_strict_settings(&coord, prefs) } +/// 设置独立速记快捷键。None = 停用。 +#[tauri::command] +pub fn set_quick_note_hotkey( + coord: CoordinatorState<'_>, + binding: Option, +) -> Result<(), String> { + if let Some(binding) = binding.as_ref() { + crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?; + crate::shortcut_binding::reject_side_specific_non_dictation(binding)?; + reject_modifier_only_action_shortcut(binding)?; + } + let mut prefs = coord.backend().get_preferences(); + prefs.quick_note_hotkey = binding; + reject_hotkey_collisions(&prefs)?; + super::settings::persist_strict_settings(&coord, prefs) +} + /// 设置 Selection Polish 全局快捷键。Core 先产生显式 effect target;Tauri /// 注册成功后才持久化,失败则按 receipt 恢复旧监听器且不写偏好。 /// 选区润色为桌面(Windows-first)工作流,mobile 不注册。 diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index cf7aca06f..71df28a6c 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -469,6 +469,7 @@ struct Inner { translation_hotkey: Mutex>, switch_style_hotkey: Mutex>, open_app_hotkey: Mutex>, + quick_note_hotkey: Mutex>, /// 风格包直达快捷键监听器(issue #759):pack_id → 实际绑定 + monitor。 /// 绑定元数据让 supervisor 能区分「同一 pack_id 但按键已变化」,并在任何 /// 非事务设置路径注册失败后继续重试到实际状态与 prefs 一致。 @@ -517,6 +518,7 @@ struct Inner { enum ActionHotkeyKind { SwitchStyle, OpenApp, + QuickNote, } impl Coordinator { @@ -621,6 +623,7 @@ impl Coordinator { translation_hotkey: Mutex::new(None), switch_style_hotkey: Mutex::new(None), open_app_hotkey: Mutex::new(None), + quick_note_hotkey: Mutex::new(None), style_pack_hotkeys: Mutex::new(std::collections::HashMap::new()), #[cfg(not(mobile))] selection_polish_hotkey: Mutex::new(None), @@ -730,6 +733,7 @@ impl Coordinator { translation_hotkey: Mutex::new(None), switch_style_hotkey: Mutex::new(None), open_app_hotkey: Mutex::new(None), + quick_note_hotkey: Mutex::new(None), style_pack_hotkeys: Mutex::new(std::collections::HashMap::new()), #[cfg(not(mobile))] selection_polish_hotkey: Mutex::new(None), @@ -1036,6 +1040,18 @@ impl Coordinator { take_action_hotkey_on_main_thread(&self.inner, ActionHotkeyKind::OpenApp); } + pub fn start_quick_note_hotkey_listener(&self) { + let inner = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-quick-note-hotkey-supervisor".into()) + .spawn(move || action_hotkey_supervisor_loop(inner, ActionHotkeyKind::QuickNote)) + .ok(); + } + + pub fn stop_quick_note_hotkey_listener(&self) { + take_action_hotkey_on_main_thread(&self.inner, ActionHotkeyKind::QuickNote); + } + /// 启动风格包直达快捷键监听(issue #759)。supervisor 线程等 AppHandle 就绪后 /// 按 prefs 全量注册,个别注册失败按 action hotkey 的节奏重试。 pub fn start_style_pack_hotkey_listeners(&self) { @@ -1256,6 +1272,10 @@ impl Coordinator { self.update_action_hotkey_binding(ActionHotkeyKind::OpenApp); } + pub(crate) fn update_quick_note_hotkey_binding(&self) { + self.update_action_hotkey_binding(ActionHotkeyKind::QuickNote); + } + fn update_action_hotkey_binding(&self, kind: ActionHotkeyKind) { // None = 用户主动停用:反注册全局键,立即生效。 let Some(binding) = action_hotkey_binding(&self.inner, kind) else { @@ -1493,6 +1513,9 @@ impl Coordinator { if previous.open_app != next.open_app { self.update_open_app_hotkey_binding(); } + if previous.quick_note != next.quick_note { + self.update_quick_note_hotkey_binding(); + } if previous.coding_agent_enabled != next.coding_agent_enabled || previous.coding_agent_voice != next.coding_agent_voice { diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 63af12fdb..cac95d42c 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -1548,6 +1548,46 @@ pub(super) fn handle_action_hotkey_pressed(inner: &Arc, kind: ActionHotke match kind { ActionHotkeyKind::SwitchStyle => switch_to_previous_style(inner), ActionHotkeyKind::OpenApp => inner.host.show_main_window(), + ActionHotkeyKind::QuickNote => { + let backend = Arc::clone(&inner.backend); + inner.host.spawn(async move { + let phase = backend.snapshot().dictation.phase; + let result = match phase { + openless_core::DictationPhase::Idle => backend + .start_dictation_with_options(openless_core::DictationStartOptions { + insert_text: false, + output_target: openless_core::DictationOutputTarget::QuickNote, + ..openless_core::DictationStartOptions::default() + }) + .await + .map(|_| ()), + openless_core::DictationPhase::Starting + | openless_core::DictationPhase::Recording + if matches!( + backend.dictation_output_target(), + Some( + openless_core::DictationOutputTarget::QuickNote + | openless_core::DictationOutputTarget::Undecided + ) + ) => + { + backend + .stop_dictation_with_options( + openless_core::DictationStopOptions { + quick_note: Some(true), + ..openless_core::DictationStopOptions::default() + }, + ) + .await + .map(|_| ()) + } + _ => Ok(()), + }; + if let Err(error) = result { + log::warn!("[coord] quick note hotkey failed: {error}"); + } + }); + } } } @@ -1629,6 +1669,7 @@ pub(super) fn action_hotkey_slot( match kind { ActionHotkeyKind::SwitchStyle => &inner.switch_style_hotkey, ActionHotkeyKind::OpenApp => &inner.open_app_hotkey, + ActionHotkeyKind::QuickNote => &inner.quick_note_hotkey, } } @@ -1640,6 +1681,7 @@ pub(super) fn action_hotkey_binding( match kind { ActionHotkeyKind::SwitchStyle => target.switch_style, ActionHotkeyKind::OpenApp => target.open_app, + ActionHotkeyKind::QuickNote => target.quick_note, } } @@ -1661,6 +1703,7 @@ pub(super) fn action_hotkey_bridge_thread_name(kind: ActionHotkeyKind) -> &'stat match kind { ActionHotkeyKind::SwitchStyle => "openless-switch-style-hotkey-bridge", ActionHotkeyKind::OpenApp => "openless-open-app-hotkey-bridge", + ActionHotkeyKind::QuickNote => "openless-quick-note-hotkey-bridge", } } @@ -2256,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( diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index 69fa20b9a..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)] @@ -2423,14 +2424,14 @@ struct TauriActiveRecording { } struct TauriRecordingArchive { - path: PathBuf, + path: Arc>, available: Arc, } impl TauriRecordingArchive { fn new(path: PathBuf, available: bool) -> Self { Self { - path, + path: Arc::new(Mutex::new(path)), available: Arc::new(AtomicBool::new(available)), } } @@ -2442,7 +2443,7 @@ impl RecordingArchive for TauriRecordingArchive { } fn read_pcm(&self) -> BoxFuture<'static, Result, BackendError>> { - let path = self.path.clone(); + let path = self.path.lock().clone(); Box::pin(async move { let wav = tokio::fs::read(&path).await.map_err(|error| { BackendError::new( @@ -2465,7 +2466,7 @@ impl RecordingArchive for TauriRecordingArchive { } fn discard(&self) -> BoxFuture<'static, Result<(), BackendError>> { - let path = self.path.clone(); + let path = self.path.lock().clone(); let available = Arc::clone(&self.available); Box::pin(async move { if !available.load(Ordering::Acquire) { @@ -2493,6 +2494,60 @@ impl RecordingArchive for TauriRecordingArchive { } }) } + + fn promote_to_quick_note(&self) -> BoxFuture<'static, Result<(), BackendError>> { + let path = Arc::clone(&self.path); + Box::pin(async move { + let current = path.lock().clone(); + let Some(file_name) = current.file_name().map(|name| name.to_owned()) else { + return Err(BackendError::new( + BackendErrorCode::Persistence, + "quick-note archive has no file name", + )); + }; + let target = crate::persistence::quick_note_recordings_root() + .map_err(|error| BackendError::new(BackendErrorCode::Persistence, error.to_string()))? + .join(file_name); + if current == target { + return Ok(()); + } + tokio::fs::rename(¤t, &target).await.map_err(|error| { + BackendError::new( + BackendErrorCode::Persistence, + format!("promote quick-note recording archive: {error}"), + ) + })?; + *path.lock() = target; + Ok(()) + }) + } + + fn demote_to_ordinary_recording(&self) -> BoxFuture<'static, Result<(), BackendError>> { + let path = Arc::clone(&self.path); + Box::pin(async move { + let current = path.lock().clone(); + let Some(file_name) = current.file_name().map(|name| name.to_owned()) else { + return Err(BackendError::new( + BackendErrorCode::Persistence, + "recording archive has no file name", + )); + }; + let target = crate::persistence::recordings_root() + .map_err(|error| BackendError::new(BackendErrorCode::Persistence, error.to_string()))? + .join(file_name); + if current == target { + return Ok(()); + } + tokio::fs::rename(¤t, &target).await.map_err(|error| { + BackendError::new( + BackendErrorCode::Persistence, + format!("move recording archive to ordinary storage: {error}"), + ) + })?; + *path.lock() = target; + Ok(()) + }) + } } impl ActiveRecording for TauriActiveRecording { @@ -2566,17 +2621,33 @@ impl AudioRecorder for TauriAudioRecorder { preview.stop(); } } - // QA/划词语音沿用1.x不落盘语义;不要先创建WAV,再依赖停止时删除。 + let permanent_archive = !matches!( + context.output_target, + openless_core::DictationOutputTarget::ForegroundApp + ); + // Undecided Android captures use the permanent quick-note spool + // until the terminal tap/gesture classifies the session. let archive_path = context .recording .archive_enabled - .then(|| crate::persistence::recording_path_for_session(&session_id.to_string())) + .then(|| { + if permanent_archive { + crate::persistence::quick_note_recording_path_for_session( + &session_id.to_string(), + ) + } else { + crate::persistence::recording_path_for_session(&session_id.to_string()) + } + }) .transpose(); let microphone = context.recording.microphone_device_name.clone(); let recording_plan = context.recording.clone(); + let 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 { + if prune_recordings_before_capture { if let Err(error) = crate::persistence::prune_recordings( recording_plan.retention_days, recording_plan.max_entries, @@ -2612,6 +2683,16 @@ impl AudioRecorder for TauriAudioRecorder { return Err(map_recorder_error(error)); } }; + if recording_plan.archive_required && !archive_active { + recorder.stop(); + if let Some(path) = &archive_path { + let _ = std::fs::remove_file(path); + } + return Err(BackendError::new( + BackendErrorCode::Persistence, + "速记录音文件无法创建,已阻止开始录音以避免丢失内容", + )); + } let recording = Box::new(TauriActiveRecording { recorder: Some(recorder), archive: archive_path diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 6a8438546..eccdc898d 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -220,6 +220,7 @@ macro_rules! app_invoke_handler_desktop { commands::read_audio_recording, commands::export_audio_recording, commands::retranscribe_recording, + commands::apply_quick_note_repolish, commands::marketplace_list, commands::marketplace_detail, commands::marketplace_install, @@ -325,6 +326,7 @@ macro_rules! app_invoke_handler_desktop { commands::set_translation_hotkey, commands::set_switch_style_hotkey, commands::set_open_app_hotkey, + commands::set_quick_note_hotkey, commands::set_style_pack_hotkeys, commands::qa_window_dismiss, commands::qa_toggle_recording, @@ -468,6 +470,7 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::read_audio_recording, $crate::commands::export_audio_recording, $crate::commands::retranscribe_recording, + $crate::commands::apply_quick_note_repolish, $crate::commands::marketplace_list, $crate::commands::marketplace_detail, $crate::commands::marketplace_install, @@ -885,6 +888,7 @@ fn run_desktop() { coordinator.start_translation_hotkey_listener(); coordinator.start_switch_style_hotkey_listener(); coordinator.start_open_app_hotkey_listener(); + coordinator.start_quick_note_hotkey_listener(); coordinator.start_style_pack_hotkey_listeners(); } #[cfg(target_os = "macos")] @@ -908,6 +912,7 @@ fn run_desktop() { coordinator.stop_translation_hotkey_listener(); coordinator.stop_switch_style_hotkey_listener(); coordinator.stop_open_app_hotkey_listener(); + coordinator.stop_quick_note_hotkey_listener(); coordinator.stop_style_pack_hotkey_listeners(); let backend = coordinator.backend(); tauri::async_runtime::spawn(async move { diff --git a/openless-all/app/src-tauri/src/persistence/paths.rs b/openless-all/app/src-tauri/src/persistence/paths.rs index 19d118e70..01a242075 100644 --- a/openless-all/app/src-tauri/src/persistence/paths.rs +++ b/openless-all/app/src-tauri/src/persistence/paths.rs @@ -64,6 +64,14 @@ pub fn recordings_root() -> Result { Ok(dir) } +/// Permanent quick-note archives live outside the ordinary debug-recording +/// directory so the normal WAV count/retention prune can never remove them. +pub fn quick_note_recordings_root() -> Result { + let dir = data_dir()?.join("quick-notes").join("recordings"); + ensure_dir(&dir)?; + Ok(dir) +} + /// 双重 cap 清理 `recordings/*.wav`: /// - `retention_days > 0` → 把超过 N 天的删掉(沿用 history 的 retention 逻辑)。 /// - `max_entries == Some(n)` → 按 mtime 倒序保留最新的 n 条(clamp 到 1..=HISTORY_CAP); @@ -139,6 +147,10 @@ pub fn recording_path_for_session(session_id: &str) -> Result { Ok(recordings_root()?.join(format!("{session_id}.wav"))) } +pub fn quick_note_recording_path_for_session(session_id: &str) -> Result { + Ok(quick_note_recordings_root()?.join(format!("{session_id}.wav"))) +} + /// Foundry Local 下载与缓存根目录。DLL 和模型都不打进安装包,和 Qwen3-ASR /// 一样放在 OpenLess 的 models 目录下,卸载清理用户数据时可以一起删除。 #[cfg(target_os = "windows")] diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index f5ee9dde9..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); @@ -772,6 +877,7 @@ fn update_peak(slot: &AtomicUsize, current: f32) { struct WavArchiver { file: std::fs::File, bytes_written: u32, + last_checkpoint_bytes: u32, } impl WavArchiver { @@ -785,6 +891,7 @@ impl WavArchiver { Ok(Self { file, bytes_written: 0, + last_checkpoint_bytes: 0, }) } @@ -794,21 +901,43 @@ impl WavArchiver { self.bytes_written = self .bytes_written .saturating_add(pcm_bytes.len().min(u32::MAX as usize) as u32); + // Keep the header usable during a long meeting. Drop still does + // the final sync, but a process kill should not leave a WAV with + // data_size=0 for the entire recording. + const CHECKPOINT_INTERVAL_BYTES: u32 = 160_000; + if self + .bytes_written + .saturating_sub(self.last_checkpoint_bytes) + >= CHECKPOINT_INTERVAL_BYTES + { + self.checkpoint_header(); + } } } -} -impl Drop for WavArchiver { - fn drop(&mut self) { + fn checkpoint_header(&mut self) { use std::io::{Seek, SeekFrom, Write}; - let header = build_wav_header(self.bytes_written); if self.file.seek(SeekFrom::Start(0)).is_ok() { - let _ = self.file.write_all(&header); - let _ = self.file.sync_all(); + if self + .file + .write_all(&build_wav_header(self.bytes_written)) + .is_ok() + { + let _ = self.file.seek(SeekFrom::End(0)); + let _ = self.file.sync_data(); + self.last_checkpoint_bytes = self.bytes_written; + } } } } +impl Drop for WavArchiver { + fn drop(&mut self) { + self.checkpoint_header(); + let _ = self.file.sync_all(); + } +} + fn build_wav_header(data_size: u32) -> [u8; 44] { // RIFF/WAVE PCM 标准 44-byte header,16 kHz / mono / 16-bit 写死。 let total_size = data_size.saturating_add(36); diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index d74b7c4de..114462417 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -15,6 +15,7 @@ import { Style } from '../pages/Style'; import { Marketplace } from '../pages/Marketplace'; import { Translation } from '../pages/Translation'; import { SelectionAsk } from '../pages/SelectionAsk'; +import { QuickNote } from '../pages/QuickNote'; import { Corrections } from '../pages/Corrections'; import { APP_VERSION_LABEL, IS_BETA_BUILD } from '../lib/appVersion'; import { @@ -39,7 +40,7 @@ import { useMobileLayout, useConservativeLayout } from '../lib/useMobileLayout'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { useAppState, type AppTab } from '../state/useAppState'; -const MORE_TAB_IDS: AppTab[] = ['vocab', 'translation', 'selectionAsk', 'corrections']; +const MORE_TAB_IDS: AppTab[] = ['vocab', 'translation', 'selectionAsk', 'quickNote', 'corrections']; const STYLE_TAB_IDS: AppTab[] = ['style', 'marketplace']; /** Reserve the native traffic-light strip before the sidebar's version row. */ @@ -55,6 +56,7 @@ const PAGE_CMP: Record, ComponentType> = { marketplace: Marketplace, translation: Translation, selectionAsk: SelectionAsk, + quickNote: QuickNote, corrections: Corrections, }; @@ -77,7 +79,12 @@ const NAV_TREE: NavNode[] = [ kind: 'group', key: 'tools', icon: 'selectionAsk', - children: [{ id: 'translation' }, { id: 'selectionAsk' }, { id: 'corrections' }], + children: [ + { id: 'translation' }, + { id: 'selectionAsk' }, + { id: 'quickNote' }, + { id: 'corrections' }, + ], }, ]; diff --git a/openless-all/app/src/components/MobileMoreSheet.tsx b/openless-all/app/src/components/MobileMoreSheet.tsx index 8bfc483ce..f0388608c 100644 --- a/openless-all/app/src/components/MobileMoreSheet.tsx +++ b/openless-all/app/src/components/MobileMoreSheet.tsx @@ -7,6 +7,7 @@ const MORE_TABS: Array<{ id: AppTab; icon: string }> = [ { id: 'vocab', icon: 'vocab' }, { id: 'translation', icon: 'translate' }, { id: 'selectionAsk', icon: 'selectionAsk' }, + { id: 'quickNote', icon: 'history' }, { id: 'corrections', icon: 'filter' }, ]; diff --git a/openless-all/app/src/components/chat/chat.css b/openless-all/app/src/components/chat/chat.css index 7a0cbd280..c008de616 100644 --- a/openless-all/app/src/components/chat/chat.css +++ b/openless-all/app/src/components/chat/chat.css @@ -267,13 +267,18 @@ height: 100%; } -/* ─── 助手 markdown 正文(原 qa-answer / lc-answer 合并,token 换 shadcn)── */ +/* ─── 助手 markdown 正文(原 qa-answer / lc-answer 合并,token 换 shadcn)── + * Inherit host color so History/QuickNote follow app light/dark ink tokens. + * Inside .olchat-shell the shell still pins a light foreground (intentional). */ .olchat-answer { font-size: 0.8125rem; line-height: 1.6; - color: var(--color-foreground); + color: inherit; word-break: break-word; } +.olchat-shell .olchat-answer { + color: var(--color-foreground); +} .olchat-answer p { margin: 0 0 6px; } @@ -323,11 +328,14 @@ background: transparent; } .olchat-answer a { - color: var(--color-foreground); + color: inherit; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; } +.olchat-shell .olchat-answer a { + color: var(--color-foreground); +} .olchat-answer blockquote { margin: 0 0 6px; padding: 4px 0 4px 8px; diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index ea3628ad5..367e46544 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -171,6 +171,7 @@ export const de: typeof zhCN = { marketplace: 'Marktplatz', translation: 'Übersetzung', selectionAsk: 'Nachfragen', + quickNote: 'Schnellnotizen', corrections: 'Korrekturen', polishMode: 'Überarbeitungsmodus', group: { @@ -302,6 +303,7 @@ export const de: typeof zhCN = { translation: 'Übersetzung: Beim Sprechen Shift gedrückt halten, um Text in der Zielsprache einzufügen', selectionAsk: 'Zum ausgewählten Text fragen: Text auswählen und eine Frage dazu sprechen', + quickNote: 'Schnellnotizen: Audio speichern und später wiedergeben', settings: 'Einstellungen: Kurzbefehle, Dienste, Datenschutz und Updates', }, footer: { @@ -505,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.', @@ -528,6 +540,7 @@ export const de: typeof zhCN = { inserted: 'Eingefügt', pasteSent: 'Einfügebefehl gesendet', copiedFallback: 'Kopiert (mit {{shortcut}} einfügen)', + notRequested: 'Nicht eingefügt', insertFailed: 'Einfügen fehlgeschlagen', confirmClear: 'Alle {{count}} Verlaufseinträge löschen? Dies kann nicht rückgängig gemacht werden.', @@ -898,6 +911,23 @@ export const de: typeof zhCN = { step2: 'Wähle Text in einer beliebigen App aus.', }, }, + quickNote: { + kicker: 'Schnellnotizen', + title: 'Schnellnotizen', + desc: 'Audio dauerhaft behalten, mit Wiedergabe, Export, Neu-Transkription und Überarbeitung.', + recording: 'Aufnahme läuft …', + failedTitle: 'Aufnahme benötigt Aufmerksamkeit', + emptyTitle: 'Unbenannte Aufnahme', + noTranscript: 'Noch keine Transkription.', + applyResult: 'Auf Notiz anwenden', + applying: 'Wird angewendet …', + shortcutTitle: 'Schnellnotizen-Kurzbefehl', + shortcutDesc: 'Einmal drücken zum Aufnehmen, erneut drücken zum Speichern.', + showShortcut: 'Schnellnotizen-Kurzbefehl anzeigen', + repolishNeedsTranscript: 'Bitte zuerst die Audiodatei neu transkribieren.', + shareRecording: 'Audio teilen', + cancelledTitle: 'Aufnahme abgebrochen', + }, settings: { selectionWorkspace: { title: 'Assistent für Textauswahl', @@ -1514,6 +1544,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', @@ -1732,6 +1765,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 bf2d03b75..ca67bb7bd 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -171,6 +171,7 @@ export const en: typeof zhCN = { marketplace: 'Marketplace', translation: 'Translation', selectionAsk: 'Ask', + quickNote: 'Quick notes', corrections: 'Corrections', polishMode: 'Polish mode', group: { @@ -298,6 +299,7 @@ export const en: typeof zhCN = { style: 'Polish styles: manage output styles and custom prompts', translation: 'Translation: hold Shift while speaking to insert in a target language', selectionAsk: 'Selection ask: select text, then ask about it by voice', + quickNote: 'Quick notes: keep audio and revisit the transcript', settings: 'Preferences: shortcuts, providers, privacy and updates', }, footer: { @@ -497,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.', @@ -520,6 +532,7 @@ export const en: typeof zhCN = { inserted: 'Inserted', pasteSent: 'Paste sent', copiedFallback: 'Copied (use {{shortcut}})', + notRequested: 'Not inserted', insertFailed: 'Insert failed', confirmClear: 'Delete all {{count}} history entries? This cannot be undone.', backToList: 'Back to list', @@ -879,6 +892,23 @@ export const en: typeof zhCN = { step2: 'Select text in any app.', }, }, + quickNote: { + kicker: 'Quick notes', + title: 'Quick notes', + desc: 'Permanent audio with playback, export, retranscription, and repolish.', + recording: 'Recording…', + failedTitle: 'Recording needs attention', + emptyTitle: 'Untitled recording', + noTranscript: 'No transcript yet.', + applyResult: 'Apply to note', + applying: 'Applying…', + shortcutTitle: 'Quick note shortcut', + shortcutDesc: 'Press once to start a permanent capture, then press again to finish.', + showShortcut: 'Show quick note shortcut', + repolishNeedsTranscript: 'Re-transcribe the audio before repolishing.', + shareRecording: 'Share audio', + cancelledTitle: 'Recording cancelled', + }, settings: { selectionWorkspace: { title: 'Selection Assistant', @@ -1478,6 +1508,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', @@ -1682,6 +1714,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 0c71ec6f2..08581b339 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -171,6 +171,7 @@ export const es: typeof zhCN = { marketplace: 'Catálogo', translation: 'Traducción', selectionAsk: 'Preguntar', + quickNote: 'Notas rápidas', corrections: 'Correcciones', polishMode: 'Modo de redacción', group: { @@ -301,6 +302,7 @@ export const es: typeof zhCN = { translation: 'Traducción: mantén pulsada Mayús mientras hablas para insertar el texto en otro idioma', selectionAsk: 'Preguntar sobre una selección: selecciona texto y pregunta por voz', + quickNote: 'Notas rápidas: conserva el audio y vuelve al texto cuando quieras', settings: 'Preferencias: atajos, proveedores, privacidad y actualizaciones', }, footer: { @@ -504,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.', @@ -527,6 +539,7 @@ export const es: typeof zhCN = { inserted: 'Insertado', pasteSent: 'Pegado enviado', copiedFallback: 'Copiado (usa {{shortcut}})', + notRequested: 'No se solicitó insertar', insertFailed: 'No se pudo insertar', confirmClear: '¿Eliminar los {{count}} registros del historial? Esta acción no se puede deshacer.', @@ -893,6 +906,23 @@ export const es: typeof zhCN = { step2: 'Selecciona texto en cualquier aplicación.', }, }, + quickNote: { + kicker: 'Notas rápidas', + title: 'Notas rápidas', + desc: 'Audio permanente con reproducción, exportación, retranscripción y redacción.', + recording: 'Grabando…', + failedTitle: 'La grabación necesita atención', + emptyTitle: 'Grabación sin título', + noTranscript: 'Aún no hay transcripción.', + applyResult: 'Aplicar a la nota', + applying: 'Aplicando…', + shortcutTitle: 'Atajo de nota rápida', + shortcutDesc: 'Pulsa una vez para grabar y otra vez para guardar.', + showShortcut: 'Mostrar el atajo de nota rápida', + repolishNeedsTranscript: 'Primero vuelve a transcribir el audio.', + shareRecording: 'Compartir audio', + cancelledTitle: 'Grabación cancelada', + }, settings: { selectionWorkspace: { title: 'Asistente de selección', @@ -1506,6 +1536,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', @@ -1719,6 +1752,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 692b1e9d2..f58f9d5c7 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -171,6 +171,7 @@ export const fr: typeof zhCN = { marketplace: 'Catalogue', translation: 'Traduction', selectionAsk: 'Questions', + quickNote: 'Notes vocales', corrections: 'Corrections', polishMode: 'Mode de rédaction', group: { @@ -304,6 +305,7 @@ export const fr: typeof zhCN = { 'Traduction : maintenez Maj pendant que vous parlez pour insérer le texte dans une autre langue', selectionAsk: 'Questions sur la sélection : sélectionnez du texte, puis posez une question à voix haute', + quickNote: 'Notes vocales : conservez l’audio et relisez la transcription', settings: 'Préférences : raccourcis, fournisseurs, confidentialité et mises à jour', }, footer: { @@ -510,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.', @@ -533,6 +545,7 @@ export const fr: typeof zhCN = { inserted: 'Inséré', pasteSent: 'Collage envoyé', copiedFallback: 'Copié (utilisez {{shortcut}})', + notRequested: 'Insertion non demandée', insertFailed: 'Échec de l’insertion', confirmClear: 'Supprimer les {{count}} entrées de l’historique ? Cette action est irréversible.', @@ -905,6 +918,23 @@ export const fr: typeof zhCN = { step2: 'Sélectionnez du texte dans une application.', }, }, + quickNote: { + kicker: 'Notes vocales', + title: 'Notes vocales', + desc: 'Audio conservé durablement avec lecture, export, retranscription et réécriture.', + recording: 'Enregistrement…', + failedTitle: 'La note nécessite une intervention', + emptyTitle: 'Enregistrement sans titre', + noTranscript: 'Aucune transcription pour le moment.', + applyResult: 'Appliquer à la note', + applying: 'Application…', + shortcutTitle: 'Raccourci de note vocale', + shortcutDesc: 'Appuyez une fois pour enregistrer, puis à nouveau pour sauvegarder.', + showShortcut: 'Afficher le raccourci de note vocale', + repolishNeedsTranscript: 'Retranscrivez d’abord l’audio avant la réécriture.', + shareRecording: 'Partager l’audio', + cancelledTitle: 'Enregistrement annulé', + }, settings: { selectionWorkspace: { title: 'Assistant de sélection', @@ -1526,6 +1556,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', @@ -1739,6 +1772,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 7e5440262..70294ac6e 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -162,6 +162,7 @@ export const ja: typeof zhCN = { marketplace: 'マーケット', translation: '翻訳', selectionAsk: '選択追問', + quickNote: '速記', corrections: '修正ルール', polishMode: '推敲モード', group: { @@ -289,6 +290,7 @@ export const ja: typeof zhCN = { style: 'スタイル:出力スタイルとカスタムプロンプトを管理', translation: '翻訳:Shift を押しながら話すと目標言語で挿入', selectionAsk: '選択質問:テキストを選択して音声で質問', + quickNote: '速記:音声を保存して後から確認', settings: '環境設定:ショートカット・プロバイダー・プライバシー・更新', }, footer: { @@ -485,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 件選択して詳細を表示。', @@ -508,6 +520,7 @@ export const ja: typeof zhCN = { inserted: '入力済み', pasteSent: '貼り付けを試行', copiedFallback: 'コピー済み(要 {{shortcut}})', + notRequested: '入力なし', insertFailed: '入力失敗', confirmClear: '全 {{count}} 件の記録を削除しますか?この操作は取り消せません。', backToList: '一覧に戻る', @@ -866,6 +879,23 @@ export const ja: typeof zhCN = { step2: '任意のアプリでテキストを選択。', }, }, + quickNote: { + kicker: '速記', + title: '速記', + desc: '音声を保持し、再生・書き出し・再文字起こし・再推敲に対応します。', + recording: '録音中…', + failedTitle: '録音の確認が必要です', + emptyTitle: '無題の録音', + noTranscript: 'まだ文字起こしがありません。', + applyResult: '速記に適用', + applying: '適用中…', + shortcutTitle: '速記ショートカット', + shortcutDesc: '一度押して録音を開始し、もう一度押して保存します。', + showShortcut: '速記ショートカットを表示', + repolishNeedsTranscript: '先に音声を再文字起こししてください。', + shareRecording: '音声を共有', + cancelledTitle: '録音をキャンセルしました', + }, settings: { selectionWorkspace: { title: '選択範囲アシスタント', @@ -1463,6 +1493,8 @@ export const ja: typeof zhCN = { descNoAcc: 'すべてのショートカットはグローバルで有効。応答がない場合は権限ページでグローバルショートカット監視の状態を確認してください。', startStop: '録音開始 / 停止', + quickNote: '速記', + quickNoteDesc: '1回押して音声メモを開始し、もう一度押して終了します。', cancel: '本回の録音をキャンセル', confirm: 'カプセル入力を確定', switchStyle: '前のスタイルに切り替え', @@ -1652,6 +1684,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 6dc745a55..d65c7fb07 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -160,6 +160,7 @@ export const ko: typeof zhCN = { marketplace: '마켓', translation: '번역', selectionAsk: '선택 질문', + quickNote: '속기', corrections: '교정 규칙', polishMode: '다듬기 모드', group: { @@ -287,6 +288,7 @@ export const ko: typeof zhCN = { style: '스타일: 출력 스타일과 사용자 지정 프롬프트 관리', translation: '번역: Shift를 누른 채 말하면 대상 언어로 삽입', selectionAsk: '선택 질문: 텍스트를 선택한 뒤 음성으로 질문', + quickNote: '속기: 오디오를 저장하고 나중에 다시 확인', settings: '환경설정: 단축키, 제공자, 개인정보 및 업데이트', }, footer: { @@ -483,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: '왼쪽에서 하나를 선택하여 자세히 보기.', @@ -506,6 +518,7 @@ export const ko: typeof zhCN = { inserted: '입력됨', pasteSent: '붙여넣기 시도됨', copiedFallback: '복사됨({{shortcut}} 필요)', + notRequested: '입력하지 않음', insertFailed: '입력 실패', confirmClear: '전체 {{count}}건의 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.', backToList: '목록으로', @@ -864,6 +877,23 @@ export const ko: typeof zhCN = { step2: '아무 앱에서 텍스트 선택.', }, }, + quickNote: { + kicker: '속기', + title: '속기', + desc: '오디오를 영구 보관하고 재생·내보내기·재전사·다시 다듬기를 지원합니다.', + recording: '녹음 중…', + failedTitle: '녹음을 확인해야 합니다', + emptyTitle: '제목 없는 녹음', + noTranscript: '아직 전사 내용이 없습니다.', + applyResult: '속기에 적용', + applying: '적용 중…', + shortcutTitle: '속기 단축키', + shortcutDesc: '한 번 눌러 녹음하고 다시 눌러 저장합니다.', + showShortcut: '속기 단축키 표시', + repolishNeedsTranscript: '먼저 오디오를 다시 전사해 주세요.', + shareRecording: '오디오 공유', + cancelledTitle: '녹음이 취소됨', + }, settings: { selectionWorkspace: { title: '선택 영역 도우미', @@ -1453,6 +1483,8 @@ export const ko: typeof zhCN = { descNoAcc: '모든 단축키는 전역에서 작동. 응답이 없으면 권한 페이지에서 전역 단축키 감지 상태를 확인해 주세요.', startStop: '녹음 시작 / 정지', + quickNote: '속기', + quickNoteDesc: '한 번 눌러 영구 오디오 메모를 시작하고, 다시 눌러 종료합니다.', cancel: '이번 녹음 취소', confirm: '캡슐 입력 확정', switchStyle: '이전 스타일로 전환', @@ -1641,6 +1673,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 f503982eb..299dea061 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -162,6 +162,7 @@ export const zhCN = { marketplace: '风格市场', translation: '翻译', selectionAsk: '划词追问', + quickNote: '速记', corrections: '纠正规则', polishMode: '润色模式', group: { @@ -287,6 +288,7 @@ export const zhCN = { style: '润色风格:管理输出风格与自定义提示词', translation: '翻译:按住 Shift 说话,译成目标语言插入', selectionAsk: '划词追问:选中文字后语音提问', + quickNote: '速记:保存录音并整理为可回看的记录', settings: '偏好设置:快捷键、提供商、隐私与更新', }, footer: { @@ -477,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: '左侧选一条查看详情。', @@ -499,6 +511,7 @@ export const zhCN = { inserted: '已插入', pasteSent: '已尝试粘贴', copiedFallback: '已复制(需 {{shortcut}})', + notRequested: '未请求插入', insertFailed: '插入失败', confirmClear: '确定清空全部 {{count}} 条记录?此操作不可恢复。', backToList: '返回列表', @@ -849,6 +862,23 @@ export const zhCN = { step2: '在任意 app 选中文字。', }, }, + quickNote: { + kicker: '速记', + title: '速记', + desc: '永久保留录音,支持回放、导出、重新转录和重新润色。', + recording: '录音中…', + failedTitle: '录音需要处理', + emptyTitle: '未命名录音', + noTranscript: '还没有转写内容。', + applyResult: '应用到速记', + applying: '应用中…', + shortcutTitle: '速记快捷键', + shortcutDesc: '按一次开始永久录音,再按一次结束并保存。', + showShortcut: '显示速记快捷键', + repolishNeedsTranscript: '请先重新转录录音,再进行润色。', + shareRecording: '分享录音', + cancelledTitle: '已取消录音', + }, settings: { selectionWorkspace: { title: '选区助手', @@ -1399,6 +1429,8 @@ export const zhCN = { descAcc: '所有快捷键全局生效,需要在权限设置中开启辅助功能。', descNoAcc: '所有快捷键全局生效。若无响应,请在权限页查看全局快捷键监听状态。', startStop: '开始 / 停止录音', + quickNote: '速记', + quickNoteDesc: '按一次开始永久保留的录音,再按一次结束录音。', cancel: '取消本次录音', confirm: '胶囊确认插入', switchStyle: '切换到上一个风格', @@ -1588,6 +1620,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 e2b3cff71..f25271473 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -164,6 +164,7 @@ export const zhTW: typeof zhCN = { marketplace: '風格市場', translation: '翻譯', selectionAsk: '劃詞追問', + quickNote: '速記', corrections: '糾正規則', polishMode: '潤色模式', group: { @@ -289,6 +290,7 @@ export const zhTW: typeof zhCN = { style: '潤色風格:管理輸出風格與自訂提示詞', translation: '翻譯:按住 Shift 說話,譯成目標語言插入', selectionAsk: '劃詞追問:選取文字後語音提問', + quickNote: '速記:保存錄音並整理成可回看的記錄', settings: '偏好設定:快捷鍵、提供商、隱私與更新', }, footer: { @@ -479,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: '左側選一條查看詳情。', @@ -501,6 +513,7 @@ export const zhTW: typeof zhCN = { inserted: '已插入', pasteSent: '已嘗試粘貼', copiedFallback: '已複製(需 {{shortcut}})', + notRequested: '未要求插入', insertFailed: '插入失敗', confirmClear: '確定清空全部 {{count}} 條記錄?此操作不可恢復。', backToList: '返回列表', @@ -851,6 +864,23 @@ export const zhTW: typeof zhCN = { step2: '在任意 app 選中文字。', }, }, + quickNote: { + kicker: '速記', + title: '速記', + desc: '永久保留錄音,支援回放、匯出、重新轉錄與重新潤色。', + recording: '錄音中…', + failedTitle: '錄音需要處理', + emptyTitle: '未命名錄音', + noTranscript: '尚未有轉錄內容。', + applyResult: '套用到速記', + applying: '套用中…', + shortcutTitle: '速記快捷鍵', + shortcutDesc: '按一下開始永久錄音,再按一下結束並保存。', + showShortcut: '顯示速記快捷鍵', + repolishNeedsTranscript: '請先重新轉錄錄音,再進行潤色。', + shareRecording: '分享錄音', + cancelledTitle: '已取消錄音', + }, settings: { selectionWorkspace: { title: '選區助手', @@ -1401,6 +1431,8 @@ export const zhTW: typeof zhCN = { descAcc: '所有快捷鍵全局生效,需要在權限設置中開啓輔助功能。', descNoAcc: '所有快捷鍵全局生效。若無響應,請在權限頁查看全局快捷鍵監聽狀態。', startStop: '開始 / 停止錄音', + quickNote: '速記', + quickNoteDesc: '按一次開始永久保留的錄音,再按一次結束錄音。', cancel: '取消本次錄音', confirm: '膠囊確認插入', switchStyle: '切換到上一個風格', @@ -1574,6 +1606,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/history-retranscribe.test.ts b/openless-all/app/src/lib/history-retranscribe.test.ts index 8c2a4a19f..948a7428a 100644 --- a/openless-all/app/src/lib/history-retranscribe.test.ts +++ b/openless-all/app/src/lib/history-retranscribe.test.ts @@ -44,5 +44,13 @@ assert( }), 'multimodal entries should not show an unsupported retranscription action', ); +assert( + canRetranscribeHistoryEntry({ + ...archivedEntry, + pipelineMode: 'multimodal', + source: 'quick_note', + }), + 'quick notes should retain retranscription even when their original pipeline was multimodal', +); console.log('history-retranscribe: all assertions passed'); diff --git a/openless-all/app/src/lib/history-retranscribe.ts b/openless-all/app/src/lib/history-retranscribe.ts index 2bd5ee872..cdad4d4ca 100644 --- a/openless-all/app/src/lib/history-retranscribe.ts +++ b/openless-all/app/src/lib/history-retranscribe.ts @@ -1,14 +1,14 @@ import type { DictationSession } from './types'; /** - * 重新转录需要一份仍存在的 WAV 归档。多模态历史目前没有对应的重转录 - * provider 通道,避免展示一个点击后必然失败的按钮。 - * - * 成功转录、润色失败和转录失败的条目都可能有可用录音,用户都应能用同一份 - * 音频重新验证当前 ASR provider。是否回写失败记录由后端决定。 + * 重新转录需要一份仍存在的 WAV 归档。成功转录、润色失败、转录失败和速记 + * 都可以用同一份音频重新验证当前 ASR provider。是否回写失败记录由后端决定。 */ export function canRetranscribeHistoryEntry( - session: Pick, + session: Pick, ): boolean { - return session.hasAudioRecording === true && session.pipelineMode !== 'multimodal'; + return ( + session.hasAudioRecording === true && + (session.pipelineMode !== 'multimodal' || session.source === 'quick_note') + ); } diff --git a/openless-all/app/src/lib/ipc/history.ts b/openless-all/app/src/lib/ipc/history.ts index ac1f869b0..da8098e34 100644 --- a/openless-all/app/src/lib/ipc/history.ts +++ b/openless-all/app/src/lib/ipc/history.ts @@ -41,3 +41,15 @@ export function retranscribeRecording(sessionId: string): Promise; } + +export function applyQuickNoteRepolish( + sessionId: string, + text: string, + stylePackId?: string, +): Promise { + return invokeOrMock( + 'apply_quick_note_repolish', + { sessionId, text, stylePackId: stylePackId ?? null }, + () => mockHistory[0], + ) as Promise; +} diff --git a/openless-all/app/src/lib/ipc/hotkeys.ts b/openless-all/app/src/lib/ipc/hotkeys.ts index 0ba4dcbba..0df1e1a18 100644 --- a/openless-all/app/src/lib/ipc/hotkeys.ts +++ b/openless-all/app/src/lib/ipc/hotkeys.ts @@ -85,6 +85,13 @@ export function setOpenAppHotkey(binding: ShortcutBinding | null): Promise return invokeOrMock('set_open_app_hotkey', { binding }, () => undefined); } +export function setQuickNoteHotkey(binding: ShortcutBinding | null): Promise { + return invokeOrMock('set_quick_note_hotkey', { binding }, () => { + mockSetSettings({ ...mockSettings, quickNoteHotkey: binding }); + return undefined; + }); +} + // 风格包直达快捷键:整表替换(前端任何增删改都发全量列表,issue #759)。 export function setStylePackHotkeys(hotkeys: StylePackHotkey[]): Promise { return invokeOrMock('set_style_pack_hotkeys', { hotkeys }, () => { diff --git a/openless-all/app/src/lib/ipc/index.ts b/openless-all/app/src/lib/ipc/index.ts index 5f984a570..971f257c8 100644 --- a/openless-all/app/src/lib/ipc/index.ts +++ b/openless-all/app/src/lib/ipc/index.ts @@ -65,6 +65,7 @@ export { getActivityStats, readAudioRecording, retranscribeRecording, + applyQuickNoteRepolish, } from './history'; // vocab @@ -140,6 +141,7 @@ export { setTranslationHotkey, setSwitchStyleHotkey, setOpenAppHotkey, + setQuickNoteHotkey, setStylePackHotkeys, setShortcutRecordingActive, } from './hotkeys'; diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 0298d0e01..b69990f9c 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -71,6 +71,7 @@ export let mockSettings: UserPreferences = { workingLanguages: ['简体中文'], translationTargetLanguage: '', qaHotkey: defaultQaShortcut(), + quickNoteHotkey: null, selectionPolishStylePackId: 'builtin.light', selectionPolishOutputMode: 'directReplace', selectionPolishHotkey: defaultSelectionPolishShortcut(), @@ -128,6 +129,7 @@ export let mockSettings: UserPreferences = { historyMaxEntries: null, recordAudioForDebug: false, audioRecordingMaxEntries: null, + quickNoteExportDirectory: '', marketplaceBaseUrl: 'https://apic.openless.top', marketplaceDevLogin: '', remoteInputEnabled: false, @@ -139,6 +141,12 @@ export let mockSettings: UserPreferences = { androidOverlayActivationMode: 'tap', androidOverlayLeftSwipeAction: 'translation', androidOverlayCancelSwipeDirection: 'up', + androidOverlayGestureActions: { + up: 'cancel', + down: 'none', + left: 'translation', + right: 'qa', + }, androidOverlaySizeDp: 72, }; diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 25ecc2cd2..03b6c36ff 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -7,6 +7,8 @@ import type { AndroidInsertStrategy, AndroidOverlayActivationMode, AndroidOverlayCancelSwipeDirection, + AndroidOverlayGestureAction, + AndroidOverlayGestureActions, AndroidOverlayLeftSwipeAction, AndroidOverlayStatus, AndroidOverlayTrigger, @@ -17,6 +19,8 @@ export type { AndroidInsertStrategy, AndroidOverlayActivationMode, AndroidOverlayCancelSwipeDirection, + AndroidOverlayGestureAction, + AndroidOverlayGestureActions, AndroidOverlayLeftSwipeAction, AndroidOverlayStatus, AndroidOverlayTrigger, @@ -29,7 +33,14 @@ export type PolishMode = 'raw' | 'light' | 'structured' | 'formal'; * 两套配置在凭据库中完全隔离,运行时只读当前模式。 */ export type PipelineMode = 'traditional' | 'multimodal'; -export type InsertStatus = 'inserted' | 'pasteSent' | 'copiedFallback' | 'failed'; +export type InsertStatus = + | 'inserted' + | 'pasteSent' + | 'copiedFallback' + | 'failed' + | 'notRequested'; + +export type HistorySource = 'voice' | 'quick_note' | 'selection_polish' | 'selection_voice_edit'; /** 概览页年度活动热力图的单日计数(date = 本地日期 YYYY-MM-DD)。 */ export interface ActivityDay { @@ -44,6 +55,7 @@ export interface ActivityDay { export interface DictationSession { id: string; createdAt: string; // ISO-8601 + source?: HistorySource; rawTranscript: string; /** 纠正规则**之前**的 ASR 原文。`rawTranscript` 存的是规则跑完之后的版本, * 两者相同时后端不写这个字段(null)。用于归因:一次误识别到底是 ASR 听错还是 @@ -396,6 +408,8 @@ export interface UserPreferences { outputLanguagePreference: 'auto' | 'zhCn' | 'zhTw' | 'en' | 'ja' | 'ko'; /** 划词语音问答快捷键。null = 未启用。详见 issue #118。 */ qaHotkey: QaHotkeyBinding | null; + /** 独立速记快捷键。null = 未配置。 */ + quickNoteHotkey: ShortcutBinding | null; /** 选区润色快捷键。null = 已停用。 */ selectionPolishHotkey: ShortcutBinding | null; /** The style pack used only by selected written-text polishing. */ @@ -518,6 +532,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。 */ @@ -540,6 +556,8 @@ export interface UserPreferences { androidOverlayLeftSwipeAction: AndroidOverlayLeftSwipeAction; /** Android: vertical swipe direction that cancels recording. */ androidOverlayCancelSwipeDirection: AndroidOverlayCancelSwipeDirection; + /** Android: action assigned to each overlay swipe direction. */ + androidOverlayGestureActions: AndroidOverlayGestureActions; /** Android: floating overlay control diameter in dp. */ androidOverlaySizeDp: number; /** 开屏 PV 的主版本世代标记(如 '2')。空 = 从未播过;由 Rust 侧 diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index f91c7d20f..b129c4485 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -5,10 +5,12 @@ 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 { clearHistory, + applyQuickNoteRepolish, deleteHistoryEntry, listHistory, listStylePacks, @@ -25,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'; @@ -68,7 +69,7 @@ function styleLabelFor( return pack ? packDisplayName(pack, modeLabel) : modeLabel[session.mode]; } -export function History() { +export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } = {}) { const { t, i18n } = useTranslation(); const locale = i18n.resolvedLanguage || i18n.language; const os = detectOS(); @@ -82,12 +83,16 @@ export function History() { 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 加进来, @@ -102,7 +107,7 @@ export function History() { 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); @@ -111,6 +116,7 @@ export function History() { useEffect(() => { if (!mobile) setMobileDetailOpen(true); }, [mobile]); + // 风格包在本页有两个用途:给历史条目显示包名、给「重新润色」面板选风格。加载提到这里 // 一次拿全,两处共用,省掉切换条目时 RepolishPanel 重挂载带来的重复 IPC。 // 注意这里存的是**全部**包(含已禁用):历史条目可能出自后来被禁用的包,显示名字要能查到; @@ -123,10 +129,11 @@ export function History() { setLoadError(null); try { const data = await listHistory(); - setItems(data); + const visible = quickNotesOnly ? data.filter((entry) => entry.source === 'quick_note') : data; + setItems(visible); setActionError(null); setSelectedId((prev) => - prev && data.some((s) => s.id === prev) ? prev : (data[0]?.id ?? null), + prev && visible.some((s) => s.id === prev) ? prev : (visible[0]?.id ?? null), ); } catch (error) { console.error('[history] failed to load history', error); @@ -134,7 +141,7 @@ export function History() { } finally { setLoading(false); } - }, []); + }, [quickNotesOnly]); useEffect(() => { void refresh(); @@ -202,14 +209,28 @@ export function History() { [filtered, selectedId], ); + useEffect(() => { + setShowRawTranscript(false); + setRepolishOpen(false); + }, [item?.id]); + const handleAudioMissing = useCallback(() => { + if (item?.id) markAudioMissing(item.id); + }, [item?.id, markAudioMissing]); + const onClear = async () => { - if (items.length === 0) return; - if (!confirm(t('history.confirmClear', { count: items.length }))) return; + const clearable = items.filter((entry) => entry.source !== 'quick_note'); + if (clearable.length === 0) return; + if (!confirm(t('history.confirmClear', { count: clearable.length }))) return; setActionError(null); try { await clearHistory(); - setItems([]); - setSelectedId(null); + setItems((prev) => prev.filter((entry) => entry.source === 'quick_note')); + setSelectedId((current) => { + const remaining = items.filter((entry) => entry.source === 'quick_note'); + return current && remaining.some((entry) => entry.id === current) + ? current + : (remaining[0]?.id ?? null); + }); } catch (error) { console.error('[history] failed to clear history', error); setActionError(t('history.clearFailed', { err: errorMessage(error) })); @@ -305,6 +326,75 @@ export function History() { } }; + const onShareAudio = async () => { + if (!item?.hasAudioRecording) return; + try { + const dataUrl = await readAudioRecording(item.id); + const comma = dataUrl.indexOf(','); + const b64 = comma >= 0 ? dataUrl.slice(comma + 1) : ''; + if (!b64) throw new Error('empty recording'); + const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + const file = new File([bin], `openless-recording-${item.id}.wav`, { + type: 'audio/wav', + }); + if ( + !navigator.share || + (navigator.canShare && !navigator.canShare({ files: [file] })) + ) { + await onExportAudio(); + return; + } + await navigator.share({ + title: historyTitle(item, t), + files: [file], + }); + } catch (error) { + const msg = errorMessage(error); + if (!isUserCancelled(msg)) { + setActionError(t('history.exportFailed', { err: msg })); + } + } + }; + + 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 () => { @@ -340,17 +430,19 @@ export function History() { return (
void refresh()}> {t('common.refresh')} - - {t('common.clear')} - + {!quickNotesOnly && ( + + {t('common.clear')} + + )}
} /> @@ -459,7 +551,14 @@ export function History() { {debouncedQuery.trim() ? t('history.searchNoMatch', { query: debouncedQuery.trim() }) : t('history.empty', { - trigger: prefs ? formatComboLabel(prefs.dictationHotkey) : '', + trigger: prefs + ? formatComboLabel( + (quickNotesOnly ? prefs.quickNoteHotkey : prefs.dictationHotkey) ?? { + primary: '', + modifiers: [], + }, + ) + : '', })} )} @@ -469,6 +568,7 @@ export function History() { key={s.id} onClick={() => { setSelectedId(s.id); + setPlaybackRequest(0); if (mobile) setMobileDetailOpen(true); }} // 选中项不再用蓝色左条 + 淡蓝底 —— 与渠道行同一套 @@ -531,7 +631,7 @@ export function History() { overflow: 'hidden', }} > - {s.finalText.split('\n')[0]} + {historyTitle(s, t)} {/* tone 仍按 baseMode 走:颜色保留原来的粗分类信息,文字换成实际风格包名。 */}
@@ -605,41 +705,37 @@ export function History() { })}
-
- {item.hasAudioRecording && !audioMissingIds.has(item.id) && ( - void onExportAudio()} - > - {t('history.exportRecording')} - - )} - {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}`} /> )} @@ -651,9 +747,8 @@ export function History() { /> )} - {/* 流水线明细:识别 / 润色 / 插入 三步各占一行 —— 左列步骤名、中列 - 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', - }) - : 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} -

-
+
+ + {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() && item.errorCode !== 'qaSession' && ( + {repolishOpen && + (item.rawTranscript.trim() || quickNotesOnly) && + item.errorCode !== 'qaSession' && ( setRepolishOpen(false)} + persistOnApply={quickNotesOnly} + onApplied={(updated) => + setItems((prev) => + prev.map((entry) => (entry.id === updated.id ? updated : entry)), + ) + } key={`repolish-${item.id}`} /> )} @@ -896,6 +975,245 @@ export function History() { ); } +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'], +): 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 === 'cancelled') { + return t('quickNote.cancelledTitle', 'Recording cancelled'); + } + 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 +1254,27 @@ function RepolishPanel({ mobile, allPacks, packsError, + onClose, + persistOnApply, + onApplied, }: { session: DictationSession; mobile: boolean; /** History 顶层加载的**全部**风格包(含已禁用);null 表示还在加载。 */ allPacks: StylePack[] | null; packsError: string | null; + onClose: () => void; + 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 +1292,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 +1322,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 (
- {results.length > 0 && ( - setResults([])}> - {t('history.repolish.clear')} + + {results.length > 0 && ( + setResults([])}> + {t('history.repolish.clear')} + + )} + + {t('common.close')} - )} +
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 +1443,7 @@ function RepolishPanel({ void run('apply')} > {running === 'apply' ? t('history.repolish.applying') : t('history.repolish.apply')} @@ -1117,7 +1471,14 @@ function RepolishPanel({ {results.length > 0 && (
{results.map((result) => ( - + void applyResult(result) : undefined} + /> ))}
)} @@ -1125,7 +1486,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,18 +1549,20 @@ function HistoryResultCard({ title, text }: { title: string; text: string }) { {copied ? t('common.copied') : t('common.copy')}
)} + {onApply && text.trim() && ( + + {applying ? t('quickNote.applying', 'Applying…') : applyLabel} + + )} +
+
+
-

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

); } @@ -1202,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); @@ -1219,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) { @@ -1240,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; @@ -1273,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 ( @@ -1299,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 new file mode 100644 index 000000000..87294a366 --- /dev/null +++ b/openless-all/app/src/pages/QuickNote.tsx @@ -0,0 +1,100 @@ +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 { 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() && 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 }); + }} + /> + )} +
+ )} + {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({