From 51f6f4cfa7e0267d1eaf02bc626be014c59b99c6 Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 26 Sep 2026 17:07:38 +0800 Subject: [PATCH] feat(ui): integrate desktop panel refresh and session-safe voice controls --- docs/architecture.md | 2 + openless-all/app/contract/backend-2.0.json | 6 +- .../app/crates/openless-core/src/api.rs | 670 ++++++- .../app/crates/openless-core/src/events.rs | 36 + .../app/crates/openless-core/src/lib.rs | 18 +- .../tests/auxiliary_voice_lifecycle.rs | 40 +- .../crates/openless-core/tests/contract_2.rs | 58 +- .../shared-backend-wire-contract.test.mjs | 23 + openless-all/app/src-tauri/src/commands/qa.rs | 105 +- openless-all/app/src-tauri/src/coordinator.rs | 45 + .../src-tauri/src/coordinator/hotkey_loops.rs | 395 +++- openless-all/app/src-tauri/src/lib.rs | 4 + .../app/src-tauri/src/tauri_events.rs | 10 +- .../app/src/components/FloatingShell.tsx | 4 +- .../app/src/components/GithubLoginModal.tsx | 48 +- .../app/src/components/SavedToast.tsx | 34 +- .../app/src/components/chat/LiveWaveform.tsx | 124 ++ openless-all/app/src/components/ui/Modal.tsx | 79 +- openless-all/app/src/i18n/de.ts | 86 +- openless-all/app/src/i18n/en.ts | 85 +- openless-all/app/src/i18n/es.ts | 86 +- openless-all/app/src/i18n/fr.ts | 92 +- openless-all/app/src/i18n/ja.ts | 87 +- openless-all/app/src/i18n/ko.ts | 83 +- openless-all/app/src/i18n/zh-CN.ts | 80 +- openless-all/app/src/i18n/zh-TW.ts | 80 +- openless-all/app/src/lib/ipc/index.ts | 4 + openless-all/app/src/lib/ipc/less-computer.ts | 23 +- .../app/src/lib/lessComputerComposer.test.ts | 44 + .../app/src/lib/lessComputerComposer.ts | 70 + .../app/src/lib/lessComputerReplay.test.ts | 20 +- openless-all/app/src/lib/types.ts | 18 +- .../app/src/pages/LessComputerChrome.tsx | 168 ++ .../app/src/pages/LessComputerPanel.test.ts | 373 +++- .../app/src/pages/LessComputerPanel.tsx | 1090 +++++++--- openless-all/app/src/pages/Style.test.ts | 150 ++ openless-all/app/src/pages/Style.tsx | 134 +- openless-all/app/src/pages/Vocab.tsx | 26 +- openless-all/app/src/pages/_atoms.tsx | 10 +- .../app/src/pages/less-computer-panel.css | 1764 ++++++++++++----- .../app/src/pages/settings/ChannelList.tsx | 17 +- .../pages/settings/CloudSyncSection.test.ts | 23 +- .../src/pages/settings/CloudSyncSection.tsx | 250 ++- .../pages/settings/RecordingInputSection.tsx | 7 +- .../app/src/pages/settings/shared.tsx | 24 +- openless-all/app/src/pages/settings/tabs.tsx | 9 + openless-all/app/src/styles/global.css | 43 +- openless-all/app/src/styles/overlays.css | 9 + openless-all/app/src/styles/tokens.css | 15 +- 49 files changed, 5307 insertions(+), 1364 deletions(-) create mode 100644 openless-all/app/src/components/chat/LiveWaveform.tsx create mode 100644 openless-all/app/src/lib/lessComputerComposer.test.ts create mode 100644 openless-all/app/src/lib/lessComputerComposer.ts create mode 100644 openless-all/app/src/pages/LessComputerChrome.tsx create mode 100644 openless-all/app/src/pages/Style.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 190e46ba..e1ba031c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,6 +73,8 @@ Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺 思考动画覆盖转写、润色和原生文字写入,输入完成后才收尾。macOS 流式键盘输入由会话内串行 worker 维护原控件和累计 UTF-16 终点;每批发送后不再等待 AX 长确认,finish/cancel 在已接收写入之后等待最终屏障,再恢复输入源。AX 仅读选区范围,不读正文;采用 250 ms 无进展预算和 10 秒总预算。不可读、提交型 Return 或预算耗尽时明确降级到按键已发送语义,不重新粘贴已发送文字。目标应用的实际输入表现仍需设备验收。 +Less Computer 面板将听写与直接语音提交分开:麦克风把转写填入草稿供编辑,语音模式和快捷键可直接提交给 Agent。Core 的 `voice_state` 事件携带会话 ID、模式、实时转写及收尾结果;波形使用实际音量采样。停止和取消均绑定指定会话,延迟请求不能结束下一段录音;开麦与文字发送互斥。工具过程默认折叠,运行状态只在真实活动步骤显示动效,右侧工作台汇总当前轮次;历史和多会话仍标为暂不可用。登录弹窗打开时,听写结果只更新草稿,不抢走弹窗或授权浏览器的焦点。 + 选区直接润色在捕获文字和原输入目标后显示处理中提示,重复快捷键的 Busy 返回不覆盖该提示。已有语音选区入口在松开快捷键后继续显示思考动画,直到处理/替换完成,或交给确认和预览面板。录音提示音的 Web Audio context 在恢复超时或音频时钟冻结时丢弃并最多重试一次,重试沿用原请求的取消和迟到边界。 界面启动等待所选语言资源就绪;语言选择持久化到 `ol.locale`,其他 WebView 通过存储事件同步,日期、数字和默认风格展示随语言变化。用户修改的风格名称、说明和内容保持原文。旧版两种强制排版字段只保留数据兼容,界面清除其布局效果,窄屏改由响应式布局处理。 diff --git a/openless-all/app/contract/backend-2.0.json b/openless-all/app/contract/backend-2.0.json index 053fd426..25507cdf 100644 --- a/openless-all/app/contract/backend-2.0.json +++ b/openless-all/app/contract/backend-2.0.json @@ -107,7 +107,11 @@ }, "lessComputerVoice": { "phases": ["starting", "recording", "transcribing", "idle"], - "sample": {"seq": 3, "kind": "voice_state", "sessionId": "00000000-0000-4000-8000-000000000000", "phase": "recording", "level": 0.5, "elapsedMs": 120} + "modes": ["submit", "dictate"], + "outcomes": ["submitted", "committed", "empty", "failed", "cancelled"], + "sample": {"seq": 3, "kind": "voice_state", "sessionId": "00000000-0000-4000-8000-000000000000", "phase": "recording", "level": 0.5, "elapsedMs": 120, "mode": "dictate", "transcript": "打开"}, + "idleSample": {"seq": 4, "kind": "voice_state", "sessionId": "00000000-0000-4000-8000-000000000000", "phase": "idle", "level": 0.0, "elapsedMs": 120, "mode": "dictate", "transcript": "打开设置", "outcome": "committed"}, + "legacySample": {"seq": 2, "kind": "voice_state", "sessionId": "00000000-0000-4000-8000-000000000000", "phase": "starting", "level": 0.0, "elapsedMs": 0} }, "androidJni": { "fields": ["contractVersion", "ok", "payload", "error"], diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index e47f932d..72e274dd 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -132,6 +132,36 @@ pub struct LessComputerVoiceSession { request: crate::domains::LessComputerRunRequest, partials: Arc, archive_successful_recording: bool, + mode: crate::events::LessComputerVoiceMode, + feedback: Arc, +} + +/// Start options for a Core-owned Less Computer voice capture. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LessComputerVoiceOptions { + pub mode: crate::events::LessComputerVoiceMode, + /// Hotkey captures surface start failures in the conversation stream. A + /// panel request receives the error from its command and shows it inline. + pub publish_start_error: bool, +} + +impl Default for LessComputerVoiceOptions { + fn default() -> Self { + Self { + mode: crate::events::LessComputerVoiceMode::Submit, + publish_start_error: true, + } + } +} + +/// How a finished Less Computer capture was delivered. +#[derive(Debug, Clone, PartialEq)] +pub enum LessComputerVoiceFinish { + /// The transcript became an Agent turn (hotkey / voice-mode semantics). + Submitted(crate::domains::LessComputerRunResult), + /// Dictation only: the transcript was published for the host composer. + /// An empty transcript means nothing was recognized. + Dictated { transcript: String }, } pub struct VoiceTranscriptionSession { @@ -611,6 +641,9 @@ struct VoiceTranscriptSink { publisher: crate::events::BackendEventPublisher, session_id: SessionId, transcript: Mutex, + /// Less Computer mirrors the accumulated transcript into its voice + /// projection; other voice surfaces only publish `TranscriptDelta`. + feedback: Option>, } struct VoiceCaptureControl { @@ -625,23 +658,72 @@ struct VoiceCaptureControl { struct LessVoiceFeedback { publisher: BackendEventPublisher, session_id: SessionId, - state: Mutex<(crate::events::LessComputerVoicePhase, u64)>, + mode: crate::events::LessComputerVoiceMode, + state: Mutex, +} + +struct LessVoiceFeedbackState { + phase: crate::events::LessComputerVoicePhase, + elapsed_ms: u64, + level: f32, + transcript: String, + outcome: Option, +} + +impl LessVoiceFeedbackState { + fn new(phase: crate::events::LessComputerVoicePhase) -> Self { + Self { + phase, + elapsed_ms: 0, + level: 0.0, + transcript: String::new(), + outcome: None, + } + } } impl LessVoiceFeedback { + fn new( + publisher: BackendEventPublisher, + session_id: SessionId, + mode: crate::events::LessComputerVoiceMode, + phase: crate::events::LessComputerVoicePhase, + ) -> Self { + Self { + publisher, + session_id, + mode, + state: Mutex::new(LessVoiceFeedbackState::new(phase)), + } + } + fn phase(&self, phase: crate::events::LessComputerVoicePhase) { let mut state = self.state.lock().expect("voice feedback lock poisoned"); - if state.0 == crate::events::LessComputerVoicePhase::Idle { + if state.phase == crate::events::LessComputerVoicePhase::Idle { return; } - state.0 = phase; - self.emit(phase, 0.0, state.1); + state.phase = phase; + state.level = 0.0; + // Only a delivered transcript survives into the terminal snapshot; + // cancelled or failed captures must not leave partial text behind. + if phase == crate::events::LessComputerVoicePhase::Idle + && !matches!( + state.outcome, + Some( + crate::events::LessComputerVoiceOutcome::Committed + | crate::events::LessComputerVoiceOutcome::Submitted + ) + ) + { + state.transcript.clear(); + } + self.emit(&state); } fn level(&self, elapsed_ms: u64, level: f32) { let mut state = self.state.lock().expect("voice feedback lock poisoned"); if !matches!( - state.0, + state.phase, crate::events::LessComputerVoicePhase::Starting | crate::events::LessComputerVoicePhase::Recording ) { @@ -649,21 +731,49 @@ impl LessVoiceFeedback { } // AudioRecorder reports levels only after consuming a non-empty PCM // frame. A native start receipt alone cannot make capture look ready. - state.0 = crate::events::LessComputerVoicePhase::Recording; - state.1 = elapsed_ms; - self.emit(state.0, level.clamp(0.0, 1.0), elapsed_ms); + state.phase = crate::events::LessComputerVoicePhase::Recording; + state.elapsed_ms = elapsed_ms; + state.level = level.clamp(0.0, 1.0); + self.emit(&state); } - fn emit(&self, phase: crate::events::LessComputerVoicePhase, level: f32, elapsed_ms: u64) { + /// Mirror the accumulated live transcript while the capture is still open. + fn transcript(&self, text: &str) { + let mut state = self.state.lock().expect("voice feedback lock poisoned"); + if state.phase == crate::events::LessComputerVoicePhase::Idle || state.transcript == text { + return; + } + state.transcript = text.to_string(); + self.emit(&state); + } + + /// Record how the capture ends. The terminal `idle` snapshot emitted when + /// the feedback guard drops carries it; the first recorded outcome wins. + fn settle(&self, outcome: crate::events::LessComputerVoiceOutcome, transcript: Option) { + let mut state = self.state.lock().expect("voice feedback lock poisoned"); + if state.phase == crate::events::LessComputerVoicePhase::Idle || state.outcome.is_some() { + return; + } + state.outcome = Some(outcome); + if let Some(transcript) = transcript { + state.transcript = transcript; + } + } + + fn emit(&self, state: &LessVoiceFeedbackState) { + let idle = state.phase == crate::events::LessComputerVoicePhase::Idle; self.publisher.publish( Some(self.session_id), BackendEventKind::LessComputerEvent(crate::events::LessComputerEvent { seq: None, kind: crate::events::LessComputerEventKind::VoiceState { session_id: self.session_id, - phase, - level, - elapsed_ms, + phase: state.phase, + level: state.level, + elapsed_ms: state.elapsed_ms, + mode: self.mode, + transcript: state.transcript.clone(), + outcome: if idle { state.outcome } else { None }, }, }), ); @@ -853,16 +963,18 @@ struct VoiceControlGuard { impl Drop for VoiceControlGuard { fn drop(&mut self) { - self.control - .feedback - .lock() - .expect("voice feedback lock poisoned") - .take(); + // Release the hold first: observers of the terminal idle snapshot may + // immediately start another capture or text turn. self.control .resources .lock() .expect("voice resource lock poisoned") .take(); + self.control + .feedback + .lock() + .expect("voice feedback lock poisoned") + .take(); let mut controls = self.controls.lock().expect("voice control lock poisoned"); if controls .get(&self.session_id) @@ -880,10 +992,15 @@ impl TextStreamSink for VoiceTranscriptSink { offset: chunk.offset, is_final: false, }; - self.transcript + let mut transcript = self + .transcript .lock() - .expect("voice transcript lock poisoned") - .apply(&delta)?; + .expect("voice transcript lock poisoned"); + transcript.apply(&delta)?; + if let Some(feedback) = &self.feedback { + feedback.transcript(transcript.text()); + } + drop(transcript); self.publisher.publish( Some(self.session_id), BackendEventKind::TranscriptDelta(delta), @@ -958,6 +1075,8 @@ impl LessComputerVoiceSession { { return Box::pin(async { Ok(()) }); } + self.feedback + .settle(crate::events::LessComputerVoiceOutcome::Cancelled, None); let control = Arc::clone(&self.control); let controls = Arc::clone(&self.controls); let less_computer = Arc::clone(&self.less_computer); @@ -978,9 +1097,17 @@ impl LessComputerVoiceSession { ) } + pub fn mode(&self) -> crate::events::LessComputerVoiceMode { + self.mode + } + + /// Stop capture and deliver the transcript according to the session mode: + /// `Submit` starts an Agent turn, `Dictate` only publishes the text. pub fn finish( self, - ) -> futures_util::future::BoxFuture<'static, Result> { + ) -> futures_util::future::BoxFuture<'static, Result> + { + use crate::events::{LessComputerVoiceMode, LessComputerVoiceOutcome}; if self .control .closed @@ -1003,6 +1130,8 @@ impl LessComputerVoiceSession { let request = self.request; let partials = Arc::clone(&self.partials); let session_id = self.session_id; + let mode = self.mode; + let feedback = Arc::clone(&self.feedback); let resources = control .resources .lock() @@ -1029,6 +1158,7 @@ impl LessComputerVoiceSession { controls, }; if less_computer.capture_cancelled(session_id) { + feedback.settle(LessComputerVoiceOutcome::Cancelled, None); if let Some(recording) = recording { let _ = recording.stop().await; } @@ -1042,21 +1172,32 @@ impl LessComputerVoiceSession { if let Some(recording) = recording { if let Err(error) = recording.stop().await { let _ = transcription.cancel().await; - return Err( - fail_less_voice_capture(&less_computer, session_id, error).await - ); + return Err(fail_less_voice_finish( + &less_computer, + session_id, + &feedback, + mode, + error, + ) + .await); } } let transcript = match transcription.finish().await { Ok(output) => output.text, Err(error) => { let _ = transcription.cancel().await; - return Err( - fail_less_voice_capture(&less_computer, session_id, error).await - ); + return Err(fail_less_voice_finish( + &less_computer, + session_id, + &feedback, + mode, + error, + ) + .await); } }; if less_computer.capture_cancelled(session_id) { + feedback.settle(LessComputerVoiceOutcome::Cancelled, None); let _ = transcription.cancel().await; let _ = less_computer.abort_capture(session_id); return Err(BackendError::new( @@ -1066,9 +1207,21 @@ impl LessComputerVoiceSession { } let transcript = transcript.trim().to_string(); if transcript.is_empty() { - return Err(fail_less_voice_capture( + if mode == LessComputerVoiceMode::Dictate { + // Silence is not an error for dictation; the composer shows a hint. + feedback.settle(LessComputerVoiceOutcome::Empty, Some(String::new())); + let _ = less_computer.abort_capture(session_id); + drop(resources); + drop(_guard); + return Ok(LessComputerVoiceFinish::Dictated { + transcript: String::new(), + }); + } + return Err(fail_less_voice_finish( &less_computer, session_id, + &feedback, + mode, BackendError::new( BackendErrorCode::Provider, "transcription provider returned an empty transcript", @@ -1089,9 +1242,32 @@ impl LessComputerVoiceSession { } } if less_computer.capture_cancelled(session_id) { + feedback.settle(LessComputerVoiceOutcome::Cancelled, None); return Err(VoiceCaptureLifecycle::cancelled_error()); } - partials.publish_final(transcript.clone())?; + if let Err(error) = partials.publish_final(transcript.clone()) { + feedback.settle(LessComputerVoiceOutcome::Failed, None); + if mode == LessComputerVoiceMode::Dictate { + let _ = less_computer.abort_capture(session_id); + } + return Err(error); + } + if mode == LessComputerVoiceMode::Dictate { + // Release the capture before the terminal snapshot so a text + // submit triggered by the composer never observes it as busy. + let _ = less_computer.abort_capture(session_id); + drop(resources); + feedback.settle( + LessComputerVoiceOutcome::Committed, + Some(transcript.clone()), + ); + drop(_guard); + return Ok(LessComputerVoiceFinish::Dictated { transcript }); + } + feedback.settle( + LessComputerVoiceOutcome::Submitted, + Some(transcript.clone()), + ); drop(_guard); // Keep the hold through capture -> run promotion. If cancellation // won immediately before submit, acquire() must reject this old id @@ -1100,7 +1276,7 @@ impl LessComputerVoiceSession { let mut request = request; request.transcript = transcript; match less_computer.submit(request).await { - Ok(result) => Ok(result), + Ok(result) => Ok(LessComputerVoiceFinish::Submitted(result)), Err(error) => { let _ = less_computer.abort_capture(session_id); Err(error) @@ -1111,6 +1287,48 @@ impl LessComputerVoiceSession { } } +/// Terminal failure while finishing a capture. Agent-bound captures keep the +/// existing conversation error; dictation never became a turn, so it only +/// releases the capture and reports through its idle snapshot. +async fn fail_less_voice_finish( + less_computer: &Arc, + session_id: SessionId, + feedback: &LessVoiceFeedback, + mode: crate::events::LessComputerVoiceMode, + error: BackendError, +) -> BackendError { + use crate::events::LessComputerVoiceOutcome; + if mode == crate::events::LessComputerVoiceMode::Dictate { + let cancelled = error.code == BackendErrorCode::Cancelled + || less_computer.capture_cancelled(session_id); + feedback.settle( + if cancelled { + LessComputerVoiceOutcome::Cancelled + } else { + LessComputerVoiceOutcome::Failed + }, + None, + ); + let _ = less_computer.abort_capture(session_id); + return if cancelled { + VoiceCaptureLifecycle::cancelled_error() + } else { + BackendError::new(error.code, crate::less_computer::VOICE_CAPTURE_FAILED) + .retryable(error.retryable) + }; + } + let public = fail_less_voice_capture(less_computer, session_id, error).await; + feedback.settle( + if public.code == BackendErrorCode::Cancelled { + LessComputerVoiceOutcome::Cancelled + } else { + LessComputerVoiceOutcome::Failed + }, + None, + ); + public +} + impl VoiceTranscriptionSession { pub fn session_id(&self) -> SessionId { self.session_id @@ -2599,6 +2817,22 @@ impl OpenLessBackend { &self, session_id: SessionId, recording_control: Arc, + ) -> Result { + self.start_less_computer_voice_with( + session_id, + recording_control, + LessComputerVoiceOptions::default(), + ) + .await + } + + /// Same as [`Self::start_less_computer_voice`], with an explicit delivery + /// mode and start-error surface (panel requests report errors inline). + pub async fn start_less_computer_voice_with( + &self, + session_id: SessionId, + recording_control: Arc, + options: LessComputerVoiceOptions, ) -> Result { let _runtime = self.runtime_start_work.acquire()?; let preferences = self.get_preferences(); @@ -2617,11 +2851,12 @@ impl OpenLessBackend { } self.deps.services.less_computer.begin_capture(session_id)?; let resources = self.voice_sessions.hold_resources(session_id)?; - let feedback = Arc::new(LessVoiceFeedback { - publisher: self.event_publisher(), + let feedback = Arc::new(LessVoiceFeedback::new( + self.event_publisher(), session_id, - state: Mutex::new((crate::events::LessComputerVoicePhase::Starting, 0)), - }); + options.mode, + crate::events::LessComputerVoicePhase::Starting, + )); feedback.phase(crate::events::LessComputerVoicePhase::Starting); let feedback_guard = LessVoiceFeedbackGuard(Arc::clone(&feedback)); let result = async { @@ -2691,6 +2926,7 @@ impl OpenLessBackend { publisher: self.event_publisher(), session_id, transcript: Mutex::new(crate::types::TranscriptAccumulator::default()), + feedback: Some(Arc::clone(&feedback)), }); let started_at = std::time::Instant::now(); let recording_progress = Arc::new(LessComputerRecordingProgress { @@ -2807,11 +3043,13 @@ impl OpenLessBackend { request, partials, archive_successful_recording: context.recording.archive_successful_recording, + mode: options.mode, + feedback: Arc::clone(&feedback), }) } .await; if let Err(error) = &result { - if error.code != BackendErrorCode::Cancelled { + if options.publish_start_error && error.code != BackendErrorCode::Cancelled { self.event_publisher().publish( Some(session_id), BackendEventKind::LessComputerEvent(crate::events::LessComputerEvent { @@ -2872,6 +3110,7 @@ impl OpenLessBackend { publisher: self.event_publisher(), session_id, transcript: Mutex::new(crate::types::TranscriptAccumulator::default()), + feedback: None, }); let capture = own_voice_start( &self.deps.task_spawner, @@ -7436,7 +7675,9 @@ mod tests { BackendErrorCode::InvalidArgument ); session.feed_pcm(&[1, 0, 2, 0]).unwrap(); - let result = session.finish().await.unwrap(); + let LessComputerVoiceFinish::Submitted(result) = session.finish().await.unwrap() else { + panic!("hotkey-mode capture must submit an Agent turn"); + }; assert_eq!(result.session_id, session_id); assert_eq!(*transcription.pcm.lock().unwrap(), vec![1, 0, 2, 0]); @@ -7459,6 +7700,340 @@ mod tests { assert_eq!(transcription.starts.load(Ordering::Acquire), 1); } + struct DictationTranscription { + text: String, + partials: Mutex>>, + fail_start: std::sync::atomic::AtomicBool, + } + + impl crate::ports::AudioConsumer for DictationTranscription { + fn consume_pcm_chunk(&self, _pcm: &[u8]) {} + } + + impl crate::ports::TranscriptionSession for DictationTranscription { + fn finish(&self) -> BoxFuture<'static, Result> { + let text = self.text.clone(); + boxed(async move { + Ok(crate::TranscriptOutput { + text, + duration_ms: 100, + }) + }) + } + + fn cancel(&self) -> BoxFuture<'static, Result<(), BackendError>> { + boxed(async { Ok(()) }) + } + } + + struct DictationOnlyEngine(Arc); + + impl DictationEngine for DictationOnlyEngine { + fn start( + &self, + _session_id: SessionId, + _context: Arc, + _progress: Arc, + ) -> BoxFuture<'static, Result<(), BackendError>> { + boxed(async { Ok(()) }) + } + + fn start_transcription( + &self, + _session_id: SessionId, + _context: Arc, + partials: Arc, + ) -> BoxFuture<'static, Result, BackendError>> { + *self.0.partials.lock().unwrap() = Some(partials); + let session: Arc = self.0.clone(); + boxed(async move { Ok(session) }) + } + + fn start_voice_capture( + &self, + _session_id: SessionId, + _context: Arc, + _partials: Arc, + _progress: Arc, + _cancel: crate::CancellationToken, + ) -> BoxFuture<'static, Result> { + let error = if self.0.fail_start.load(Ordering::Acquire) { + BackendError::new( + BackendErrorCode::PermissionDenied, + "microphone permission denied", + ) + } else { + BackendError::new(BackendErrorCode::Unsupported, "use the transcription path") + }; + boxed(async move { Err(error) }) + } + + fn finish( + &self, + _session_id: SessionId, + _progress: Arc, + ) -> BoxFuture<'static, Result> { + boxed(async { unreachable!("dictation-only engine does not run dictation") }) + } + + fn cancel(&self, _session_id: SessionId) -> BoxFuture<'static, Result<(), BackendError>> { + boxed(async { Ok(()) }) + } + } + + fn dictation_backend( + name: &str, + text: &str, + ) -> ( + TestDataDir, + OpenLessBackend, + Arc, + Arc, + ) { + let data_dir = TestDataDir::new(name); + let transcription = Arc::new(DictationTranscription { + text: text.into(), + partials: Mutex::new(None), + fail_start: std::sync::atomic::AtomicBool::new(false), + }); + let runtime = Arc::new(LessComputerCaptureRuntime::default()); + let dependencies = BackendDependencies { + host_actions: Arc::new(crate::testing::RecordingHostActions::default()), + dictation_engine: Arc::new(DictationOnlyEngine(Arc::clone(&transcription))), + ..BackendDependencies::unsupported() + }; + dependencies.services.less_computer.bind_runner(Arc::new( + crate::coding_agent::CodingAgentRunner::new(runtime.clone()), + )); + let backend = OpenLessBackend::new( + BackendConfig { + data_dir: data_dir.path().to_path_buf(), + ..BackendConfig::default() + }, + dependencies, + ) + .unwrap(); + let mut preferences = backend.get_preferences(); + preferences.coding_agent_enabled = true; + backend.set_preferences(preferences).unwrap(); + (data_dir, backend, transcription, runtime) + } + + const DICTATE: LessComputerVoiceOptions = LessComputerVoiceOptions { + mode: crate::events::LessComputerVoiceMode::Dictate, + publish_start_error: false, + }; + + fn less_computer_event_kinds( + events: &mut crate::events::EventSubscription, + ) -> Vec { + std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event.kind { + BackendEventKind::LessComputerEvent(event) => Some(event.kind), + _ => None, + }) + .collect() + } + + fn last_voice_state( + kinds: &[crate::events::LessComputerEventKind], + ) -> &crate::events::LessComputerEventKind { + kinds + .iter() + .rev() + .find(|kind| { + matches!( + kind, + crate::events::LessComputerEventKind::VoiceState { .. } + ) + }) + .expect("voice state was published") + } + + #[tokio::test] + async fn less_computer_dictation_streams_partials_and_commits_without_submitting() { + use crate::events::{ + LessComputerEventKind, LessComputerVoiceMode, LessComputerVoiceOutcome, + LessComputerVoicePhase, + }; + let (_data_dir, backend, transcription, runtime) = + dictation_backend("less-computer-dictation", " 打开设置 "); + let mut events = backend.subscribe(); + let session_id = SessionId::new(); + let session = backend + .start_less_computer_voice_with( + session_id, + Arc::new(FakeRecordingControl::default()), + DICTATE, + ) + .await + .unwrap(); + assert_eq!(session.mode(), LessComputerVoiceMode::Dictate); + let partials = transcription.partials.lock().unwrap().clone().unwrap(); + partials + .publish(crate::ports::TextStreamChunk { + text: "打开".into(), + offset: 0, + }) + .unwrap(); + session.feed_pcm(&[1, 0]).unwrap(); + + assert_eq!( + session.finish().await.unwrap(), + LessComputerVoiceFinish::Dictated { + transcript: "打开设置".into() + } + ); + assert!(runtime.request.lock().unwrap().is_none()); + assert_eq!(backend.less_computer_active_session(), None); + + let kinds = less_computer_event_kinds(&mut events); + assert!(kinds.iter().any(|kind| matches!( + kind, + LessComputerEventKind::VoiceState { + phase: LessComputerVoicePhase::Starting, + mode: LessComputerVoiceMode::Dictate, + transcript, + outcome: None, + .. + } if transcript == "打开" + ))); + assert!(!kinds.iter().any(|kind| matches!( + kind, + LessComputerEventKind::User { .. } + | LessComputerEventKind::Started + | LessComputerEventKind::Error { .. } + ))); + let committed = LessComputerEventKind::VoiceState { + session_id, + phase: LessComputerVoicePhase::Idle, + level: 0.0, + elapsed_ms: 0, + mode: LessComputerVoiceMode::Dictate, + transcript: "打开设置".into(), + outcome: Some(LessComputerVoiceOutcome::Committed), + }; + assert_eq!(last_voice_state(&kinds), &committed); + assert_eq!( + backend + .event_publisher() + .latest_less_computer_voice_state() + .unwrap() + .kind, + committed + ); + + // The composer can send the edited text immediately after dictation. + backend + .submit_less_computer("打开设置并截图".into()) + .await + .unwrap(); + assert!(runtime.request.lock().unwrap().is_some()); + } + + #[tokio::test] + async fn less_computer_dictation_reports_silence_and_cancel_without_chat_errors() { + use crate::events::{LessComputerEventKind, LessComputerVoiceOutcome}; + let (_data_dir, backend, transcription, runtime) = + dictation_backend("less-computer-dictation-empty", " "); + let mut events = backend.subscribe(); + let silent = backend + .start_less_computer_voice_with( + SessionId::new(), + Arc::new(FakeRecordingControl::default()), + DICTATE, + ) + .await + .unwrap(); + assert_eq!( + silent.finish().await.unwrap(), + LessComputerVoiceFinish::Dictated { + transcript: String::new() + } + ); + let kinds = less_computer_event_kinds(&mut events); + assert!(!kinds + .iter() + .any(|kind| matches!(kind, LessComputerEventKind::Error { .. }))); + assert!(matches!( + last_voice_state(&kinds), + LessComputerEventKind::VoiceState { + outcome: Some(LessComputerVoiceOutcome::Empty), + .. + } + )); + assert_eq!(backend.less_computer_active_session(), None); + + let abandoned = backend + .start_less_computer_voice_with( + SessionId::new(), + Arc::new(FakeRecordingControl::default()), + DICTATE, + ) + .await + .unwrap(); + transcription + .partials + .lock() + .unwrap() + .clone() + .unwrap() + .publish(crate::ports::TextStreamChunk { + text: "draft".into(), + offset: 0, + }) + .unwrap(); + abandoned.cancel().await.unwrap(); + let kinds = less_computer_event_kinds(&mut events); + assert!(matches!( + last_voice_state(&kinds), + LessComputerEventKind::VoiceState { + outcome: Some(LessComputerVoiceOutcome::Cancelled), + transcript, + .. + } if transcript.is_empty() + )); + assert!(runtime.request.lock().unwrap().is_none()); + assert_eq!(backend.less_computer_active_session(), None); + } + + #[tokio::test] + async fn less_computer_panel_start_errors_are_returned_not_published() { + let (_data_dir, backend, transcription, _runtime) = + dictation_backend("less-computer-dictation-start-error", "unused"); + transcription.fail_start.store(true, Ordering::Release); + let has_chat_error = |kinds: Vec| { + kinds + .iter() + .any(|kind| matches!(kind, crate::events::LessComputerEventKind::Error { .. })) + }; + + let mut events = backend.subscribe(); + let error = match backend + .start_less_computer_voice_with( + SessionId::new(), + Arc::new(FakeRecordingControl::default()), + DICTATE, + ) + .await + { + Err(error) => error, + Ok(_) => panic!("the engine refused to start"), + }; + assert_eq!(error.code, BackendErrorCode::PermissionDenied); + assert!(!has_chat_error(less_computer_event_kinds(&mut events))); + assert_eq!(backend.less_computer_active_session(), None); + + assert!(backend + .start_less_computer_voice(SessionId::new(), Arc::new(FakeRecordingControl::default())) + .await + .is_err()); + assert!( + has_chat_error(less_computer_event_kinds(&mut events)), + "hotkey captures keep reporting start failures in the conversation" + ); + } + #[tokio::test] async fn qa_voice_capture_owns_recorder_and_transcription_lifecycle() { let data_dir = TestDataDir::new("qa-voice-capture"); @@ -7623,6 +8198,7 @@ mod tests { publisher: backend.event_publisher(), session_id: SessionId::new(), transcript: Mutex::new(crate::types::TranscriptAccumulator::default()), + feedback: None, }), lifecycle: Arc::new(VoiceCaptureLifecycle::default()), task_spawner: Arc::new(TokioTaskSpawner), @@ -7740,11 +8316,12 @@ mod tests { let started_at = std::time::Instant::now(); let progress = LessComputerRecordingProgress { session_id: stop_session, - feedback: Arc::new(LessVoiceFeedback { - publisher: backend.event_publisher(), - session_id: stop_session, - state: Mutex::new((crate::events::LessComputerVoicePhase::Recording, 0)), - }), + feedback: Arc::new(LessVoiceFeedback::new( + backend.event_publisher(), + stop_session, + crate::events::LessComputerVoiceMode::Submit, + crate::events::LessComputerVoicePhase::Recording, + )), less_computer: Arc::clone(&backend.services().less_computer), control: Arc::clone(&control) as Arc, task_spawner: Arc::new(TokioTaskSpawner), @@ -7787,11 +8364,12 @@ mod tests { .unwrap(); let fault_progress = LessComputerRecordingProgress { session_id: fault_session, - feedback: Arc::new(LessVoiceFeedback { - publisher: backend.event_publisher(), - session_id: fault_session, - state: Mutex::new((crate::events::LessComputerVoicePhase::Recording, 0)), - }), + feedback: Arc::new(LessVoiceFeedback::new( + backend.event_publisher(), + fault_session, + crate::events::LessComputerVoiceMode::Submit, + crate::events::LessComputerVoicePhase::Recording, + )), less_computer: Arc::clone(&backend.services().less_computer), control: Arc::clone(&control) as Arc, task_spawner: Arc::new(TokioTaskSpawner), diff --git a/openless-all/app/crates/openless-core/src/events.rs b/openless-all/app/crates/openless-core/src/events.rs index 789edfe4..ac568b8a 100644 --- a/openless-all/app/crates/openless-core/src/events.rs +++ b/openless-all/app/crates/openless-core/src/events.rs @@ -129,6 +129,16 @@ pub enum LessComputerEventKind { phase: LessComputerVoicePhase, level: f32, elapsed_ms: u64, + /// `dictate` sessions deliver the transcript to the host composer + /// instead of starting an Agent turn. + #[serde(default)] + mode: LessComputerVoiceMode, + /// Full transcript observed so far, including live partial results. + #[serde(default)] + transcript: String, + /// Present only on the terminal `idle` snapshot of a capture. + #[serde(default, skip_serializing_if = "Option::is_none")] + outcome: Option, }, User { text: String, @@ -167,6 +177,29 @@ pub enum LessComputerVoicePhase { Idle, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LessComputerVoiceMode { + /// Hotkey semantics: the final transcript becomes an Agent turn. + #[default] + Submit, + /// Composer dictation: the final transcript is returned for editing only. + Dictate, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LessComputerVoiceOutcome { + /// The transcript was handed to the Agent. + Submitted, + /// A dictation transcript is ready for the composer. + Committed, + /// Recognition finished without any text. + Empty, + Failed, + Cancelled, +} + #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct LessComputerEvent { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -766,6 +799,9 @@ mod tests { phase, level: 0.0, elapsed_ms: 120, + mode: LessComputerVoiceMode::Submit, + transcript: String::new(), + outcome: None, }, }), ); diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index db82bf29..8fdd4a9a 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -15,8 +15,8 @@ mod cloud_providers; pub mod cloud_sync; pub mod cloud_sync_e2ee; pub mod cloud_sync_e2ee_documents; -pub mod cloud_sync_e2ee_store; mod cloud_sync_e2ee_protocol; +pub mod cloud_sync_e2ee_store; mod cloud_sync_transaction; mod cloud_sync_types; mod cloud_sync_validation; @@ -213,8 +213,9 @@ pub mod contract { pub use activity::{ActivityDay, ActivityStore, DayStats}; pub use api::{ BackendRepositories, BackendSnapshot, CliDispatchOutcome, DictationHotkeyDispatchOptions, - DictationHotkeyEdge, LessComputerHotkeyAction, LessComputerVoiceSession, OpenLessBackend, - QaVoiceCaptureResult, QaVoiceCaptureSession, StartupSnapshot, VoiceTranscriptionSession, + DictationHotkeyEdge, LessComputerHotkeyAction, LessComputerVoiceFinish, + LessComputerVoiceOptions, LessComputerVoiceSession, OpenLessBackend, QaVoiceCaptureResult, + QaVoiceCaptureSession, StartupSnapshot, VoiceTranscriptionSession, }; pub use audio::{encode_dictation_wav, NormalizedPcmChunk, PcmNormalizer, DICTATION_SAMPLE_RATE}; pub use auxiliary::{ @@ -256,10 +257,10 @@ pub use errors::{BackendError, BackendErrorCode}; pub use events::{ BackendEvent, BackendEventKind, BackendEventPublisher, CodingAgentStreamEvent, EventRecvError, EventReplay, EventSubscription, LessComputerEvent, LessComputerEventKind, - LessComputerVoicePhase, LocalAsrDownloadPhase, LocalAsrDownloadProgress, LocalAsrPreparePhase, - LocalAsrPrepareProgress, LocalAsrRuntimeKind, QaRecordingLevel, QaStateEvent, QaStateKind, - RecordingControlAction, RecordingControlRequest, RemoteInputErrorEvent, - RemoteInputRuntimeEvent, + LessComputerVoiceMode, LessComputerVoiceOutcome, LessComputerVoicePhase, LocalAsrDownloadPhase, + LocalAsrDownloadProgress, LocalAsrPreparePhase, LocalAsrPrepareProgress, LocalAsrRuntimeKind, + QaRecordingLevel, QaStateEvent, QaStateKind, RecordingControlAction, RecordingControlRequest, + RemoteInputErrorEvent, RemoteInputRuntimeEvent, }; pub use external_audio::{AudioRecorderRouter, ExternalAudioRecorder}; pub use history::{HistoryStore, HISTORY_CAP}; @@ -331,8 +332,7 @@ pub use shared_types::{ pub use shortcut_types::{ binding_from_legacy_trigger, binding_requires_side_aware_hook, bindings_overlap, is_modifier_chord_binding, is_side_specific_modifier_tag, legacy_modifier_trigger, - normalize_side_modifier_tag, - reconcile_hotkey_collisions, reject_bare_shift_dictation_shortcut, + normalize_side_modifier_tag, reconcile_hotkey_collisions, reject_bare_shift_dictation_shortcut, reject_dictation_qa_hotkey_overlap, reject_dictation_translation_hotkey_overlap, reject_hotkey_collisions, reject_modifier_only_action_shortcut, reject_non_dictation_side_specific_shortcuts, reject_qa_less_computer_hotkey_overlap, diff --git a/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs b/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs index b60957fe..222fb137 100644 --- a/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs +++ b/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs @@ -732,22 +732,30 @@ async fn cancellation_during_cold_asr_stops_the_already_started_microphone() { assert!(starting.await.unwrap().is_err()); assert_eq!(starts.load(Ordering::SeqCst), 1); if less { - let phases = std::iter::from_fn(|| events.try_recv().ok()) + let mut snapshots = std::iter::from_fn(|| events.try_recv().ok()) .filter_map(|event| match event.kind { BackendEventKind::LessComputerEvent(LessComputerEvent { - kind: LessComputerEventKind::VoiceState { phase, .. }, + kind: + LessComputerEventKind::VoiceState { + phase, transcript, .. + }, .. - }) => Some(phase), + }) => Some((phase, transcript)), _ => None, }) .collect::>(); + let terminal = snapshots.pop().unwrap(); assert_eq!( - phases, - [ - LessComputerVoicePhase::Starting, - LessComputerVoicePhase::Idle - ] + terminal, + (LessComputerVoicePhase::Idle, String::new()), + "a cancelled cold start must not leave live transcript text behind" ); + // The ASR fixture streams a live partial while starting; it is + // mirrored into the same Starting phase, never a later phase. + assert!(!snapshots.is_empty()); + assert!(snapshots + .iter() + .all(|(phase, _)| *phase == LessComputerVoicePhase::Starting)); } std::fs::remove_dir_all(path).unwrap(); } @@ -1158,26 +1166,30 @@ async fn less_voice_feedback_preserves_phases_and_rejects_late_levels() { session_id, phase, level, + transcript, .. }, .. }) = event.kind { assert_eq!(session_id, id); - Some((phase, level)) + Some((phase, level, transcript)) } else { None } }) .collect::>(); + let live = || "instruction".to_string(); assert_eq!( phases, vec![ - (LessComputerVoicePhase::Starting, 0.0), - (LessComputerVoicePhase::Recording, 0.0), - (LessComputerVoicePhase::Recording, 0.7), - (LessComputerVoicePhase::Transcribing, 0.0), - (LessComputerVoicePhase::Idle, 0.0) + (LessComputerVoicePhase::Starting, 0.0, String::new()), + // The fixture ASR streams its text as a live partial at start. + (LessComputerVoicePhase::Starting, 0.0, live()), + (LessComputerVoicePhase::Recording, 0.0, live()), + (LessComputerVoicePhase::Recording, 0.7, live()), + (LessComputerVoicePhase::Transcribing, 0.0, live()), + (LessComputerVoicePhase::Idle, 0.0, live()) ] ); std::fs::remove_dir_all(path).unwrap(); diff --git a/openless-all/app/crates/openless-core/tests/contract_2.rs b/openless-all/app/crates/openless-core/tests/contract_2.rs index 2c31475f..18ef2976 100644 --- a/openless-all/app/crates/openless-core/tests/contract_2.rs +++ b/openless-all/app/crates/openless-core/tests/contract_2.rs @@ -71,23 +71,73 @@ fn runtime_wire_rejects_non_2_contracts() { #[test] fn less_computer_voice_feedback_matches_the_shared_wire_contract() { - use openless_core::{LessComputerEvent, LessComputerEventKind, LessComputerVoicePhase}; + use openless_core::{ + LessComputerEvent, LessComputerEventKind, LessComputerVoiceMode, LessComputerVoiceOutcome, + LessComputerVoicePhase, + }; let fixture = fixture(); let event: LessComputerEvent = serde_json::from_value(fixture["lessComputerVoice"]["sample"].clone()).unwrap(); assert!(matches!( - event.kind, + &event.kind, LessComputerEventKind::VoiceState { phase: LessComputerVoicePhase::Recording, - level: 0.5, + level, elapsed_ms: 120, + mode: LessComputerVoiceMode::Dictate, + transcript, + outcome: None, .. - } + } if *level == 0.5 && transcript == "打开" )); assert_eq!( serde_json::to_value(event).unwrap(), fixture["lessComputerVoice"]["sample"] ); + let idle: LessComputerEvent = + serde_json::from_value(fixture["lessComputerVoice"]["idleSample"].clone()).unwrap(); + assert!(matches!( + &idle.kind, + LessComputerEventKind::VoiceState { + phase: LessComputerVoicePhase::Idle, + outcome: Some(LessComputerVoiceOutcome::Committed), + .. + } + )); + assert_eq!( + serde_json::to_value(idle).unwrap(), + fixture["lessComputerVoice"]["idleSample"] + ); + let legacy: LessComputerEvent = + serde_json::from_value(fixture["lessComputerVoice"]["legacySample"].clone()).unwrap(); + assert!(matches!( + &legacy.kind, + LessComputerEventKind::VoiceState { + mode: LessComputerVoiceMode::Submit, + transcript, + outcome: None, + .. + } if transcript.is_empty() + )); + assert_eq!( + serde_json::to_value([ + LessComputerVoiceMode::Submit, + LessComputerVoiceMode::Dictate + ]) + .unwrap(), + fixture["lessComputerVoice"]["modes"] + ); + assert_eq!( + serde_json::to_value([ + LessComputerVoiceOutcome::Submitted, + LessComputerVoiceOutcome::Committed, + LessComputerVoiceOutcome::Empty, + LessComputerVoiceOutcome::Failed, + LessComputerVoiceOutcome::Cancelled, + ]) + .unwrap(), + fixture["lessComputerVoice"]["outcomes"] + ); assert_eq!( serde_json::to_value([ LessComputerVoicePhase::Starting, diff --git a/openless-all/app/scripts/shared-backend-wire-contract.test.mjs b/openless-all/app/scripts/shared-backend-wire-contract.test.mjs index 741c01ec..c8b402cc 100644 --- a/openless-all/app/scripts/shared-backend-wire-contract.test.mjs +++ b/openless-all/app/scripts/shared-backend-wire-contract.test.mjs @@ -508,6 +508,29 @@ assert.doesNotMatch( /pub fn less_computer_(?:window_dismiss|window_open|submit_text)\(/, 'Coordinator must not own Less Computer command business or window wrappers', ); +for (const [command, args] of [ + ['less_computer_voice_start', '{ mode }'], + ['less_computer_voice_stop', '{ sessionId }'], + ['less_computer_voice_cancel', '{ sessionId }'], + ['less_computer_task_cancel', 'undefined'], +]) { + assert(lessComputerIpc.includes(`'${command}', ${args}`), `${command} wire drifted`); + assert.match( + qaCommand, + new RegExp(`pub (?:async )?fn ${command}\\([^]*?require_less_computer_window\\(&window\\)\\?`), + `${command} must only accept the Less Computer window`, + ); +} +assert.match( + qaCommand, + /less_computer_voice_start\([^]*?mode: openless_core::LessComputerVoiceMode[^]*?start_less_computer_voice_from_panel\(mode\)/, + 'panel voice must pass its delivery mode to the shared Host capture', +); +assert.match( + coordinator, + /start_less_computer_voice_from_panel\([^]*?publish_start_error: false/, + 'panel voice start errors are returned to the composer, not posted into the conversation', +); assert.match( stylePacksCommand, /core\.preview_style_pack_runtime\(&style_pack\)/, diff --git a/openless-all/app/src-tauri/src/commands/qa.rs b/openless-all/app/src-tauri/src/commands/qa.rs index 9c4e7310..9b424860 100644 --- a/openless-all/app/src-tauri/src/commands/qa.rs +++ b/openless-all/app/src-tauri/src/commands/qa.rs @@ -50,18 +50,41 @@ pub async fn qa_toggle_recording(core: CoreState<'_>) -> Result<(), String> { /// QA 面板键盘输入:复用语音 QA 的 LLM 管线,只替换问题来源。 #[tauri::command] -pub async fn qa_submit_text(core: CoreState<'_>, text: String, expected_session_id: Option, enforce_context: Option) -> Result<(), String> { +pub async fn qa_submit_text( + core: CoreState<'_>, + text: String, + expected_session_id: Option, + enforce_context: Option, +) -> Result<(), String> { if enforce_context.unwrap_or(false) { - core.services().qa.submit_text_in_context(text, expected_session_id).await.map_err(|error| error.message) + core.services() + .qa + .submit_text_in_context(text, expected_session_id) + .await + .map_err(|error| error.message) } else { - core.services().qa.submit_text(text).await.map_err(|error| error.message) + core.services() + .qa + .submit_text(text) + .await + .map_err(|error| error.message) } } #[tauri::command] -pub async fn qa_get_snapshot(window: Window, core: CoreState<'_>) -> Result { - if !matches!(window.label(), "qa" | "main") { return Err("qa_window_required".into()); } - let snapshot = core.services().qa.snapshot().await.map_err(|_| "qa_snapshot_unavailable")?; +pub async fn qa_get_snapshot( + window: Window, + core: CoreState<'_>, +) -> Result { + if !matches!(window.label(), "qa" | "main") { + return Err("qa_window_required".into()); + } + let snapshot = core + .services() + .qa + .snapshot() + .await + .map_err(|_| "qa_snapshot_unavailable")?; let mut event = openless_core::events::QaStateEvent::from_snapshot(&snapshot); event.edit_instruction_mode = Some(snapshot.edit_instruction_mode); event.edit_apply_available = Some(snapshot.edit_apply_available); @@ -72,13 +95,20 @@ pub async fn qa_get_snapshot(window: Window, core: CoreState<'_>) -> Result Result<(), String> { use tauri::Manager; - if window.label() != "qa" { return Err("qa_window_required".into()); } + if window.label() != "qa" { + return Err("qa_window_required".into()); + } let app = window.app_handle().clone(); let (sender, receiver) = tokio::sync::oneshot::channel(); - window.app_handle().run_on_main_thread(move || { - let _ = sender.send(crate::set_qa_window_expanded(&app, expanded)); - }).map_err(|_| "qa_main_thread_unavailable".to_string())?; - receiver.await.map_err(|_| "qa_resize_cancelled".to_string())? + window + .app_handle() + .run_on_main_thread(move || { + let _ = sender.send(crate::set_qa_window_expanded(&app, expanded)); + }) + .map_err(|_| "qa_main_thread_unavailable".to_string())?; + receiver + .await + .map_err(|_| "qa_resize_cancelled".to_string())? } /// 划词提问面板「编辑指令」复选框。 @@ -172,6 +202,59 @@ pub fn less_computer_submit_text(core: CoreState<'_>, coord: CoordinatorState<'_ }); } +fn require_less_computer_window(window: &Window) -> Result<(), String> { + if window.label() != "less-computer" { + return Err("voice input can only be controlled from the Less Computer window".to_string()); + } + Ok(()) +} + +/// 输入框麦克风(dictate:转写只回填输入框)/ 语音模式按钮(submit:与快捷键一致)。 +/// 启动失败直接返回给面板内联提示,不写入对话流。 +#[tauri::command] +pub async fn less_computer_voice_start( + window: Window, + coord: CoordinatorState<'_>, + mode: openless_core::LessComputerVoiceMode, +) -> Result<(), String> { + require_less_computer_window(&window)?; + coord.start_less_computer_voice_from_panel(mode).await +} + +/// 结束当前录音,按会话自己的 mode 收尾;收尾在后台执行,不等待 Agent 跑完。 +#[tauri::command] +pub fn less_computer_voice_stop( + window: Window, + coord: CoordinatorState<'_>, + session_id: openless_core::SessionId, +) -> Result<(), String> { + require_less_computer_window(&window)?; + coord.stop_less_computer_voice_from_panel(session_id) +} + +/// 只取消面板指定的那次录音。 +#[tauri::command] +pub async fn less_computer_voice_cancel( + window: Window, + coord: CoordinatorState<'_>, + session_id: openless_core::SessionId, +) -> Result<(), String> { + require_less_computer_window(&window)?; + coord + .cancel_less_computer_voice_from_panel(session_id) + .await +} + +/// 停止正在运行的 Agent 任务,浮窗保持打开。 +#[tauri::command] +pub async fn less_computer_task_cancel( + window: Window, + coord: CoordinatorState<'_>, +) -> Result<(), String> { + require_less_computer_window(&window)?; + coord.cancel_less_computer_task().await +} + /// 主设置页的文字测试入口。浮窗自身无需也不允许反向调用这个命令。 #[tauri::command] pub fn less_computer_window_open( diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index e791656b..1c2cbf1d 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1711,6 +1711,51 @@ impl Coordinator { .map_err(|error| error.to_string()) } + /// Composer microphone / voice-mode button. Start errors are returned to the + /// panel instead of being posted into the conversation stream. + pub(crate) async fn start_less_computer_voice_from_panel( + &self, + mode: openless_core::LessComputerVoiceMode, + ) -> Result<(), String> { + start_less_computer_capture( + &self.inner, + openless_core::LessComputerVoiceOptions { + mode, + publish_start_error: false, + }, + ) + .await + .map(|_| ()) + .map_err(|error| error.message) + } + + pub(crate) fn stop_less_computer_voice_from_panel( + &self, + session_id: openless_core::SessionId, + ) -> Result<(), String> { + request_less_computer_voice_stop(&self.inner, session_id) + .map(|_| ()) + .map_err(|error| error.message) + } + + pub(crate) async fn cancel_less_computer_voice_from_panel( + &self, + session_id: openless_core::SessionId, + ) -> Result<(), String> { + cancel_less_computer_voice_request(&self.inner, session_id) + .await + .map(|_| ()) + .map_err(|error| error.message) + } + + /// Stop button while an Agent turn runs; the window stays open. + pub(crate) async fn cancel_less_computer_task(&self) -> Result<(), String> { + cancel_active_less_computer(&self.inner) + .await + .map(|_| ()) + .map_err(|error| error.message) + } + pub(crate) async fn cancel_active_voice(&self) { dictation::cancel_active_session(&self.inner).await; } 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 f842f07c..b1e239f4 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -1220,12 +1220,27 @@ pub(super) async fn handle_less_computer_pressed( } return None; } + start_less_computer_capture(inner, openless_core::LessComputerVoiceOptions::default()) + .await + .ok() +} + +/// Open a Core-owned capture in the single Host slot. Hotkey presses and the +/// panel buttons share this path; only hotkeys own a press generation, so a +/// panel capture never feeds the hotkey interpreter. +pub(super) async fn start_less_computer_capture( + inner: &Arc, + options: openless_core::LessComputerVoiceOptions, +) -> Result { let session_id = openless_core::SessionId::new(); let recording_control = Arc::new(LessComputerRecordingControl::new(inner)); { let mut slot = inner.less_computer_voice.lock(); if slot.is_some() { - return None; + return Err(openless_core::BackendError::new( + openless_core::BackendErrorCode::Busy, + "Less Computer voice input is already active", + )); } *slot = Some(LessComputerHostCapture::Starting( session_id, @@ -1234,9 +1249,10 @@ pub(super) async fn handle_less_computer_pressed( } match inner .backend - .start_less_computer_voice( + .start_less_computer_voice_with( session_id, Arc::clone(&recording_control) as Arc, + options, ) .await { @@ -1247,15 +1263,21 @@ pub(super) async fn handle_less_computer_pressed( // 重绑/禁用/Esc已取走Starting。迟到的原生handle只允许释放, // 不得重新挂回slot,更不能覆盖新的录音或继续提交给Agent。 let _ = session.cancel().await; - return None; + return Err(openless_core::BackendError::new( + openless_core::BackendErrorCode::Cancelled, + "Less Computer voice session was cancelled while starting", + )); } // Make the visible capture state observable before flushing an // early Stop/Cancel. Otherwise a queued effect could hide the glow // first and this function would immediately show it again. inner.host.show_less_computer_glow(); recording_control.flush(session_id); - log::info!("[less-computer] voice session started (session={session_id})"); - Some(session_id) + log::info!( + "[less-computer] voice session started (session={session_id}, mode={:?})", + options.mode + ); + Ok(session_id) } Err(error) => { let mut slot = inner.less_computer_voice.lock(); @@ -1264,11 +1286,77 @@ pub(super) async fn handle_less_computer_pressed( slot.take(); } log::warn!("[less-computer] voice session startup failed: {error}"); - None + Err(error) } } } +/// Panel cancel for one recording. A capture still in the Host slot is +/// released there; one already finishing has left the slot, so only that Core +/// session is cancelled. Other sessions and later runs are never touched. +pub(super) async fn cancel_less_computer_voice_request( + inner: &Arc, + session_id: openless_core::SessionId, +) -> Result { + let owns_capture = inner + .less_computer_voice + .lock() + .as_ref() + .map(LessComputerHostCapture::session_id) + == Some(session_id); + if owns_capture { + cancel_less_computer_capture(inner, Some(session_id)); + return Ok(true); + } + if inner.backend.less_computer_capture_cancelled(session_id) { + return Ok(false); + } + inner + .backend + .cancel_less_computer(Some(session_id)) + .await + .map(|()| true) +} + +/// Panel stop: queue Stop for a cold start, otherwise finish in the background. +/// `finish` awaits a whole Agent turn in submit mode, so the command must not. +pub(super) fn request_less_computer_voice_stop( + inner: &Arc, + session_id: openless_core::SessionId, +) -> Result { + request_less_computer_voice_stop_with(inner, session_id, |task| { + inner.host.spawn(task); + }) +} + +fn request_less_computer_voice_stop_with( + inner: &Arc, + session_id: openless_core::SessionId, + spawn: impl FnOnce(futures_util::future::BoxFuture<'static, ()>), +) -> Result { + let starting = match inner.less_computer_voice.lock().as_ref() { + Some(LessComputerHostCapture::Starting(id, control)) if *id == session_id => { + Some(control.clone()) + } + Some(LessComputerHostCapture::Recording(session)) if session.session_id() == session_id => { + None + } + _ => return Ok(false), + }; + if let Some(control) = starting { + return control + .request(session_id, openless_core::RecordingControlAction::Stop) + .map(|()| true); + } + let task_inner = Arc::clone(inner); + // Stop belongs to the recording the panel observed, even if the task is + // scheduled after that recording was cancelled and a new one has started. + spawn(Box::pin(async move { + let _ = finish_less_computer_voice_session(&task_inner, Some(session_id)).await; + })); + Ok(true) +} + pub(super) async fn handle_less_computer_released( inner: &Arc, expected_session_id: Option, @@ -2386,8 +2474,8 @@ pub(super) fn window_key_matches_trigger( } } -#[cfg(all(test, target_os = "windows"))] -pub(crate) mod windows_less_computer_tests { +#[cfg(test)] +pub(crate) mod less_computer_test_support { use super::*; // 只替换设备/云边界;边沿、取消、Host slot和Core lease都使用生产实现。 @@ -2461,9 +2549,11 @@ pub(crate) mod windows_less_computer_tests { quick_note_hotkey: Mutex::new(None), style_pack_hotkeys: Mutex::new(std::collections::HashMap::new()), selection_polish_hotkey: Mutex::new(None), + #[cfg(target_os = "windows")] selection_voice_host: Arc::new(Mutex::new( selection_voice_session::SelectionVoiceHostState::default(), )), + #[cfg(target_os = "windows")] selection_voice_capture: Mutex::new(None), qa_hotkey: Mutex::new(None), coding_agent_modifier_hotkey: Mutex::new(None), @@ -2478,6 +2568,268 @@ pub(crate) mod windows_less_computer_tests { (Coordinator { inner }, recorder, data_dir) } + pub(crate) struct DelayedFixtureRecorder { + pub(crate) recorder: Arc, + pub(crate) startup_delay: std::time::Duration, + } + + impl openless_core::AudioRecorder for DelayedFixtureRecorder { + fn start( + &self, + session_id: openless_core::SessionId, + context: Arc, + consumer: Arc, + progress: Arc, + ) -> futures_util::future::BoxFuture< + 'static, + Result, openless_core::BackendError>, + > { + let recorder = self.recorder.clone(); + let delay = self.startup_delay; + Box::pin(async move { + tokio::time::sleep(delay).await; + recorder + .start(session_id, context, consumer, progress) + .await + }) + } + } +} + +#[cfg(test)] +mod less_computer_panel_tests { + use super::less_computer_test_support::fixture_coordinator; + use super::*; + + async fn wait_for_released_capture( + coordinator: &Coordinator, + recorder: &openless_core::testing::FixtureAudioRecorder, + stops: usize, + ) { + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while recorder.stop_count() != stops + || coordinator + .backend() + .less_computer_active_session() + .is_some() + { + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + } + }) + .await + .expect("the capture must stop and release its lease"); + } + + fn voice_states( + events: &mut openless_core::EventSubscription, + ) -> Vec { + std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event.kind { + openless_core::BackendEventKind::LessComputerEvent(event) => Some(event.kind), + _ => None, + }) + .collect() + } + + #[tokio::test] + async fn panel_dictation_bypasses_hotkeys_and_a_toggle_press_only_commits_text() { + use openless_core::{ + LessComputerEventKind, LessComputerVoiceMode, LessComputerVoiceOutcome, + }; + let (coordinator, recorder, data_dir) = + fixture_coordinator(crate::types::HotkeyMode::Toggle, std::time::Duration::ZERO); + let mut events = coordinator.backend().subscribe(); + coordinator + .start_less_computer_voice_from_panel(LessComputerVoiceMode::Dictate) + .await + .unwrap(); + let inner = &coordinator.inner; + assert_eq!( + inner.less_computer_press_generation.load(Ordering::SeqCst), + 0 + ); + assert!(matches!( + inner.less_computer_voice.lock().as_ref(), + Some(LessComputerHostCapture::Recording(session)) + if session.mode() == LessComputerVoiceMode::Dictate + )); + assert!(coordinator + .start_less_computer_voice_from_panel(LessComputerVoiceMode::Submit) + .await + .is_err()); + + // A toggle press ends the panel capture with the panel's own delivery mode. + assert!( + handle_less_computer_pressed(inner, 7, std::time::Instant::now()) + .await + .is_none() + ); + wait_for_released_capture(&coordinator, &recorder, 1).await; + let kinds = voice_states(&mut events); + assert!(!kinds + .iter() + .any(|kind| matches!(kind, LessComputerEventKind::User { .. }))); + assert!(kinds.iter().any(|kind| matches!( + kind, + LessComputerEventKind::VoiceState { + outcome: Some(LessComputerVoiceOutcome::Committed), + transcript, + .. + } if transcript == "voice" + ))); + drop(coordinator); + std::fs::remove_dir_all(data_dir).unwrap(); + } + + #[tokio::test] + async fn panel_stop_and_cancel_only_touch_the_named_capture() { + use openless_core::LessComputerVoiceMode; + let (coordinator, recorder, data_dir) = + fixture_coordinator(crate::types::HotkeyMode::Hold, std::time::Duration::ZERO); + coordinator + .stop_less_computer_voice_from_panel(openless_core::SessionId::new()) + .unwrap(); + coordinator + .start_less_computer_voice_from_panel(LessComputerVoiceMode::Dictate) + .await + .unwrap(); + let owner = coordinator + .backend() + .less_computer_active_session() + .unwrap(); + coordinator + .stop_less_computer_voice_from_panel(openless_core::SessionId::new()) + .unwrap(); + assert_eq!(recorder.stop_count(), 0); + coordinator + .cancel_less_computer_voice_from_panel(openless_core::SessionId::new()) + .await + .unwrap(); + assert_eq!( + coordinator.backend().less_computer_active_session(), + Some(owner), + "a stale session id must not cancel the live capture" + ); + coordinator + .cancel_less_computer_voice_from_panel(owner) + .await + .unwrap(); + wait_for_released_capture(&coordinator, &recorder, 1).await; + + coordinator + .start_less_computer_voice_from_panel(LessComputerVoiceMode::Dictate) + .await + .unwrap(); + let next = coordinator + .backend() + .less_computer_active_session() + .unwrap(); + coordinator + .stop_less_computer_voice_from_panel(next) + .unwrap(); + wait_for_released_capture(&coordinator, &recorder, 2).await; + drop(coordinator); + std::fs::remove_dir_all(data_dir).unwrap(); + } + + #[tokio::test] + async fn delayed_panel_stop_cannot_finish_a_new_recording() { + use openless_core::{LessComputerEventKind, LessComputerVoiceMode}; + let (coordinator, recorder, data_dir) = + fixture_coordinator(crate::types::HotkeyMode::Hold, std::time::Duration::ZERO); + let mut events = coordinator.backend().subscribe(); + coordinator + .start_less_computer_voice_from_panel(LessComputerVoiceMode::Dictate) + .await + .unwrap(); + let first = coordinator + .backend() + .less_computer_active_session() + .unwrap(); + let mut queued = None; + assert!( + request_less_computer_voice_stop_with(&coordinator.inner, first, |task| { + queued = Some(task); + }) + .unwrap() + ); + coordinator + .cancel_less_computer_voice_from_panel(first) + .await + .unwrap(); + wait_for_released_capture(&coordinator, &recorder, 1).await; + + coordinator + .start_less_computer_voice_from_panel(LessComputerVoiceMode::Submit) + .await + .unwrap(); + let second = coordinator + .backend() + .less_computer_active_session() + .unwrap(); + assert_ne!(first, second); + // Poll exactly the production stop effect only after the successor has + // acquired the Host slot. No timing/sleep assumption schedules this race. + queued.expect("the recording stop must be scheduled").await; + assert_eq!(recorder.stop_count(), 1); + assert_eq!( + coordinator.backend().less_computer_active_session(), + Some(second) + ); + assert!(matches!( + coordinator.inner.less_computer_voice.lock().as_ref(), + Some(LessComputerHostCapture::Recording(session)) if session.session_id() == second + )); + assert!(!voice_states(&mut events).iter().any(|kind| matches!( + kind, + LessComputerEventKind::User { .. } | LessComputerEventKind::Started + ))); + coordinator + .cancel_less_computer_voice_from_panel(second) + .await + .unwrap(); + wait_for_released_capture(&coordinator, &recorder, 2).await; + drop(coordinator); + std::fs::remove_dir_all(data_dir).unwrap(); + } + + #[tokio::test] + async fn panel_stop_rejects_another_session_during_cold_start() { + let (coordinator, _recorder, data_dir) = + fixture_coordinator(crate::types::HotkeyMode::Hold, std::time::Duration::ZERO); + let owner = openless_core::SessionId::new(); + let control = Arc::new(LessComputerRecordingControl::new(&coordinator.inner)); + *coordinator.inner.less_computer_voice.lock() = + Some(LessComputerHostCapture::Starting(owner, control.clone())); + assert!(!request_less_computer_voice_stop_with( + &coordinator.inner, + openless_core::SessionId::new(), + |_| panic!("a stale cold-start stop must not schedule an effect"), + ) + .unwrap()); + assert!(control.pending.lock().is_empty()); + assert!( + request_less_computer_voice_stop_with(&coordinator.inner, owner, |_| { + panic!("cold-start stop belongs in the handoff queue") + }) + .unwrap() + ); + assert!(matches!( + control.pending.lock().as_slice(), + [(id, openless_core::RecordingControlAction::Stop)] if *id == owner + )); + coordinator.inner.less_computer_voice.lock().take(); + drop(control); + drop(coordinator); + std::fs::remove_dir_all(data_dir).unwrap(); + } +} + +#[cfg(all(test, target_os = "windows"))] +pub(crate) mod windows_less_computer_tests { + pub(crate) use super::less_computer_test_support::fixture_coordinator; + use super::*; + #[tokio::test] async fn translation_stopped_outside_the_hotkey_does_not_leak_to_the_next_session() { for stop_entry in ["button", "cli", "silence"] { @@ -2556,33 +2908,6 @@ pub(crate) mod windows_less_computer_tests { std::fs::remove_dir_all(data_dir).unwrap(); } - struct DelayedFixtureRecorder { - recorder: Arc, - startup_delay: std::time::Duration, - } - - impl openless_core::AudioRecorder for DelayedFixtureRecorder { - fn start( - &self, - session_id: openless_core::SessionId, - context: Arc, - consumer: Arc, - progress: Arc, - ) -> futures_util::future::BoxFuture< - 'static, - Result, openless_core::BackendError>, - > { - let recorder = self.recorder.clone(); - let delay = self.startup_delay; - Box::pin(async move { - tokio::time::sleep(delay).await; - recorder - .start(session_id, context, consumer, progress) - .await - }) - } - } - #[tokio::test] async fn escape_after_toggle_release_allows_the_next_less_recording() { let (coordinator, recorder, data_dir) = diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 48b09e5b..82831531 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -364,6 +364,10 @@ macro_rules! app_invoke_handler_desktop { commands::less_computer_submit_text, commands::less_computer_sync, commands::less_computer_approve, + commands::less_computer_voice_start, + commands::less_computer_voice_stop, + commands::less_computer_voice_cancel, + commands::less_computer_task_cancel, commands::validate_combo_hotkey, commands::set_combo_hotkey, commands::list_provider_descriptors, diff --git a/openless-all/app/src-tauri/src/tauri_events.rs b/openless-all/app/src-tauri/src/tauri_events.rs index ad87e1cb..a4352035 100644 --- a/openless-all/app/src-tauri/src/tauri_events.rs +++ b/openless-all/app/src-tauri/src/tauri_events.rs @@ -65,9 +65,9 @@ pub fn start(app: AppHandle, backend: Arc) { } let preferences = backend.get_preferences(); if !preferences.active_asr_provider.is_empty() { - if let Err(error) = crate::commands::sync_active_asr_provider_to_vault( - &preferences.active_asr_provider, - ) { + if let Err(error) = + crate::commands::sync_active_asr_provider_to_vault(&preferences.active_asr_provider) + { log::warn!("[startup] active ASR provider mirror failed: {error}"); } } @@ -249,6 +249,7 @@ async fn forward_legacy_event( phase, level, elapsed_ms, + .. } = &event.kind { // 胶囊只展示Core语音快照。已开始的其它会话拥有共享窗口,旧Less终态不得盖掉它。 @@ -1266,6 +1267,9 @@ mod tests { phase: openless_core::LessComputerVoicePhase::Transcribing, level: 0.0, elapsed_ms: 456, + mode: openless_core::LessComputerVoiceMode::Submit, + transcript: String::new(), + outcome: None, }, }; let payload = transcription_notice_payload( diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index b6c2bbe2..a2a96c20 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -18,7 +18,7 @@ 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 { IS_BETA_BUILD } from '../lib/appVersion'; import { HOTKEY_MODE_MIGRATION_ACK_KEY, HOTKEY_MODE_MIGRATION_DEFERRED_KEY, @@ -357,8 +357,6 @@ function FloatingShellBody({ {t('shell.betaTag')} )} - - {t('shell.footer.version', { version: APP_VERSION_LABEL })} {/* nav — 扁平项 + 可展开分组(用户拍板结构)。扁平项:概览/历史/词汇。 diff --git a/openless-all/app/src/components/GithubLoginModal.tsx b/openless-all/app/src/components/GithubLoginModal.tsx index 408efdbe..fad25b78 100644 --- a/openless-all/app/src/components/GithubLoginModal.tsx +++ b/openless-all/app/src/components/GithubLoginModal.tsx @@ -3,8 +3,9 @@ // 轮询直到 authorized。各阶段内容套同一 minHeight 容器,窗口尺寸恒定, // 不再出现「先弹小窗、过会儿变大窗」的跳动。 -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useId, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { CircleCheckIcon, XIcon } from 'lucide-react'; import { githubDeviceFlowCancel, githubDeviceFlowPoll, @@ -34,10 +35,19 @@ interface GithubLoginModalProps { onClose: () => void; /** 授权成功回调(拿到 GitHub login)。 */ onSuccess: (login: string) => void; + /** 由调用方的 useExitMount 驱动退场动画。 */ + closing?: boolean; + overlayClassName?: string; } -export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps) { +export function GithubLoginModal({ + onClose, + onSuccess, + closing = false, + overlayClassName, +}: GithubLoginModalProps) { const { t } = useTranslation(); + const titleId = useId(); const [phase, setPhase] = useState({ kind: 'starting' }); const [copied, setCopied] = useState(false); const cancelledRef = useRef(false); @@ -175,7 +185,14 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps) }; return ( - +
-

{t('marketplace.oauth.title')}

+

+ {t('marketplace.oauth.title')} +

@@ -298,8 +315,13 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps) {phase.kind === 'success' && (
-
-
✓
+
+
{t('marketplace.oauth.successAs', { login: phase.login })}
@@ -313,9 +335,9 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps) style={{ padding: 12, borderRadius: 10, - border: '0.5px solid rgba(239,68,68,0.3)', - background: 'rgba(239,68,68,0.06)', - color: '#b91c1c', + border: '0.5px solid color-mix(in srgb, var(--ol-err) 32%, transparent)', + background: 'color-mix(in srgb, var(--ol-err) 8%, transparent)', + color: 'var(--ol-err)', fontSize: 12, lineHeight: 1.6, whiteSpace: 'pre-wrap', diff --git a/openless-all/app/src/components/SavedToast.tsx b/openless-all/app/src/components/SavedToast.tsx index 33541271..cb9abd2e 100644 --- a/openless-all/app/src/components/SavedToast.tsx +++ b/openless-all/app/src/components/SavedToast.tsx @@ -14,6 +14,9 @@ interface SavedToastProps { message: string; offsetStyle?: Pick; slideFrom?: ToastSlideFrom; + actionLabel?: string; + onAction?: () => void; + durationMs?: number; } export function SavedToast({ @@ -21,6 +24,9 @@ export function SavedToast({ message, offsetStyle, slideFrom = 'right', + actionLabel, + onAction, + durationMs, }: SavedToastProps) { // 维护内部状态,使通知可以自己倒计时关闭(即使用户父组件的 timer 长于 0.8s) const [internalVisible, setInternalVisible] = useState(false); @@ -28,12 +34,14 @@ export function SavedToast({ useEffect(() => { if (saveState !== 'idle') { setInternalVisible(true); - // 满足用户要求:弹出后约 0.8 秒自动收回 - const timer = window.setTimeout(() => setInternalVisible(false), 800); + const timer = window.setTimeout( + () => setInternalVisible(false), + durationMs ?? (onAction ? 6000 : 800), + ); return () => window.clearTimeout(timer); } setInternalVisible(false); - }, [saveState, message]); + }, [saveState, message, durationMs, onAction]); const failed = saveState === 'failed'; @@ -59,7 +67,7 @@ export function SavedToast({ : '0 4px 12px -8px rgba(37,99,235,.26)', backdropFilter: 'blur(12px) saturate(160%)', WebkitBackdropFilter: 'blur(12px) saturate(160%)', - pointerEvents: 'none', + pointerEvents: onAction ? 'auto' : 'none', whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', @@ -83,6 +91,24 @@ export function SavedToast({ style={style} > {failed ? '⚠️' : '✓'} {message} + {actionLabel && onAction && ( + + )} )} diff --git a/openless-all/app/src/components/chat/LiveWaveform.tsx b/openless-all/app/src/components/chat/LiveWaveform.tsx new file mode 100644 index 00000000..62bdaa6f --- /dev/null +++ b/openless-all/app/src/components/chat/LiveWaveform.tsx @@ -0,0 +1,124 @@ +import { useEffect, useRef } from 'react'; + +const BAR_WIDTH = 3; +const BAR_GAP = 3; +const SAMPLE_MS = 70; +const MIN_BAR = 2; + +function clampLevel(level: number): number { + return Number.isFinite(level) ? Math.max(0, Math.min(1, level)) : 0; +} + +/** + * Scrolling history of the real microphone level. Each bar is one sampled + * level (fast attack, slow release); nothing is synthesized while recording. + * `processing` replaces the history with a quiet travelling pulse. + */ +export function LiveWaveform({ + level, + processing = false, + label, +}: { + level: number; + processing?: boolean; + label: string; +}) { + const canvasRef = useRef(null); + const levelRef = useRef(0); + const processingRef = useRef(processing); + levelRef.current = clampLevel(level); + processingRef.current = processing; + + useEffect(() => { + const canvas = canvasRef.current; + const context = canvas?.getContext('2d'); + if (!canvas || !context) return; + const reducedMotion = + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + let history: number[] = []; + let smoothed = 0; + let lastSample = performance.now(); + let width = 0; + let height = 0; + let color = ''; + let colorAge = 0; + let frame = 0; + + const resize = () => { + const rect = canvas.getBoundingClientRect(); + const ratio = window.devicePixelRatio || 1; + width = rect.width; + height = rect.height; + canvas.width = Math.max(1, Math.round(width * ratio)); + canvas.height = Math.max(1, Math.round(height * ratio)); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + }; + resize(); + const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null; + observer?.observe(canvas); + + const bar = (x: number, amplitude: number, alpha: number) => { + const h = Math.max(MIN_BAR, amplitude * height * 0.92); + const y = (height - h) / 2; + context.globalAlpha = alpha; + context.beginPath(); + if (typeof context.roundRect === 'function') + context.roundRect(x, y, BAR_WIDTH, h, BAR_WIDTH / 2); + else context.rect(x, y, BAR_WIDTH, h); + context.fill(); + }; + + const draw = (now: number) => { + const step = BAR_WIDTH + BAR_GAP; + const capacity = Math.max(1, Math.ceil(width / step) + 1); + if (colorAge-- <= 0) { + color = getComputedStyle(canvas).color; + colorAge = 30; + } + context.clearRect(0, 0, width, height); + context.fillStyle = color; + + if (processingRef.current) { + smoothed = 0; + history = []; + for (let j = 0; j < capacity; j += 1) { + const wave = reducedMotion ? 0.5 : 0.5 + 0.5 * Math.sin(now / 240 - j * 0.45); + bar(width - BAR_WIDTH - j * step, 0.1 + 0.16 * wave, 0.35 + 0.35 * wave); + } + } else { + const target = levelRef.current; + smoothed += (target - smoothed) * (target > smoothed ? 0.6 : 0.2); + if (now - lastSample >= SAMPLE_MS) { + history.push(smoothed); + lastSample = now; + if (history.length > capacity) history = history.slice(-capacity); + } + const drift = reducedMotion ? 0 : Math.min(1, (now - lastSample) / SAMPLE_MS); + for (let j = 0; j < capacity; j += 1) { + const sample = history[history.length - 1 - j] ?? 0; + const amplitude = Math.min(1, Math.sqrt(sample) * 1.08); + const age = j / capacity; + bar(width - BAR_WIDTH - (j + drift) * step, amplitude, 1 - age * 0.65); + } + } + context.globalAlpha = 1; + frame = window.requestAnimationFrame(draw); + }; + frame = window.requestAnimationFrame(draw); + return () => { + window.cancelAnimationFrame(frame); + observer?.disconnect(); + }; + }, []); + + return ( +
+
+ ); +} diff --git a/openless-all/app/src/components/ui/Modal.tsx b/openless-all/app/src/components/ui/Modal.tsx index 41774f8b..5c0c66ce 100644 --- a/openless-all/app/src/components/ui/Modal.tsx +++ b/openless-all/app/src/components/ui/Modal.tsx @@ -1,10 +1,11 @@ // Modal — 居中弹窗:backdrop + 卡片。风格市场详情 / 上传 / 我的发布 / GitHub 登录 // 等共用同一套弹出逻辑,避免每处各写一个。 // -// 动画沿用 global.css 的 ol-modal-backdrop-in / ol-modal-card-in(纯 opacity + -// transform,不碰 blur),与设置弹窗、各市场弹窗保持一致。 +// 动画在 overlays.css 的 .ol-dialog-overlay / .ol-dialog-card 上(纯 opacity + +// transform,不碰 blur)。退场使用独立的 *-out 关键帧:仅反转同名动画的方向不会 +// 重新播放,已结束的入场动画会直接跳到起始帧,表现为「啪」地消失。 -import type { CSSProperties, ReactNode } from 'react'; +import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; interface ModalProps { @@ -16,9 +17,24 @@ interface ModalProps { width?: string; /** 需要固定标题和底栏的弹窗可由内部内容区负责滚动。 */ style?: CSSProperties; - /** true 时反向播放入场动画;调用方用 + /** true 时播放退场动画;调用方用 * useExitMount 门控卸载时机,动画播完再 unmount。 */ closing?: boolean; + /** 宿主窗口定制遮罩与卡片外观(例如圆角浮窗需要圆角遮罩)。 */ + overlayClassName?: string; + labelledBy?: string; +} + +const FOCUSABLE = + 'a[href], button:not([disabled]), textarea:not([disabled]), select:not([disabled]), input:not([disabled]):not([type="hidden"]), [tabindex]:not([tabindex="-1"])'; + +// Only the top-most dialog keeps Tab cycling inside itself. +const openModals: symbol[] = []; + +function focusableWithin(root: HTMLElement): HTMLElement[] { + return Array.from(root.querySelectorAll(FOCUSABLE)).filter( + (element) => element.getClientRects().length > 0, + ); } export function Modal({ @@ -28,7 +44,47 @@ export function Modal({ width = 'min(560px, 100%)', style, closing = false, + overlayClassName, + labelledBy, }: ModalProps) { + const cardRef = useRef(null); + + useEffect(() => { + const id = Symbol('modal'); + openModals.push(id); + const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const card = cardRef.current; + if (card && !card.contains(document.activeElement)) card.focus({ preventScroll: true }); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Tab' || !card || openModals[openModals.length - 1] !== id) return; + const items = focusableWithin(card); + const active = document.activeElement; + if (items.length === 0) { + event.preventDefault(); + card.focus({ preventScroll: true }); + return; + } + const first = items[0]; + const last = items[items.length - 1]; + if (event.shiftKey) { + if (active === first || active === card || !card.contains(active)) { + event.preventDefault(); + last.focus(); + } + } else if (active === last || !card.contains(active)) { + event.preventDefault(); + first.focus(); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + const index = openModals.indexOf(id); + if (index >= 0) openModals.splice(index, 1); + if (previous?.isConnected) previous.focus({ preventScroll: true }); + }; + }, []); + // Portal 到 document.body:弹窗常从设置 / 市场等面板内部触发,而窗口 chrome // (WindowChrome)和页面容器带常驻 `will-change: transform`,会创建 containing // block —— 直接渲染的话 backdrop 的 `position: fixed` 会相对那个祖先而非视口定位, @@ -36,7 +92,7 @@ export function Modal({ // Portal 出去后 fixed 相对视口,遮罩铺满全局。与 Tooltip / SelectLite 同款做法。 return createPortal(
e.stopPropagation()} style={{ width, @@ -63,9 +122,7 @@ export function Modal({ border: '1px solid var(--ol-dialog-border)', boxShadow: 'var(--ol-dialog-shadow)', padding: 24, - animation: closing - ? 'ol-modal-card-in 0.18s var(--ol-motion-soft) reverse both' - : 'ol-modal-card-in 0.24s var(--ol-motion-spring)', + outline: 'none', ...style, }} > diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 2a38e8ec..4f591c42 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -26,6 +26,20 @@ export const de: typeof zhCN = { title: 'Verschlüsselte Cloud-Synchronisierung', description: 'Vor der Synchronisierung mit deinem GitHub-Konto auf diesem Gerät verschlüsseln.', enable: 'Verschlüsselte Synchronisierung aktivieren', + setPassword: 'Sync-Passwort festlegen', + stepEnableTitle: 'Schritt 1 von 3: Synchronisierung einschalten', + stepEnableDetail: 'Den Schalter verwenden. Nach der Bestätigung folgt das Sync-Passwort.', + stepPasswordTitle: 'Schritt 2 von 3: Sync-Passwort festlegen', + stepPasswordDetail: 'Noch ein Schritt. Die Synchronisierung startet erst nach dem Passwort.', + stepUnlockTitle: 'Schritt 2 von 3: Dieses Gerät entsperren', + stepUnlockDetail: 'Noch ein Schritt. Sync-Passwort eingeben, um dieses Gerät zu entsperren.', + stepClosedTitle: 'Synchronisierung ist aus', + stepClosedDetail: 'Die Cloud-Sicherung bleibt. Schalter einschalten, um fortzufahren.', + stepDoneTitle: 'Schritt 3 von 3: Synchronisierung ist bereit', + stepDoneDetail: 'Dieses Gerät ist entsperrt und die Synchronisierung ist an.', + stepPreparingTitle: 'Nächster Schritt wird geöffnet', + stepPreparingDetail: 'Hinweis bestätigt. Als Nächstes legen Sie das Sync-Passwort fest.', + stepFollowDetail: 'Mit der Schaltfläche in dieser Zeile fortfahren.', refresh: 'Status aktualisieren', loading: 'Synchronisierungsstatus wird geprüft…', signIn: 'Mit GitHub anmelden', @@ -171,7 +185,7 @@ export const de: typeof zhCN = { invalidData: 'Verschlüsselte Sicherung nicht verifizierbar. Lokale Daten wurden nicht ersetzt.', localData: - 'Lokale Daten konnten nicht sicher vorbereitet werden. Aktualisieren und erneut versuchen.', + 'Die Daten auf diesem Gerät konnten nicht vorbereitet werden. Status aktualisieren und erneut versuchen.', }, }, cloudSync: { @@ -353,22 +367,13 @@ export const de: typeof zhCN = { agents: 'Deine Agenten', configured: 'Aktuell konfiguriert', agentSettings: 'In Einstellungen wählen', - historyUnavailable: 'Verlauf · nicht verfügbar', sessionUnavailable: 'Neue Sitzung · nicht verfügbar', - agentHint: 'Wähle den Agenten in den Einstellungen.', - workspace: 'Arbeitsbereich', - multitask: 'Mehrere Aufgaben', - unavailable: 'Noch nicht verfügbar', - historyHint: - 'Verlauf und mehrere Sitzungen sind für diese vier Agenten noch nicht verfügbar.', signedIn: 'Angemeldet', signIn: 'Anmelden', currentSession: 'Aktuelles Gespräch', minimize: 'Minimieren', maximize: 'Maximieren / Wiederherstellen', windowError: 'Die Fensteraktion ist fehlgeschlagen. Erneut versuchen.', - emptyHint: 'Beschreibe eine Aufgabe oder nutze dein eingerichtetes Sprachkürzel.', - voiceHint: 'Sprachkürzel gedrückt halten', idle: 'Wartet auf eine Aufgabe', waitingApproval: 'Wartet auf Freigabe', submittingApproval: 'Wird gesendet…', @@ -376,18 +381,46 @@ export const de: typeof zhCN = { approvalExpired: 'Dieser Durchlauf ist beendet. Eine Freigabe ist nicht mehr möglich.', sendError: 'Senden fehlgeschlagen. Deine Eingabe bleibt erhalten. Erneut versuchen.', browserUnavailable: 'Die Browser-Vorschau führt keine Befehle aus. Nutze die Desktop-App.', - cancelHint: 'Eine laufende Aufgabe kannst du über die Kapsel abbrechen.', inputHint: 'Enter zum Senden · Umschalt + Enter für eine neue Zeile', - activity: 'Aufgabenaktivität', - noActivity: 'Noch keine Aktivität', - activityHint: 'Werkzeugaufrufe und Freigaben erscheinen hier, sobald eine Aufgabe startet.', - currentTask: 'Aktuelle Aufgabe', - toolActive: 'Wird aufgerufen', - toolReported: 'Aufruf gemeldet', - approval: 'Aktionsfreigabe', apiCost: 'API-Kosten dieses Durchlaufs', - activitySource: - 'Folgt der aktuellen Aufgabe. Kosten erscheinen nur, wenn der Dienst sie meldet.', + emptyHint: 'Beschreibe eine Aufgabe, tippe aufs Mikrofon oder nutze dein Sprach-Kürzel.', + showInspector: 'Arbeitsbereich einblenden', + hideInspector: 'Arbeitsbereich ausblenden', + inspectorTitle: 'Arbeitsbereich', + inspectorStatus: 'Status', + inspectorTurn: 'Diese Runde', + toolCalls: 'Tool-Aufrufe', + pendingApprovals: 'Wartet auf dich', + inspectorVoice: 'Spracheingabe', + voiceShortcutOff: + 'Kein Sprach-Kürzel festgelegt. Du kannst es in den Einstellungen aktivieren.', + voiceModesHint: + 'Mikrofon: Der Text landet im Eingabefeld, damit du ihn vor dem Senden bearbeiten kannst. Sprachtaste: wird beim Stoppen direkt an den Agenten gesendet.', + copy: 'Kopieren', + copied: 'Kopiert', + messagePlaceholder: 'Nachricht an {{agent}}', + }, + voice: { + dictate: 'Ins Eingabefeld diktieren', + voiceMode: 'Sprachmodus (sendet beim Stoppen)', + stopTask: 'Aufgabe stoppen', + cancel: 'Aufnahme abbrechen', + confirmDictation: 'Diktat beenden', + stopAndSend: 'Stoppen und senden', + listening: 'Hört zu…', + starting: 'Mikrofon wird vorbereitet…', + transcribing: 'Wird transkribiert…', + empty: 'Nichts erkannt. Bitte noch einmal.', + failed: 'Spracherkennung fehlgeschlagen. Bitte erneut versuchen.', + startFailed: 'Aufnahme konnte nicht starten: {{message}}', + taskCancelFailed: 'Die Aufgabe wurde nicht gestoppt. Bitte erneut versuchen.', + dictateCaption: + 'Tippe auf ✓, wenn du fertig bist – der Text landet zum Bearbeiten im Eingabefeld · Esc bricht ab', + submitCaption: 'Tippe auf ↑, um direkt an den Agenten zu senden · Esc bricht ab', + stopHint: 'Aufgabe läuft – tippe auf ■ zum Stoppen', + holdHint: '{{key}} gedrückt halten zum Sprechen', + toggleHint: '{{key}} drücken zum Starten oder Beenden', + autoHint: '{{key}} halten oder kurz drücken zum Sprechen', }, title: 'Less Computer', subtitle: 'Was soll dein Computer tun?', @@ -824,6 +857,9 @@ export const de: typeof zhCN = { }, vocab: { selectAllVisible: 'Aktuelle Ergebnisse auswählen', + selecting: 'Auswählen', + doneSelecting: 'Fertig', + disabledWord: 'Aus', selectedCount: 'Ausgewählte Wörter: {{count}}', selectWord: '„{{phrase}}“ auswählen', deleteSelected: 'Auswahl löschen ({{count}})', @@ -1026,6 +1062,7 @@ export const de: typeof zhCN = { deleteImported: 'Löschen', deleteConfirm: '„{{name}}“ löschen? Dies kann nicht rückgängig gemacht werden.', deleteSuccess: '„{{name}}“ gelöscht.', + undoDelete: 'Rückgängig', deleteFailed: 'Paket konnte nicht gelöscht werden: {{err}}', summaryCurrentEmpty: 'Noch kein Paket ausgewählt', editorTitle: 'Paket bearbeiten', @@ -1509,6 +1546,10 @@ export const de: typeof zhCN = { verificationUnavailable: 'Die Prüfung wird für diesen Kanal nicht unterstützt', passed: 'Prüfung bestanden', failed: 'Prüfung fehlgeschlagen · {{reason}}', + failedPlain: 'Prüfung fehlgeschlagen', + failureKeepsEnabled: + 'Eine fehlgeschlagene Prüfung schaltet den Dienst nicht ab. Anfragen nutzen weiter den ersten aktivierten Dienst und wechseln nicht automatisch.', + reverify: 'Erneut prüfen', elapsed: 'Dauer: {{ms}} ms', staleResult: 'Das Ergebnis ist älter als 24 Stunden', connectionTitle: 'Dienstverbindung', @@ -2268,8 +2309,9 @@ export const de: typeof zhCN = { omni: 'Multimodal', models: 'Lokale Modelle', connections: 'Verbindungen', - statusConfigured: 'Eingerichtet', - statusMissing: 'Nicht eingerichtet', + statusConfigured: + 'Grüner Punkt: mindestens ein Dienst ist aktiv. Anfragen nutzen den ersten aktivierten Dienst.', + statusMissing: 'Roter Punkt: noch kein Dienst ist aktiviert.', }, searchPlaceholder: 'Einstellungskategorie suchen…', clearSearch: 'Suche leeren', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index c80f4c89..08c604b6 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -29,6 +29,23 @@ export const en: typeof zhCN = { title: 'Encrypted cloud sync', description: 'Encrypt on this device before syncing with your GitHub account.', enable: 'Enable encrypted sync', + setPassword: 'Set sync password', + stepEnableTitle: 'Step 1 of 3: Turn sync on', + stepEnableDetail: + 'Use the switch. After you confirm the notice, the next step is a sync password.', + stepPasswordTitle: 'Step 2 of 3: Set a sync password', + stepPasswordDetail: + 'One step left. Sync starts only after you set a password. This is not done yet.', + stepUnlockTitle: 'Step 2 of 3: Unlock this device', + stepUnlockDetail: 'One step left. Enter the sync password to unlock this device.', + stepClosedTitle: 'Sync is off', + stepClosedDetail: 'The cloud backup is still there. Turn the switch on to continue.', + stepDoneTitle: 'Step 3 of 3: Sync is ready', + stepDoneDetail: 'This device is unlocked and sync is on.', + stepPreparingTitle: 'Opening the next step', + stepPreparingDetail: + 'The notice is confirmed. Next you will set a sync password. That step is not done yet.', + stepFollowDetail: 'Use the button on this row to continue.', refresh: 'Refresh status', loading: 'Checking sync status…', signIn: 'Sign in with GitHub', @@ -170,7 +187,7 @@ export const en: typeof zhCN = { reviewRequired: 'Review the full sync scope and the cloud restore preview before continuing.', choicesRequired: 'Choose local or cloud for every conflict.', invalidData: 'The encrypted backup could not be verified. Local data was not replaced.', - localData: 'Could not prepare local data securely. Refresh and try again.', + localData: 'This device could not prepare its data. Choose Refresh status, then try again.', }, }, cloudSync: { @@ -349,21 +366,13 @@ export const en: typeof zhCN = { agents: 'Your agents', configured: 'Configured agent', agentSettings: 'Choose in Settings', - historyUnavailable: 'History · unavailable', sessionUnavailable: 'New session · unavailable', - agentHint: 'Choose your agent in Settings.', - workspace: 'Workspace', - multitask: 'Multiple tasks', - unavailable: 'Not available yet', - historyHint: 'History and multiple sessions are not available for these four agents yet.', signedIn: 'Signed in', signIn: 'Sign in', currentSession: 'Current conversation', minimize: 'Minimize', maximize: 'Maximize / restore', windowError: 'The window action failed. Try again.', - emptyHint: 'Describe a task, or use your configured voice shortcut to get started.', - voiceHint: 'Hold your voice shortcut', idle: 'Waiting for a task', waitingApproval: 'Waiting for approval', submittingApproval: 'Submitting…', @@ -371,17 +380,44 @@ export const en: typeof zhCN = { approvalExpired: 'This turn has ended. Approval is no longer available.', sendError: 'Could not send. Your draft is saved here; try again.', browserUnavailable: 'This browser preview cannot run commands. Use the desktop app.', - cancelHint: 'Cancel a running task from the capsule.', inputHint: 'Enter to send · Shift + Enter for a new line', - activity: 'Task activity', - noActivity: 'No activity yet', - activityHint: 'Tool calls and approvals will appear here when a task starts.', - currentTask: 'Current task', - toolActive: 'Calling tool', - toolReported: 'Call reported', - approval: 'Action approval', apiCost: 'API cost this turn', - activitySource: 'Updates with this task. Cost appears only when reported by the service.', + emptyHint: 'Describe a task, tap the mic, or use your voice shortcut to get started.', + showInspector: 'Show workspace panel', + hideInspector: 'Hide workspace panel', + inspectorTitle: 'Workspace', + inspectorStatus: 'Status', + inspectorTurn: 'This turn', + toolCalls: 'Tool calls', + pendingApprovals: 'Waiting for you', + inspectorVoice: 'Voice input', + voiceShortcutOff: 'No voice shortcut set. You can turn one on in Settings.', + voiceModesHint: + 'Mic: your words go into the input so you can edit before sending. Voice button: sends straight to the agent when you stop.', + copy: 'Copy', + copied: 'Copied', + messagePlaceholder: 'Message {{agent}}', + }, + voice: { + dictate: 'Dictate into the input', + voiceMode: 'Voice mode (sends when you stop)', + stopTask: 'Stop task', + cancel: 'Cancel recording', + confirmDictation: 'Finish dictation', + stopAndSend: 'Stop and send', + listening: 'Listening…', + starting: 'Preparing microphone…', + transcribing: 'Transcribing…', + empty: "Didn't catch anything. Try again.", + failed: 'Speech recognition failed. Try again.', + startFailed: 'Could not start recording: {{message}}', + taskCancelFailed: 'The task did not stop. Try again.', + dictateCaption: 'Tap ✓ when done — the text goes into the input for editing · Esc to cancel', + submitCaption: 'Tap ↑ to send straight to the agent · Esc to cancel', + stopHint: 'Task running — tap ■ to stop', + holdHint: 'Hold {{key}} to talk', + toggleHint: 'Press {{key}} to start or stop talking', + autoHint: 'Hold or tap {{key}} to talk', }, title: 'Less Computer', subtitle: 'What should your computer do?', @@ -809,6 +845,9 @@ export const en: typeof zhCN = { }, vocab: { selectAllVisible: 'Select current results', + selecting: 'Select', + doneSelecting: 'Done', + disabledWord: 'Off', selectedCount: '{{count}} words selected', selectWord: 'Select “{{phrase}}”', deleteSelected: 'Delete selected ({{count}})', @@ -1004,6 +1043,7 @@ export const en: typeof zhCN = { deleteImported: 'Delete', deleteConfirm: 'Delete "{{name}}"? This cannot be undone.', deleteSuccess: 'Deleted "{{name}}".', + undoDelete: 'Undo', deleteFailed: 'Failed to delete pack: {{err}}', summaryCurrentEmpty: 'No pack selected yet', editorTitle: 'Edit Pack', @@ -1474,6 +1514,10 @@ export const en: typeof zhCN = { verificationUnavailable: 'Verification is not supported for this channel', passed: 'Check passed', failed: 'Check failed · {{reason}}', + failedPlain: 'Check failed', + failureKeepsEnabled: + 'A failed check does not turn this service off. Requests still use the first enabled service and do not switch to the next one.', + reverify: 'Check again', elapsed: 'Took {{ms}} ms', staleResult: 'Result is over 24 hours old', connectionTitle: 'Service connection', @@ -2203,8 +2247,9 @@ export const en: typeof zhCN = { omni: 'Multimodal', models: 'Local models', connections: 'Connections', - statusConfigured: 'Configured', - statusMissing: 'Not configured', + statusConfigured: + 'Green dot: at least one service is on. Requests use the first enabled service.', + statusMissing: 'Red dot: no service is turned on yet.', }, searchPlaceholder: 'Find a settings category…', clearSearch: 'Clear search', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 2506ea79..5999b71b 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -26,6 +26,21 @@ export const es: typeof zhCN = { title: 'Sincronización cifrada en la nube', description: 'Cifra en este dispositivo antes de sincronizar con tu cuenta de GitHub.', enable: 'Activar sincronización cifrada', + setPassword: 'Crear contraseña de sincronización', + stepEnableTitle: 'Paso 1 de 3: activar la sincronización', + stepEnableDetail: 'Usa el interruptor. Tras confirmar, el siguiente paso es la contraseña.', + stepPasswordTitle: 'Paso 2 de 3: crear la contraseña', + stepPasswordDetail: 'Falta un paso. La sincronización empieza solo después de la contraseña.', + stepUnlockTitle: 'Paso 2 de 3: desbloquear este dispositivo', + stepUnlockDetail: 'Falta un paso. Introduce la contraseña para desbloquear este dispositivo.', + stepClosedTitle: 'Sincronización desactivada', + stepClosedDetail: 'La copia en la nube sigue ahí. Activa el interruptor para continuar.', + stepDoneTitle: 'Paso 3 de 3: sincronización lista', + stepDoneDetail: 'Este dispositivo está desbloqueado y la sincronización está activa.', + stepPreparingTitle: 'Abriendo el siguiente paso', + stepPreparingDetail: + 'El aviso está confirmado. Ahora vas a crear la contraseña de sincronización.', + stepFollowDetail: 'Usa el botón de esta fila para continuar.', refresh: 'Actualizar estado', loading: 'Comprobando la sincronización…', signIn: 'Iniciar sesión con GitHub', @@ -169,7 +184,7 @@ export const es: typeof zhCN = { choicesRequired: 'Elige local o nube para cada conflicto.', invalidData: 'No se pudo verificar la copia cifrada. No se reemplazaron datos locales.', localData: - 'No se pudieron preparar los datos locales de forma segura. Actualiza y vuelve a intentarlo.', + 'No se pudieron preparar los datos de este dispositivo. Actualiza el estado y vuelve a intentarlo.', }, }, cloudSync: { @@ -351,22 +366,13 @@ export const es: typeof zhCN = { agents: 'Tus agentes', configured: 'Agente configurado', agentSettings: 'Elegir en Ajustes', - historyUnavailable: 'Historial · no disponible', sessionUnavailable: 'Nueva sesión · no disponible', - agentHint: 'Elige tu agente en Ajustes.', - workspace: 'Espacio de trabajo', - multitask: 'Varias tareas', - unavailable: 'Aún no disponible', - historyHint: - 'El historial y las sesiones múltiples aún no están disponibles para estos cuatro agentes.', signedIn: 'Sesión iniciada', signIn: 'Iniciar sesión', currentSession: 'Conversación actual', minimize: 'Minimizar', maximize: 'Maximizar / restaurar', windowError: 'La acción de la ventana ha fallado. Inténtalo de nuevo.', - emptyHint: 'Describe una tarea o usa tu atajo de voz configurado para empezar.', - voiceHint: 'Mantén pulsado tu atajo de voz', idle: 'Esperando una tarea', waitingApproval: 'Esperando aprobación', submittingApproval: 'Enviando…', @@ -374,18 +380,45 @@ export const es: typeof zhCN = { approvalExpired: 'Este turno ha terminado. Ya no se puede aprobar.', sendError: 'No se pudo enviar. Tu texto sigue aquí. Inténtalo de nuevo.', browserUnavailable: 'Esta vista previa no ejecuta comandos. Usa la aplicación de escritorio.', - cancelHint: 'Cancela una tarea en curso desde la cápsula.', inputHint: 'Intro para enviar · Mayús + Intro para una nueva línea', - activity: 'Actividad de la tarea', - noActivity: 'Aún no hay actividad', - activityHint: - 'Las llamadas a herramientas y las aprobaciones aparecerán al iniciar una tarea.', - currentTask: 'Tarea actual', - toolActive: 'Llamada en curso', - toolReported: 'Llamada recibida', - approval: 'Aprobación de acción', apiCost: 'Coste de API de este turno', - activitySource: 'Sigue la tarea actual. El coste solo aparece si el servicio lo comunica.', + emptyHint: 'Describe una tarea, toca el micrófono o usa tu atajo de voz para empezar.', + showInspector: 'Mostrar panel de trabajo', + hideInspector: 'Ocultar panel de trabajo', + inspectorTitle: 'Espacio de trabajo', + inspectorStatus: 'Estado', + inspectorTurn: 'Este turno', + toolCalls: 'Llamadas a herramientas', + pendingApprovals: 'Esperando tu respuesta', + inspectorVoice: 'Entrada de voz', + voiceShortcutOff: 'No hay atajo de voz. Puedes activar uno en Ajustes.', + voiceModesHint: + 'Micrófono: el texto va al campo de entrada para que lo edites antes de enviar. Botón de voz: se envía directamente al agente al detenerte.', + copy: 'Copiar', + copied: 'Copiado', + messagePlaceholder: 'Mensaje para {{agent}}', + }, + voice: { + dictate: 'Dictar en el campo de entrada', + voiceMode: 'Modo de voz (envía al detenerte)', + stopTask: 'Detener tarea', + cancel: 'Cancelar grabación', + confirmDictation: 'Terminar dictado', + stopAndSend: 'Detener y enviar', + listening: 'Escuchando…', + starting: 'Preparando el micrófono…', + transcribing: 'Transcribiendo…', + empty: 'No se oyó nada. Inténtalo de nuevo.', + failed: 'Falló el reconocimiento de voz. Inténtalo de nuevo.', + startFailed: 'No se pudo empezar a grabar: {{message}}', + taskCancelFailed: 'La tarea no se detuvo. Inténtalo de nuevo.', + dictateCaption: + 'Toca ✓ al terminar: el texto irá al campo de entrada para editarlo · Esc para cancelar', + submitCaption: 'Toca ↑ para enviarlo directamente al agente · Esc para cancelar', + stopHint: 'Tarea en curso: toca ■ para detenerla', + holdHint: 'Mantén {{key}} para hablar', + toggleHint: 'Pulsa {{key}} para empezar o terminar', + autoHint: 'Mantén o toca {{key}} para hablar', }, title: 'Less Computer', subtitle: '¿Qué quieres que haga tu ordenador?', @@ -821,6 +854,9 @@ export const es: typeof zhCN = { }, vocab: { selectAllVisible: 'Seleccionar resultados actuales', + selecting: 'Seleccionar', + doneSelecting: 'Listo', + disabledWord: 'Desactivado', selectedCount: '{{count}} palabras seleccionadas', selectWord: 'Seleccionar «{{phrase}}»', deleteSelected: 'Eliminar seleccionadas ({{count}})', @@ -1022,6 +1058,7 @@ export const es: typeof zhCN = { deleteImported: 'Eliminar', deleteConfirm: '¿Eliminar «{{name}}»? Esta acción no se puede deshacer.', deleteSuccess: 'Se ha eliminado «{{name}}».', + undoDelete: 'Deshacer', deleteFailed: 'No se pudo eliminar el paquete: {{err}}', summaryCurrentEmpty: 'Todavía no hay ningún paquete seleccionado', editorTitle: 'Editar paquete', @@ -1502,6 +1539,10 @@ export const es: typeof zhCN = { verificationUnavailable: 'La comprobación no está disponible para este canal', passed: 'Comprobación correcta', failed: 'Comprobación fallida · {{reason}}', + failedPlain: 'Comprobación fallida', + failureKeepsEnabled: + 'Un fallo no desactiva este servicio. Las solicitudes siguen usando el primer servicio activado y no cambian solas al siguiente.', + reverify: 'Comprobar de nuevo', elapsed: 'Duración: {{ms}} ms', staleResult: 'El resultado tiene más de 24 horas', connectionTitle: 'Conexión al servicio', @@ -2250,8 +2291,9 @@ export const es: typeof zhCN = { omni: 'Multimodal', models: 'Modelos locales', connections: 'Conexiones', - statusConfigured: 'Configurado', - statusMissing: 'Sin configurar', + statusConfigured: + 'Punto verde: hay al menos un servicio activado. Las solicitudes usan el primero.', + statusMissing: 'Punto rojo: todavía no hay ningún servicio activado.', }, searchPlaceholder: 'Buscar una categoría de ajustes…', clearSearch: 'Borrar búsqueda', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 0d75035c..0a0a571b 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -26,6 +26,23 @@ export const fr: typeof zhCN = { title: 'Synchronisation cloud chiffrée', description: 'Chiffrez sur cet appareil avant de synchroniser avec votre compte GitHub.', enable: 'Activer la synchronisation chiffrée', + setPassword: 'Définir le mot de passe de sync', + stepEnableTitle: 'Étape 1 sur 3 : activer la synchronisation', + stepEnableDetail: + 'Utilisez l’interrupteur. Après confirmation, l’étape suivante est le mot de passe.', + stepPasswordTitle: 'Étape 2 sur 3 : définir le mot de passe', + stepPasswordDetail: + 'Il reste une étape. La synchronisation ne démarre qu’après le mot de passe.', + stepUnlockTitle: 'Étape 2 sur 3 : déverrouiller cet appareil', + stepUnlockDetail: + 'Il reste une étape. Saisissez le mot de passe pour déverrouiller cet appareil.', + stepClosedTitle: 'Synchronisation désactivée', + stepClosedDetail: 'La sauvegarde cloud est conservée. Activez l’interrupteur pour continuer.', + stepDoneTitle: 'Étape 3 sur 3 : synchronisation prête', + stepDoneDetail: 'Cet appareil est déverrouillé et la synchronisation est activée.', + stepPreparingTitle: 'Ouverture de l’étape suivante', + stepPreparingDetail: 'L’avis est confirmé. Vous allez ensuite définir le mot de passe de sync.', + stepFollowDetail: 'Utilisez le bouton sur cette ligne pour continuer.', refresh: 'Actualiser l’état', loading: 'Vérification de la synchronisation…', signIn: 'Se connecter avec GitHub', @@ -172,7 +189,8 @@ export const fr: typeof zhCN = { choicesRequired: 'Choisissez local ou cloud pour chaque conflit.', invalidData: 'Sauvegarde chiffrée invérifiable. Les données locales n’ont pas été remplacées.', - localData: 'Impossible de préparer les données locales en sécurité. Actualisez et réessayez.', + localData: + 'Impossible de préparer les données de cet appareil. Actualisez l’état, puis réessayez.', }, }, cloudSync: { @@ -354,22 +372,13 @@ export const fr: typeof zhCN = { agents: 'Vos agents', configured: 'Agent configuré', agentSettings: 'Choisir dans Réglages', - historyUnavailable: 'Historique · indisponible', sessionUnavailable: 'Nouvelle session · indisponible', - agentHint: 'Choisissez votre agent dans les réglages.', - workspace: 'Espace de travail', - multitask: 'Plusieurs tâches', - unavailable: 'Pas encore disponible', - historyHint: - 'L’historique et les sessions multiples ne sont pas encore disponibles pour ces quatre agents.', signedIn: 'Connecté', signIn: 'Se connecter', currentSession: 'Conversation actuelle', minimize: 'Réduire', maximize: 'Agrandir / restaurer', windowError: 'L’action sur la fenêtre a échoué. Réessayez.', - emptyHint: 'Décrivez une tâche ou utilisez votre raccourci vocal configuré pour commencer.', - voiceHint: 'Maintenez votre raccourci vocal', idle: 'En attente d’une tâche', waitingApproval: 'En attente d’approbation', submittingApproval: 'Envoi en cours…', @@ -378,19 +387,47 @@ export const fr: typeof zhCN = { sendError: 'Échec de l’envoi. Votre saisie est conservée. Réessayez.', browserUnavailable: 'Cet aperçu dans le navigateur n’exécute aucune commande. Utilisez l’application de bureau.', - cancelHint: 'Annulez une tâche en cours depuis la capsule.', inputHint: 'Entrée pour envoyer · Maj + Entrée pour une nouvelle ligne', - activity: 'Activité de la tâche', - noActivity: 'Aucune activité pour l’instant', - activityHint: - 'Les appels d’outils et les approbations apparaîtront ici au début d’une tâche.', - currentTask: 'Tâche actuelle', - toolActive: 'Appel en cours', - toolReported: 'Appel reçu', - approval: 'Approbation d’action', apiCost: 'Coût API de ce tour', - activitySource: - 'Suit la tâche actuelle. Le coût s’affiche uniquement lorsque le service le communique.', + emptyHint: + 'Décrivez une tâche, touchez le micro ou utilisez votre raccourci vocal pour commencer.', + showInspector: 'Afficher le panneau de travail', + hideInspector: 'Masquer le panneau de travail', + inspectorTitle: 'Espace de travail', + inspectorStatus: 'État', + inspectorTurn: 'Ce tour', + toolCalls: 'Appels d’outils', + pendingApprovals: 'En attente de vous', + inspectorVoice: 'Saisie vocale', + voiceShortcutOff: + 'Aucun raccourci vocal défini. Vous pouvez en activer un dans les réglages.', + voiceModesHint: + 'Micro : le texte arrive dans la zone de saisie pour être modifié avant l’envoi. Bouton vocal : envoie directement à l’agent quand vous arrêtez.', + copy: 'Copier', + copied: 'Copié', + messagePlaceholder: 'Message à {{agent}}', + }, + voice: { + dictate: 'Dicter dans la zone de saisie', + voiceMode: 'Mode vocal (envoi à l’arrêt)', + stopTask: 'Arrêter la tâche', + cancel: 'Annuler l’enregistrement', + confirmDictation: 'Terminer la dictée', + stopAndSend: 'Arrêter et envoyer', + listening: 'À l’écoute…', + starting: 'Préparation du micro…', + transcribing: 'Transcription…', + empty: 'Rien n’a été entendu. Réessayez.', + failed: 'La reconnaissance vocale a échoué. Réessayez.', + startFailed: 'Impossible de démarrer l’enregistrement : {{message}}', + taskCancelFailed: 'La tâche ne s’est pas arrêtée. Réessayez.', + dictateCaption: + 'Touchez ✓ une fois terminé : le texte arrive dans la zone de saisie · Échap pour annuler', + submitCaption: 'Touchez ↑ pour envoyer directement à l’agent · Échap pour annuler', + stopHint: 'Tâche en cours : touchez ■ pour l’arrêter', + holdHint: 'Maintenez {{key}} pour parler', + toggleHint: 'Appuyez sur {{key}} pour commencer ou arrêter', + autoHint: 'Maintenez ou touchez {{key}} pour parler', }, title: 'Less Computer', subtitle: 'Que doit faire votre ordinateur ?', @@ -832,6 +869,9 @@ export const fr: typeof zhCN = { }, vocab: { selectAllVisible: 'Sélectionner les résultats actuels', + selecting: 'Sélectionner', + doneSelecting: 'Terminé', + disabledWord: 'Désactivé', selectedCount: '{{count}} mots sélectionnés', selectWord: 'Sélectionner « {{phrase}} »', deleteSelected: 'Supprimer la sélection ({{count}})', @@ -1034,6 +1074,7 @@ export const fr: typeof zhCN = { deleteImported: 'Supprimer', deleteConfirm: 'Supprimer « {{name}} » ? Cette action est irréversible.', deleteSuccess: '« {{name}} » supprimé.', + undoDelete: 'Annuler', deleteFailed: 'Impossible de supprimer le pack : {{err}}', summaryCurrentEmpty: 'Aucun pack sélectionné', editorTitle: 'Modifier le pack', @@ -1520,6 +1561,10 @@ export const fr: typeof zhCN = { verificationUnavailable: 'La vérification n’est pas disponible pour ce canal', passed: 'Vérification réussie', failed: 'Échec de la vérification · {{reason}}', + failedPlain: 'Échec de la vérification', + failureKeepsEnabled: + 'Un échec ne désactive pas ce service. Les requêtes utilisent le premier service activé et ne passent pas automatiquement au suivant.', + reverify: 'Vérifier à nouveau', elapsed: 'Durée : {{ms}} ms', staleResult: 'Le résultat date de plus de 24 heures', connectionTitle: 'Connexion au service', @@ -2279,8 +2324,9 @@ export const fr: typeof zhCN = { omni: 'Multimodal', models: 'Modèles locaux', connections: 'Connexions', - statusConfigured: 'Configuré', - statusMissing: 'Non configuré', + statusConfigured: + 'Point vert : au moins un service est activé. Les requêtes utilisent le premier service activé.', + statusMissing: 'Point rouge : aucun service n’est activé.', }, searchPlaceholder: 'Rechercher une catégorie de réglages…', clearSearch: 'Effacer la recherche', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index ebde5c3a..a5fb4476 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -31,6 +31,22 @@ export const ja: typeof zhCN = { title: '暗号化クラウド同期', description: 'この端末で暗号化してから、GitHub アカウントで端末間を同期します。', enable: '暗号化同期を有効にする', + setPassword: '同期パスワードを設定', + stepEnableTitle: '手順 1/3:同期をオンにする', + stepEnableDetail: 'スイッチを使います。確認のあと、次は同期パスワードの設定です。', + stepPasswordTitle: '手順 2/3:同期パスワードを設定', + stepPasswordDetail: 'あと 1 ステップです。パスワードを設定するまで同期は始まりません。', + stepUnlockTitle: '手順 2/3:このデバイスのロックを解除', + stepUnlockDetail: + 'あと 1 ステップです。同期パスワードを入力して、このデバイスのロックを解除します。', + stepClosedTitle: '同期はオフです', + stepClosedDetail: + 'クラウドのバックアップは残っています。続けるには上のスイッチをオンにしてください。', + stepDoneTitle: '手順 3/3:同期の準備ができました', + stepDoneDetail: 'このデバイスはロック解除済みで、同期はオンです。', + stepPreparingTitle: '次の手順を開いています', + stepPreparingDetail: '確認は済みました。次に同期パスワードを設定します。まだ完了していません。', + stepFollowDetail: 'この行のボタンで続けてください。', refresh: '状態を更新', loading: '同期状態を確認中…', signIn: 'GitHub でログイン', @@ -172,7 +188,8 @@ export const ja: typeof zhCN = { reviewRequired: '続ける前に同期対象と復元プレビューを確認してください。', choicesRequired: 'すべての競合でローカルかクラウドを選択してください。', invalidData: '暗号化バックアップを検証できませんでした。ローカルデータは置き換えていません。', - localData: 'ローカルデータを安全に準備できませんでした。更新して再試行してください。', + localData: + 'このデバイスのデータを準備できませんでした。「状態を更新」を押して、もう一度試してください。', }, }, cloudSync: { @@ -340,21 +357,13 @@ export const ja: typeof zhCN = { agents: 'エージェント', configured: '現在の設定', agentSettings: '設定で選択', - historyUnavailable: '履歴 · 未対応', sessionUnavailable: '新しい会話 · 未対応', - agentHint: '使用するエージェントは設定で選択します。', - workspace: 'ワークスペース', - multitask: '複数のタスク', - unavailable: 'まだ利用できません', - historyHint: '4つのエージェントの履歴と複数会話には、まだ対応していません。', signedIn: 'ログイン済み', signIn: 'ログイン', currentSession: '現在の会話', minimize: '最小化', maximize: '最大化 / 元に戻す', windowError: 'ウィンドウ操作に失敗しました。再試行してください。', - emptyHint: '具体的なタスクを入力するか、設定した音声ショートカットで始めましょう。', - voiceHint: '音声ショートカットを長押し', idle: '指示を待っています', waitingApproval: '承認待ち', submittingApproval: '送信中…', @@ -363,18 +372,45 @@ export const ja: typeof zhCN = { sendError: '送信できませんでした。入力は保持されています。再試行してください。', browserUnavailable: 'ブラウザのプレビューでは実行できません。デスクトップアプリをご利用ください。', - cancelHint: '実行中のタスクはカプセルからキャンセルできます。', inputHint: 'Enter で送信 · Shift + Enter で改行', - activity: 'タスクの動き', - noActivity: 'まだ動きはありません', - activityHint: 'タスクが始まると、ツール呼び出しと承認がここに表示されます。', - currentTask: '現在のタスク', - toolActive: '呼び出し中', - toolReported: '呼び出しを受信', - approval: '操作の承認', apiCost: '今回の API 料金', - activitySource: - '現在のタスクに合わせて更新。料金はサービスから報告された場合のみ表示します。', + emptyHint: 'タスクを入力するか、マイクをタップするか、音声ショートカットで始めましょう。', + showInspector: 'ワークスペースを表示', + hideInspector: 'ワークスペースを隠す', + inspectorTitle: 'ワークスペース', + inspectorStatus: 'ステータス', + inspectorTurn: 'このターン', + toolCalls: 'ツール呼び出し', + pendingApprovals: '確認待ち', + inspectorVoice: '音声入力', + voiceShortcutOff: '音声ショートカットが未設定です。設定で有効にできます。', + voiceModesHint: + 'マイク:話した内容が入力欄に入り、編集してから送信できます。音声ボタン:話し終えるとそのままエージェントに送信します。', + copy: 'コピー', + copied: 'コピーしました', + messagePlaceholder: '{{agent}} にメッセージ', + }, + voice: { + dictate: '入力欄に音声入力', + voiceMode: '音声モード(停止で送信)', + stopTask: 'タスクを停止', + cancel: '録音をキャンセル', + confirmDictation: '音声入力を完了', + stopAndSend: '停止して送信', + listening: '聞き取り中…', + starting: 'マイクを準備中…', + transcribing: '文字起こし中…', + empty: '聞き取れませんでした。もう一度お話しください。', + failed: '音声認識に失敗しました。もう一度お試しください。', + startFailed: '録音を開始できません:{{message}}', + taskCancelFailed: 'タスクを停止できませんでした。もう一度お試しください。', + dictateCaption: + '話し終えたら ✓ をタップ。テキストは入力欄に入り編集できます · Esc でキャンセル', + submitCaption: '↑ をタップするとそのままエージェントに送信 · Esc でキャンセル', + stopHint: 'タスク実行中:■ で停止できます', + holdHint: '{{key}} を押している間話す', + toggleHint: '{{key}} を押して開始・終了', + autoHint: '{{key}} を長押しまたはタップして話す', }, title: 'Less Computer', subtitle: 'コンピュータに何をさせますか?', @@ -799,6 +835,9 @@ export const ja: typeof zhCN = { }, vocab: { selectAllVisible: '現在の結果を選択', + selecting: '選択', + doneSelecting: '完了', + disabledWord: '停止中', selectedCount: '{{count}} 語を選択中', selectWord: '「{{phrase}}」を選択', deleteSelected: '選択項目を削除({{count}})', @@ -995,6 +1034,7 @@ export const ja: typeof zhCN = { deleteImported: '削除', deleteConfirm: '"{{name}}" を削除しますか?この操作は取り消せません。', deleteSuccess: '"{{name}}" を削除しました', + undoDelete: '元に戻す', deleteFailed: 'パック削除失敗:{{err}}', summaryCurrentEmpty: 'まだパックが選択されていません', editorTitle: 'パック編集', @@ -1464,6 +1504,10 @@ export const ja: typeof zhCN = { verificationUnavailable: 'このチャンネルは確認に対応していません', passed: '確認に成功', failed: '確認に失敗 · {{reason}}', + failedPlain: '確認に失敗', + failureKeepsEnabled: + '確認に失敗しても、このサービスは自動では停止しません。リクエストは有効な一覧の先頭を使い、次へは自動で切り替わりません。', + reverify: '再確認', elapsed: '所要時間 {{ms}} ms', staleResult: '24 時間以上前の結果', connectionTitle: 'サービス接続', @@ -2175,8 +2219,9 @@ export const ja: typeof zhCN = { omni: 'マルチモーダル', models: 'ローカルモデル', connections: '接続と拡張', - statusConfigured: '設定済み', - statusMissing: '未設定', + statusConfigured: + '緑の点:有効なサービスがあります。リクエストは有効な一覧の先頭を使います。', + statusMissing: '赤の点:まだ有効なサービスがありません。', }, searchPlaceholder: '設定カテゴリを検索…', clearSearch: '検索をクリア', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index ed4747bd..12f88579 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -31,6 +31,21 @@ export const ko: typeof zhCN = { title: '암호화 클라우드 동기화', description: '이 기기에서 암호화한 후 GitHub 계정으로 기기 간에 동기화합니다.', enable: '암호화 동기화 사용', + setPassword: '동기화 비밀번호 설정', + stepEnableTitle: '1/3단계: 동기화 켜기', + stepEnableDetail: '스위치를 사용하세요. 확인 후 다음 단계는 동기화 비밀번호입니다.', + stepPasswordTitle: '2/3단계: 동기화 비밀번호 설정', + stepPasswordDetail: '한 단계 남았습니다. 비밀번호를 설정해야 동기화가 시작됩니다.', + stepUnlockTitle: '2/3단계: 이 기기 잠금 해제', + stepUnlockDetail: '한 단계 남았습니다. 동기화 비밀번호로 이 기기의 잠금을 해제하세요.', + stepClosedTitle: '동기화가 꺼져 있음', + stepClosedDetail: '클라우드 백업은 그대로입니다. 계속하려면 위 스위치를 켜세요.', + stepDoneTitle: '3/3단계: 동기화 준비됨', + stepDoneDetail: '이 기기는 잠금 해제되었고 동기화가 켜져 있습니다.', + stepPreparingTitle: '다음 단계를 여는 중', + stepPreparingDetail: + '확인을 마쳤습니다. 이제 동기화 비밀번호를 설정합니다. 아직 끝나지 않았습니다.', + stepFollowDetail: '이 줄의 버튼으로 계속하세요.', refresh: '상태 새로고침', loading: '동기화 상태 확인 중…', signIn: 'GitHub로 로그인', @@ -170,7 +185,7 @@ export const ko: typeof zhCN = { reviewRequired: '계속하기 전에 동기화 범위와 복원 미리보기를 확인하세요.', choicesRequired: '각 충돌에 대해 로컬 또는 클라우드를 선택하세요.', invalidData: '암호화 백업을 검증하지 못했습니다. 로컬 데이터는 교체되지 않았습니다.', - localData: '로컬 데이터를 안전하게 준비하지 못했습니다. 새로고침한 후 다시 시도하세요.', + localData: '이 기기의 데이터를 준비하지 못했습니다. 상태 새로고침을 누른 뒤 다시 시도하세요.', }, }, cloudSync: { @@ -336,21 +351,13 @@ export const ko: typeof zhCN = { agents: '내 에이전트', configured: '현재 설정', agentSettings: '설정에서 선택', - historyUnavailable: '기록 · 아직 지원 안 함', sessionUnavailable: '새 대화 · 아직 지원 안 함', - agentHint: '사용할 에이전트는 설정에서 선택하세요.', - workspace: '작업 공간', - multitask: '여러 작업', - unavailable: '아직 사용할 수 없음', - historyHint: '네 에이전트의 기록과 여러 대화 기능은 아직 지원하지 않습니다.', signedIn: '로그인됨', signIn: '로그인', currentSession: '현재 대화', minimize: '최소화', maximize: '최대화 / 복원', windowError: '창 작업에 실패했습니다. 다시 시도하세요.', - emptyHint: '구체적인 작업을 입력하거나 설정한 음성 단축키로 시작하세요.', - voiceHint: '음성 단축키를 길게 누르세요', idle: '지시 대기 중', waitingApproval: '승인 대기 중', submittingApproval: '제출 중…', @@ -359,17 +366,45 @@ export const ko: typeof zhCN = { sendError: '전송하지 못했습니다. 입력 내용은 유지됩니다. 다시 시도하세요.', browserUnavailable: '브라우저 미리보기에서는 명령을 실행할 수 없습니다. 데스크톱 앱을 사용하세요.', - cancelHint: '실행 중인 작업은 캡슐에서 취소할 수 있습니다.', inputHint: 'Enter로 전송 · Shift + Enter로 줄 바꿈', - activity: '작업 활동', - noActivity: '아직 활동이 없습니다', - activityHint: '작업이 시작되면 도구 호출과 승인이 여기에 표시됩니다.', - currentTask: '현재 작업', - toolActive: '호출 중', - toolReported: '호출 기록 수신', - approval: '작업 승인', apiCost: '이번 API 비용', - activitySource: '현재 작업과 함께 업데이트됩니다. 비용은 서비스가 보고할 때만 표시됩니다.', + emptyHint: '작업을 입력하거나, 마이크를 누르거나, 음성 단축키로 시작하세요.', + showInspector: '작업 패널 보기', + hideInspector: '작업 패널 숨기기', + inspectorTitle: '작업 공간', + inspectorStatus: '상태', + inspectorTurn: '이번 턴', + toolCalls: '도구 호출', + pendingApprovals: '확인 대기', + inspectorVoice: '음성 입력', + voiceShortcutOff: '음성 단축키가 설정되지 않았습니다. 설정에서 켤 수 있습니다.', + voiceModesHint: + '마이크: 말한 내용이 입력창에 들어가 수정한 뒤 보낼 수 있습니다. 음성 버튼: 말을 멈추면 바로 에이전트에 보냅니다.', + copy: '복사', + copied: '복사됨', + messagePlaceholder: '{{agent}}에게 메시지', + }, + voice: { + dictate: '입력창에 받아쓰기', + voiceMode: '음성 모드(멈추면 전송)', + stopTask: '작업 중지', + cancel: '녹음 취소', + confirmDictation: '받아쓰기 완료', + stopAndSend: '멈추고 보내기', + listening: '듣는 중…', + starting: '마이크 준비 중…', + transcribing: '받아쓰는 중…', + empty: '들린 내용이 없습니다. 다시 말씀해 주세요.', + failed: '음성 인식에 실패했습니다. 다시 시도해 주세요.', + startFailed: '녹음을 시작할 수 없습니다: {{message}}', + taskCancelFailed: '작업이 중지되지 않았습니다. 다시 시도해 주세요.', + dictateCaption: + '다 말했으면 ✓를 누르세요. 텍스트가 입력창에 들어가 수정할 수 있습니다 · Esc로 취소', + submitCaption: '↑를 누르면 바로 에이전트에 보냅니다 · Esc로 취소', + stopHint: '작업 실행 중: ■를 눌러 중지', + holdHint: '{{key}}를 누른 채 말하기', + toggleHint: '{{key}}를 눌러 시작 또는 종료', + autoHint: '{{key}}를 길게 또는 짧게 눌러 말하기', }, title: 'Less Computer', subtitle: '컴퓨터로 무엇을 할까요?', @@ -794,6 +829,9 @@ export const ko: typeof zhCN = { }, vocab: { selectAllVisible: '현재 결과 선택', + selecting: '선택', + doneSelecting: '완료', + disabledWord: '꺼짐', selectedCount: '단어 {{count}}개 선택됨', selectWord: '“{{phrase}}” 선택', deleteSelected: '선택 항목 삭제({{count}})', @@ -990,6 +1028,7 @@ export const ko: typeof zhCN = { deleteImported: '삭제', deleteConfirm: '"{{name}}"을(를) 삭제할까요? 되돌릴 수 없습니다.', deleteSuccess: '"{{name}}"을(를) 삭제했습니다', + undoDelete: '실행 취소', deleteFailed: '팩 삭제 실패: {{err}}', summaryCurrentEmpty: '아직 팩이 선택되지 않았습니다', editorTitle: '팩 편집', @@ -1453,6 +1492,10 @@ export const ko: typeof zhCN = { verificationUnavailable: '이 채널은 확인을 지원하지 않습니다', passed: '확인 성공', failed: '확인 실패 · {{reason}}', + failedPlain: '확인 실패', + failureKeepsEnabled: + '확인에 실패해도 이 서비스는 자동으로 꺼지지 않습니다. 요청은 켜진 목록의 첫 항목을 쓰며, 다음 항목으로 자동 전환되지 않습니다.', + reverify: '다시 확인', elapsed: '소요 시간 {{ms}} ms', staleResult: '24시간이 지난 결과', connectionTitle: '서비스 연결', @@ -2158,8 +2201,8 @@ export const ko: typeof zhCN = { omni: '멀티모달', models: '로컬 모델', connections: '연결 및 확장', - statusConfigured: '설정됨', - statusMissing: '미설정', + statusConfigured: '초록 점: 켜진 서비스가 있습니다. 요청은 켜진 목록의 첫 항목을 사용합니다.', + statusMissing: '빨간 점: 아직 켜진 서비스가 없습니다.', }, searchPlaceholder: '설정 카테고리 찾기…', clearSearch: '검색 지우기', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 264e8702..af66036d 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -26,6 +26,20 @@ export const zhCN = { title: '加密云同步', description: '在本机加密后,通过 GitHub 账号跨设备同步。', enable: '启用加密同步', + setPassword: '设置同步密码', + stepEnableTitle: '第 1 步,共 3 步:开启同步', + stepEnableDetail: '打开右侧开关。确认协议之后,下一步是设置同步密码。', + stepPasswordTitle: '第 2 步,共 3 步:设置同步密码', + stepPasswordDetail: '还差一步。设好同步密码之后,同步才会开始。现在还没有完成。', + stepUnlockTitle: '第 2 步,共 3 步:解锁这台设备', + stepUnlockDetail: '还差一步。输入同步密码,解锁之后才能同步。', + stepClosedTitle: '同步已关闭', + stepClosedDetail: '云端备份还在。要继续同步,打开上方的开关。', + stepDoneTitle: '第 3 步,共 3 步:同步已就绪', + stepDoneDetail: '这台设备已解锁,同步已经开启。', + stepPreparingTitle: '正在进入下一步', + stepPreparingDetail: '协议已经确认。接下来会请你设置同步密码,这一步还没完成。', + stepFollowDetail: '按这一行旁边的按钮继续。', refresh: '刷新状态', loading: '正在读取同步状态…', signIn: '使用 GitHub 登录', @@ -155,7 +169,7 @@ export const zhCN = { reviewRequired: '请先确认完整同步范围,并审阅云端恢复预览。', choicesRequired: '请为每项冲突选择本机或云端版本。', invalidData: '加密备份未通过校验,本机资料未被替换。', - localData: '无法安全准备本机资料,请刷新后重试。', + localData: '这台设备还没准备好本机资料。点「刷新状态」,然后再试一次。', }, }, cloudSync: { @@ -327,21 +341,13 @@ export const zhCN = { agents: '你的 Agents', configured: '当前配置', agentSettings: '在设置中选择', - historyUnavailable: '历史记录 · 暂不可用', sessionUnavailable: '新会话 · 暂不可用', - agentHint: '在设置中选择使用的 Agent。', - workspace: '工作区', - multitask: '多任务', - unavailable: '暂不可用', - historyHint: '四个 Agent 的历史与多会话功能尚未开放。', signedIn: '已登录', signIn: '登录账号', currentSession: '当前会话', minimize: '最小化', maximize: '最大化 / 还原', windowError: '窗口操作未完成,请重试。', - emptyHint: '写下具体任务,或用已配置的说话快捷键,让想法开始行动。', - voiceHint: '按住说话快捷键', idle: '等待指令', waitingApproval: '等待你的确认', submittingApproval: '正在提交…', @@ -349,17 +355,43 @@ export const zhCN = { approvalExpired: '此轮已结束,审批已不可用。', sendError: '发送失败,输入已保留。请重试。', browserUnavailable: '浏览器预览不会执行指令,请在桌面应用中使用。', - cancelHint: '可从胶囊取消正在运行的任务。', inputHint: 'Enter 发送 · Shift + Enter 换行', - activity: '任务动态', - noActivity: '尚无任务活动', - activityHint: '任务开始后,工具调用与审批会显示在这里。', - currentTask: '当前任务', - toolActive: '调用中', - toolReported: '已收到调用记录', - approval: '操作审批', apiCost: '本轮 API 费用', - activitySource: '随当前任务更新。费用仅在服务返回时显示。', + emptyHint: '写下具体任务,点麦克风说出来,或用说话快捷键让想法开始行动。', + showInspector: '显示工作台', + hideInspector: '收起工作台', + inspectorTitle: '工作台', + inspectorStatus: '运行状态', + inspectorTurn: '本轮', + toolCalls: '工具调用', + pendingApprovals: '待确认', + inspectorVoice: '语音输入', + voiceShortcutOff: '未设置说话快捷键,可在设置中开启。', + voiceModesHint: '麦克风:说完先填进输入框,确认后再发送。语音按钮:说完直接交给 Agent。', + copy: '复制', + copied: '已复制', + messagePlaceholder: '给 {{agent}} 发消息', + }, + voice: { + dictate: '听写到输入框', + voiceMode: '语音模式(说完直接发送)', + stopTask: '停止任务', + cancel: '取消录音', + confirmDictation: '完成听写', + stopAndSend: '停止并发送', + listening: '正在聆听…', + starting: '正在准备麦克风…', + transcribing: '正在转写…', + empty: '没有听到内容,请再说一次。', + failed: '语音识别失败,请重试。', + startFailed: '无法开始录音:{{message}}', + taskCancelFailed: '任务未能停止,请重试。', + dictateCaption: '说完点 ✓,文字会填进输入框供你修改 · Esc 取消', + submitCaption: '说完点 ↑ 直接发送给 Agent · Esc 取消', + stopHint: '任务运行中,可点击 ■ 停止', + holdHint: '按住 {{key}} 说话', + toggleHint: '按 {{key}} 开始或结束说话', + autoHint: '按住或轻按 {{key}} 说话', }, title: 'Less Computer', subtitle: '想让电脑做什么?', @@ -775,6 +807,9 @@ export const zhCN = { }, vocab: { selectAllVisible: '选择当前结果', + selecting: '选择', + doneSelecting: '完成', + disabledWord: '已停用', selectedCount: '已选择 {{count}} 个词', selectWord: '选择「{{phrase}}」', deleteSelected: '删除已选({{count}})', @@ -964,6 +999,7 @@ export const zhCN = { deleteImported: '删除', deleteConfirm: '确定删除"{{name}}"吗?删除后无法恢复。', deleteSuccess: '已删除"{{name}}"', + undoDelete: '撤销', deleteFailed: '删除风格包失败:{{err}}', summaryCurrentEmpty: '还没有选中风格包', editorTitle: '编辑风格', @@ -1406,6 +1442,10 @@ export const zhCN = { verificationUnavailable: '此渠道暂不支持验证', passed: '验证通过', failed: '验证失败 · {{reason}}', + failedPlain: '验证失败', + failureKeepsEnabled: + '验证失败不会自动停用。请求仍使用列表中第一个已启用的渠道,不会自动改用下一个。', + reverify: '重新验证', elapsed: '耗时 {{ms}} ms', staleResult: '结果已超过 24 小时', connectionTitle: '服务连接', @@ -2085,8 +2125,8 @@ export const zhCN = { omni: '多模态模型', models: '本地模型', connections: '连接与扩展', - statusConfigured: '已配置', - statusMissing: '未配置', + statusConfigured: '绿点:已有启用的渠道。请求使用列表中第一个启用的渠道。', + statusMissing: '红点:还没有启用的渠道。', }, searchPlaceholder: '查找设置分类…', clearSearch: '清除搜索', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index ca1ade2d..13322256 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -26,6 +26,20 @@ export const zhTW: typeof zhCN = { title: '加密雲端同步', description: '在本機加密後,透過 GitHub 帳號跨裝置同步。', enable: '啟用加密同步', + setPassword: '設定同步密碼', + stepEnableTitle: '第 1 步,共 3 步:開啟同步', + stepEnableDetail: '打開右側開關。確認協議之後,下一步是設定同步密碼。', + stepPasswordTitle: '第 2 步,共 3 步:設定同步密碼', + stepPasswordDetail: '還差一步。設好同步密碼之後,同步才會開始。現在還沒有完成。', + stepUnlockTitle: '第 2 步,共 3 步:解鎖這台裝置', + stepUnlockDetail: '還差一步。輸入同步密碼,解鎖之後才能同步。', + stepClosedTitle: '同步已關閉', + stepClosedDetail: '雲端備份還在。要繼續同步,打開上方的開關。', + stepDoneTitle: '第 3 步,共 3 步:同步已就緒', + stepDoneDetail: '這台裝置已解鎖,同步已經開啟。', + stepPreparingTitle: '正在進入下一步', + stepPreparingDetail: '協議已經確認。接下來會請你設定同步密碼,這一步還沒完成。', + stepFollowDetail: '按這一列旁邊的按鈕繼續。', refresh: '重新整理狀態', loading: '正在讀取同步狀態…', signIn: '使用 GitHub 登入', @@ -155,7 +169,7 @@ export const zhTW: typeof zhCN = { reviewRequired: '請先確認完整同步範圍,並檢閱雲端還原預覽。', choicesRequired: '請為每項衝突選擇本機或雲端版本。', invalidData: '加密備份未通過驗證,本機資料未被取代。', - localData: '無法安全準備本機資料,請重新整理後重試。', + localData: '這台裝置還沒準備好本機資料。點「重新整理狀態」,然後再試一次。', }, }, cloudSync: { @@ -327,21 +341,13 @@ export const zhTW: typeof zhCN = { agents: '你的 Agents', configured: '目前設定', agentSettings: '在設定中選擇', - historyUnavailable: '歷史記錄 · 暫不可用', sessionUnavailable: '新對話 · 暫不可用', - agentHint: '在設定中選擇使用的 Agent。', - workspace: '工作區', - multitask: '多工', - unavailable: '暫不可用', - historyHint: '四個 Agent 的歷史與多對話功能尚未開放。', signedIn: '已登入', signIn: '登入帳號', currentSession: '目前對話', minimize: '最小化', maximize: '最大化 / 還原', windowError: '視窗操作未完成,請重試。', - emptyHint: '寫下具體任務,或用已設定的說話快捷鍵,讓想法開始行動。', - voiceHint: '按住說話快捷鍵', idle: '等待指令', waitingApproval: '等待你的確認', submittingApproval: '正在提交…', @@ -349,17 +355,43 @@ export const zhTW: typeof zhCN = { approvalExpired: '此輪已結束,審批已不可用。', sendError: '傳送失敗,輸入已保留。請重試。', browserUnavailable: '瀏覽器預覽不會執行指令,請在桌面應用程式中使用。', - cancelHint: '可從膠囊取消正在執行的任務。', inputHint: 'Enter 傳送 · Shift + Enter 換行', - activity: '任務動態', - noActivity: '尚無任務活動', - activityHint: '任務開始後,工具呼叫與審批會顯示在這裡。', - currentTask: '目前任務', - toolActive: '呼叫中', - toolReported: '已收到呼叫記錄', - approval: '操作審批', apiCost: '本輪 API 費用', - activitySource: '隨目前任務更新。費用僅在服務回傳時顯示。', + emptyHint: '寫下具體任務,點麥克風說出來,或用說話快捷鍵讓想法開始行動。', + showInspector: '顯示工作台', + hideInspector: '收起工作台', + inspectorTitle: '工作台', + inspectorStatus: '執行狀態', + inspectorTurn: '本輪', + toolCalls: '工具呼叫', + pendingApprovals: '待確認', + inspectorVoice: '語音輸入', + voiceShortcutOff: '尚未設定說話快捷鍵,可在設定中開啟。', + voiceModesHint: '麥克風:說完先填入輸入框,確認後再傳送。語音按鈕:說完直接交給 Agent。', + copy: '複製', + copied: '已複製', + messagePlaceholder: '傳訊息給 {{agent}}', + }, + voice: { + dictate: '聽寫到輸入框', + voiceMode: '語音模式(說完直接傳送)', + stopTask: '停止任務', + cancel: '取消錄音', + confirmDictation: '完成聽寫', + stopAndSend: '停止並傳送', + listening: '正在聆聽…', + starting: '正在準備麥克風…', + transcribing: '正在轉寫…', + empty: '沒有聽到內容,請再說一次。', + failed: '語音辨識失敗,請重試。', + startFailed: '無法開始錄音:{{message}}', + taskCancelFailed: '任務未能停止,請重試。', + dictateCaption: '說完點 ✓,文字會填入輸入框供你修改 · Esc 取消', + submitCaption: '說完點 ↑ 直接傳送給 Agent · Esc 取消', + stopHint: '任務執行中,可點擊 ■ 停止', + holdHint: '按住 {{key}} 說話', + toggleHint: '按 {{key}} 開始或結束說話', + autoHint: '按住或輕按 {{key}} 說話', }, title: 'Less Computer', subtitle: '想讓電腦做什麼?', @@ -775,6 +807,9 @@ export const zhTW: typeof zhCN = { }, vocab: { selectAllVisible: '選取目前結果', + selecting: '選取', + doneSelecting: '完成', + disabledWord: '已停用', selectedCount: '已選取 {{count}} 個詞', selectWord: '選取「{{phrase}}」', deleteSelected: '刪除已選({{count}})', @@ -966,6 +1001,7 @@ export const zhTW: typeof zhCN = { deleteImported: '刪除', deleteConfirm: '確定刪除"{{name}}"嗎?刪除後無法復原。', deleteSuccess: '已刪除"{{name}}"', + undoDelete: '復原', deleteFailed: '刪除風格包失敗:{{err}}', summaryCurrentEmpty: '還沒有選中風格包', editorTitle: '編輯風格', @@ -1407,6 +1443,10 @@ export const zhTW: typeof zhCN = { verificationUnavailable: '此渠道暫不支援驗證', passed: '驗證通過', failed: '驗證失敗 · {{reason}}', + failedPlain: '驗證失敗', + failureKeepsEnabled: + '驗證失敗不會自動停用。請求仍使用列表中第一個已啟用的渠道,不會自動改用下一個。', + reverify: '重新驗證', elapsed: '耗時 {{ms}} ms', staleResult: '結果已超過 24 小時', connectionTitle: '服務連線', @@ -2085,8 +2125,8 @@ export const zhTW: typeof zhCN = { omni: '多模態模型', models: '本機模型', connections: '連線與擴充', - statusConfigured: '已設定', - statusMissing: '未設定', + statusConfigured: '綠點:已有啟用的渠道。請求使用列表中第一個啟用的渠道。', + statusMissing: '紅點:還沒有啟用的渠道。', }, searchPlaceholder: '尋找設定分類…', clearSearch: '清除搜尋', diff --git a/openless-all/app/src/lib/ipc/index.ts b/openless-all/app/src/lib/ipc/index.ts index f2ef4c63..00ccae5b 100644 --- a/openless-all/app/src/lib/ipc/index.ts +++ b/openless-all/app/src/lib/ipc/index.ts @@ -190,6 +190,10 @@ export { lessComputerApprove, lessComputerSubmitText, lessComputerSync, + lessComputerVoiceStart, + lessComputerVoiceStop, + lessComputerVoiceCancel, + lessComputerTaskCancel, } from './less-computer'; // chat-panel(QA / Less Computer 共用) diff --git a/openless-all/app/src/lib/ipc/less-computer.ts b/openless-all/app/src/lib/ipc/less-computer.ts index c2874689..edca507b 100644 --- a/openless-all/app/src/lib/ipc/less-computer.ts +++ b/openless-all/app/src/lib/ipc/less-computer.ts @@ -1,5 +1,5 @@ import { invokeOrMock } from './shared'; -import type { LessComputerSyncResult } from '../types'; +import type { LessComputerSyncResult, LessComputerVoiceMode } from '../types'; /** 用户点 ✕ / 按 Esc 关闭 Less Computer 浮窗(隐藏窗口)。 */ export function lessComputerWindowDismiss(): Promise { @@ -21,6 +21,27 @@ export function lessComputerSubmitText(text: string): Promise { return invokeOrMock('less_computer_submit_text', { text }, () => undefined); } +/** 面板内开麦。dictate:转写只填进输入框;submit:说完直接交给 Agent(与快捷键一致)。 + * 启动失败(麦克风权限、其它语音会话占用)以 reject 返回,面板内联提示。 */ +export function lessComputerVoiceStart(mode: LessComputerVoiceMode): Promise { + return invokeOrMock('less_computer_voice_start', { mode }, () => undefined); +} + +/** 只结束指定录音并按其 mode 收尾;迟到请求不会停止后来开始的会话。 */ +export function lessComputerVoiceStop(sessionId: string): Promise { + return invokeOrMock('less_computer_voice_stop', { sessionId }, () => undefined); +} + +/** 只取消指定录音会话,不会波及其它会话或已在运行的任务。 */ +export function lessComputerVoiceCancel(sessionId: string): Promise { + return invokeOrMock('less_computer_voice_cancel', { sessionId }, () => undefined); +} + +/** 停止正在运行的 Agent 任务。 */ +export function lessComputerTaskCancel(): Promise { + return invokeOrMock('less_computer_task_cancel', undefined, () => undefined); +} + /** 浮窗 mount 时拉取当前会话的事件缓冲(seq 升序),重放 webview 冷加载期间 * 丢掉的事件(尤其首条 user —— 用户说的话)。 */ export function lessComputerSync(afterSequence: number): Promise { diff --git a/openless-all/app/src/lib/lessComputerComposer.test.ts b/openless-all/app/src/lib/lessComputerComposer.test.ts new file mode 100644 index 00000000..f9bd494d --- /dev/null +++ b/openless-all/app/src/lib/lessComputerComposer.test.ts @@ -0,0 +1,44 @@ +import { + claimDictationResult, + mergeDictation, + transcriptTail, + voiceHintKey, +} from './lessComputerComposer'; + +function assert(condition: unknown, name: string): asserts condition { + if (!condition) throw new Error(name); +} +assert.equal = (actual: unknown, expected: unknown, name = 'value') => { + if (actual !== expected) + throw new Error(`${name}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); +}; + +assert.equal(mergeDictation('', ' 打开设置 '), '打开设置', 'an empty draft takes the transcript'); +assert.equal( + mergeDictation('请帮我', '打开设置'), + '请帮我打开设置', + 'CJK text joins without a space', +); +assert.equal(mergeDictation('open', 'settings'), 'open settings', 'Latin words get one space'); +assert.equal(mergeDictation('open ', 'settings'), 'open settings', 'existing whitespace is kept'); +assert.equal(mergeDictation('first line\n', 'second'), 'first line\nsecond'); +assert.equal(mergeDictation('done', ', thanks'), 'done, thanks', 'punctuation attaches directly'); +assert.equal(mergeDictation('draft', ' '), 'draft', 'silence leaves the draft untouched'); + +assert.equal(transcriptTail(' hello world '), 'hello world'); +const long = '一二三四五六七八九十'.repeat(12); +const tail = transcriptTail(long, 20); +assert.equal(Array.from(tail).length, 21, 'tail keeps the requested characters plus an ellipsis'); +assert(tail.startsWith('…') && long.endsWith(tail.slice(1)), 'the newest words stay visible'); + +assert.equal(voiceHintKey('hold'), 'holdHint'); +assert.equal(voiceHintKey('toggle'), 'toggleHint'); +assert.equal(voiceHintKey('doubleClick'), 'toggleHint', 'double click behaves like one toggle'); +assert.equal(voiceHintKey('auto'), 'autoHint'); + +const session = `dictation-${Date.now()}`; +assert.equal(claimDictationResult(session), true, 'the first observer applies the result'); +assert.equal(claimDictationResult(session), false, 'replays and remounts never apply it twice'); +assert.equal(claimDictationResult(`${session}-next`), true, 'a new dictation is independent'); + +console.log('lessComputerComposer.test.ts passed'); diff --git a/openless-all/app/src/lib/lessComputerComposer.ts b/openless-all/app/src/lib/lessComputerComposer.ts new file mode 100644 index 00000000..694507a7 --- /dev/null +++ b/openless-all/app/src/lib/lessComputerComposer.ts @@ -0,0 +1,70 @@ +import type { HotkeyMode } from './types'; + +const CJK = /[\u2e80-\u303f\u3040-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/; +const CLOSING_PUNCTUATION = /^[,.!?;:%)\]},。!?;:、)」』》]/; + +/** Append a finished dictation to the draft; CJK text and punctuation never get an extra space. */ +export function mergeDictation(draft: string, transcript: string): string { + const addition = transcript.trim(); + if (!addition) return draft; + if (!draft.trim()) return addition; + const last = draft[draft.length - 1]; + if ( + /\s/.test(last) || + CJK.test(last) || + CJK.test(addition[0]) || + CLOSING_PUNCTUATION.test(addition) + ) + return draft + addition; + return `${draft} ${addition}`; +} + +/** Live transcript line: keep the most recent words visible inside a single composer row. */ +export function transcriptTail(text: string, maxChars = 96): string { + const normalized = text.replace(/\s+/g, ' ').trim(); + const chars = Array.from(normalized); + return chars.length <= maxChars ? normalized : `…${chars.slice(-maxChars).join('')}`; +} + +export type VoiceHintKey = 'holdHint' | 'toggleHint' | 'autoHint'; + +/** Less Computer shares the dictation hotkey mode; double-click behaves like a single toggle press. */ +export function voiceHintKey(mode: HotkeyMode): VoiceHintKey { + if (mode === 'hold') return 'holdHint'; + if (mode === 'auto') return 'autoHint'; + return 'toggleHint'; +} + +const CLAIM_KEY = 'ol.lc.dictation-claims'; +const MAX_CLAIMS = 32; +let claims: string[] | null = null; + +function loadClaims(): string[] { + if (claims) return claims; + try { + const parsed: unknown = JSON.parse(globalThis.sessionStorage?.getItem(CLAIM_KEY) ?? '[]'); + claims = Array.isArray(parsed) + ? parsed.filter((id): id is string => typeof id === 'string') + : []; + } catch { + claims = []; + } + return claims; +} + +/** + * The idle projection of a finished dictation is replayed on remount and after + * a WebView reload. Only the first observer may apply it to the draft. + */ +export function claimDictationResult(sessionId: string): boolean { + const list = loadClaims(); + if (list.includes(sessionId)) return false; + list.push(sessionId); + if (list.length > MAX_CLAIMS) list.splice(0, list.length - MAX_CLAIMS); + try { + globalThis.sessionStorage?.setItem(CLAIM_KEY, JSON.stringify(list)); + } catch { + /* in-memory claims still prevent duplicates for this WebView */ + } + return true; +} diff --git a/openless-all/app/src/lib/lessComputerReplay.test.ts b/openless-all/app/src/lib/lessComputerReplay.test.ts index 866558e6..f424a974 100644 --- a/openless-all/app/src/lib/lessComputerReplay.test.ts +++ b/openless-all/app/src/lib/lessComputerReplay.test.ts @@ -1,5 +1,5 @@ import { reconcileLessComputerReplay, reduceLessComputerVoice } from './lessComputerReplay'; -import type { LessComputerEvent, LessComputerSyncResult } from './types'; +import type { LessComputerEvent, LessComputerSyncResult, LessComputerVoiceEvent } from './types'; import contract from '../../contract/backend-2.0.json'; function assertDeepEqual(actual: unknown, expected: unknown, name: string) { @@ -96,9 +96,27 @@ assertDeepEqual( phase: 'recording', level: 0.5, elapsedMs: 120, + mode: 'dictate', + transcript: '打开', }, 'React consumes the same serialized Core feedback fixture', ); +const liveDictation = contract.lessComputerVoice.sample as LessComputerVoiceEvent; +const committedDictation = contract.lessComputerVoice.idleSample as LessComputerVoiceEvent; +const settled = reduceLessComputerVoice( + reduceLessComputerVoice(null, liveDictation), + committedDictation, +); +assertDeepEqual( + settled?.kind === 'voice_state' ? [settled.phase, settled.outcome, settled.transcript] : null, + ['idle', 'committed', '打开设置'], + 'the terminal dictation snapshot carries the final transcript for the composer', +); +assertDeepEqual( + reduceLessComputerVoice(settled, { ...liveDictation, seq: 5, transcript: 'late partial' }), + settled, + 'a late partial cannot reopen a committed dictation', +); const truncated: LessComputerSyncResult = { events: [], diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 880779cb..56fa25a9 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -33,12 +33,7 @@ export type PolishMode = 'raw' | 'light' | 'structured' | 'formal'; * 两套配置在凭据库中完全隔离,运行时只读当前模式。 */ export type PipelineMode = 'traditional' | 'multimodal'; -export type InsertStatus = - | 'inserted' - | 'pasteSent' - | 'copiedFallback' - | 'failed' - | 'notRequested'; +export type InsertStatus = 'inserted' | 'pasteSent' | 'copiedFallback' | 'failed' | 'notRequested'; export type HistorySource = 'voice' | 'quick_note' | 'selection_polish' | 'selection_voice_edit'; @@ -655,6 +650,12 @@ export type LessComputerEvent = phase: 'starting' | 'recording' | 'transcribing' | 'idle'; level: number; elapsedMs: number; + /** 缺省按 submit 处理(旧事件与快捷键路径)。 */ + mode?: LessComputerVoiceMode; + /** 本会话迄今的完整转写,录音中随实时识别更新。 */ + transcript?: string; + /** 仅 idle 时出现,描述本次录音如何收尾。 */ + outcome?: LessComputerVoiceOutcome; } /** 一轮用户气泡(语音指令转写)。fresh=true 表示新会话(清空历史);否则追加为后续轮次。 */ | { kind: 'user'; text: string; fresh?: boolean } @@ -682,6 +683,11 @@ export type LessComputerEvent = export type LessComputerVoiceEvent = Extract; +/** submit:说完直接交给 Agent;dictate:转写填进输入框,由用户编辑后发送。 */ +export type LessComputerVoiceMode = 'submit' | 'dictate'; + +export type LessComputerVoiceOutcome = 'submitted' | 'committed' | 'empty' | 'failed' | 'cancelled'; + /** `less_computer_sync` 的有界 replay 结果。`truncated=true` 表示调用方的水位 * 已早于后端仍保留的最老事件,前端必须清空派生视图后再应用 `events`。 */ export interface LessComputerSyncResult { diff --git a/openless-all/app/src/pages/LessComputerChrome.tsx b/openless-all/app/src/pages/LessComputerChrome.tsx new file mode 100644 index 00000000..461d7b38 --- /dev/null +++ b/openless-all/app/src/pages/LessComputerChrome.tsx @@ -0,0 +1,168 @@ +// Less Computer leaf components: avatars, per-bubble actions and the summary +// inspector. The inspector only aggregates the current turn; tool details stay +// in the conversation so no second activity feed is created. +import { useEffect, useRef, useState } from 'react'; +import type { useTranslation } from 'react-i18next'; +import { CheckIcon, ChevronsRightIcon, CopyIcon } from 'lucide-react'; +import { Tooltip } from '../components/Tooltip'; +import type { CodingAgentProviderId } from '../lib/types'; + +type Translate = ReturnType['t']; + +const AGENT_MARKS: Record = { + 'claude-code-cli': 'CC', + 'opencode-cli': 'OC', + 'codex-cli': 'Cx', + 'dsh-cli': 'ds', +}; + +export type RunTone = 'idle' | 'working' | 'waiting' | 'done' | 'error' | 'cancelled'; + +export function AgentAvatar({ + agentId, + size = 'md', + active = false, +}: { + agentId: CodingAgentProviderId | null; + size?: 'sm' | 'md' | 'lg'; + active?: boolean; +}) { + return ( + + ); +} + +export function CopyAction({ text, t }: { text: string; t: Translate }) { + const [copied, setCopied] = useState(false); + const timer = useRef(null); + useEffect( + () => () => { + if (timer.current != null) window.clearTimeout(timer.current); + }, + [], + ); + const copy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + if (timer.current != null) window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(false), 1400); + } catch { + /* clipboard unavailable: nothing was copied, so no confirmation */ + } + }; + const label = copied ? t('lessComputer.desktop.copied') : t('lessComputer.desktop.copy'); + return ( +
+ + + +
+ ); +} + +export interface InspectorSummary { + agentId: CodingAgentProviderId | null; + agentName: string | null; + statusLabel: string; + tone: RunTone; + toolCount: number; + pendingApprovals: number; + costUsd: number | null; + voiceHint: string | null; +} + +export function LessComputerInspector({ + open, + summary, + onClose, + t, +}: { + open: boolean; + summary: InspectorSummary; + onClose: () => void; + t: Translate; +}) { + const closeLabel = t('lessComputer.desktop.hideInspector'); + return ( + + ); +} diff --git a/openless-all/app/src/pages/LessComputerPanel.test.ts b/openless-all/app/src/pages/LessComputerPanel.test.ts index 97e82a5c..a2502e13 100644 --- a/openless-all/app/src/pages/LessComputerPanel.test.ts +++ b/openless-all/app/src/pages/LessComputerPanel.test.ts @@ -12,6 +12,9 @@ const replayModule = await import( const activityModule = await import( new URL('file://' + resolve(app, 'src/lib/lessComputerToolActivity.ts')) ); +const composerModule = await import( + new URL('file://' + resolve(app, 'src/lib/lessComputerComposer.ts')) +); const source = readFileSync(resolve(app, 'src/pages/LessComputerPanel.tsx'), 'utf8'); const parsed = ts.createSourceFile( 'LessComputerPanel.tsx', @@ -121,8 +124,17 @@ class Hooks { function context(native, replay) { const events = new Map(); const keys = new Set(); - const calls = { approve: [], submit: [], windows: [] }; - let approvalResult, submitResult; + const calls = { + approve: [], + submit: [], + windows: [], + voiceStart: [], + voiceStop: [], + voiceCancel: [], + taskCancel: 0, + focus: 0, + }; + let approvalResult, submitResult, voiceStartResult; globalThis.window = { location: { search: '?window=less-computer&demo=1' }, addEventListener: (name, fn) => { @@ -139,6 +151,9 @@ function context(native, replay) { useEffect: (fn, deps) => active.effect(fn, deps), useTranslation: () => ({ t: (key) => key }), useChatPanelLifecycle: () => ({ enterEpoch: 0, closing: false }), + useExitMount: (open) => ({ mounted: open, closing: false }), + applyThemeFromPreference: () => {}, + formatComboLabel: (binding) => binding.primary, isTauri: native, getSettings: async () => ({ codingAgentProvider: 'codex-cli' }), marketplaceAuthStatus: async () => ({ signedIn: false }), @@ -155,9 +170,25 @@ function context(native, replay) { lessComputerWindowDismiss: async () => { calls.windows.push('hide'); }, - chatPanelFocusKeyboard: async () => {}, + lessComputerVoiceStart: async (mode) => { + calls.voiceStart.push(mode); + return voiceStartResult?.promise; + }, + lessComputerVoiceStop: async (sessionId) => { + calls.voiceStop.push(sessionId); + }, + lessComputerVoiceCancel: async (sessionId) => { + calls.voiceCancel.push(sessionId); + }, + lessComputerTaskCancel: async () => { + calls.taskCancel += 1; + }, + chatPanelFocusKeyboard: async () => { + calls.focus += 1; + }, ...replayModule, ...activityModule, + ...composerModule, }); const api = factory( ...names.map((n) => imports[n]), @@ -194,6 +225,9 @@ function context(native, replay) { submission: (d) => { submitResult = d; }, + voiceStartResult: (d) => { + voiceStartResult = d; + }, turns: (tree) => nodes(tree) .filter((node) => node.type === api.TurnView) @@ -228,6 +262,13 @@ await test('browser has no demo state and cannot submit, approve, open OAuth or find(composer, (n) => n.type === 'form').props.onSubmit({ preventDefault() {} }); await tick(); assert.deepEqual(c.calls.submit, []); + for (const button of nodes(composer).filter( + (n) => n.type === 'button' && n.props.className?.startsWith('lc-round'), + )) { + assert.equal(button.props.disabled, true, 'browser previews cannot open the microphone'); + await button.props.onClick?.(); + } + assert.deepEqual(c.calls.voiceStart, []); find(tree, (n) => n.type === 'button' && n.props.className === 'lc-github').props.onClick(); tree = c.render(); assert(!nodes(tree).some((n) => n.type === 'GithubLoginModal')); @@ -394,7 +435,7 @@ await test('voice projection preserves session ownership and exposes only actual const props = find(c.render(), (n) => n.type === c.api.Composer).props; const hooks = new Hooks(); const tree = hooks.render(() => c.api.Composer(props)); - const waveform = find(tree, (n) => n.type === 'VoiceWaveform'); + const waveform = find(tree, (n) => n.type === 'LiveWaveform'); assert.equal(waveform.props.level, 0.7); assert.equal(waveform.props.processing, false); assert.equal(find(tree, (n) => n.type === 'time').props.children, '1:05'); @@ -506,9 +547,22 @@ await test('tool activity is collapsed, grouped and loses shimmer on every termi { kind: 'tool', name: 'Bash', running: false }, { kind: 'tool', name: 'Bash', running: true }, ]; - const active = c.api.ToolProcess({ tools, working: true, interrupted: false, t: (k) => k }); - assert.equal(active.type, 'details'); - assert.equal(active.props.open, undefined, 'native disclosure starts closed'); + const hooks = new Hooks(); + const renderActive = () => + hooks.render(() => + c.api.ToolProcess({ tools, working: true, interrupted: false, t: (k) => k }), + ); + let active = renderActive(); + const summary = () => find(active, (n) => n.props.className === 'lc-tool-summary'); + assert.equal(summary().props['aria-expanded'], false, 'tool activity starts collapsed'); + assert.equal( + find(active, (n) => n.props.className === 'lc-tool-body').props['aria-hidden'], + true, + ); + summary().props.onClick(); + active = renderActive(); + assert.equal(summary().props['aria-expanded'], true); + assert(active.props.className.includes('is-open'), 'expansion animates via the open class'); assert.equal( nodes(active).filter((n) => n.props.className?.startsWith('lc-process-step is-')).length, 2, @@ -519,7 +573,9 @@ await test('tool activity is collapsed, grouped and loses shimmer on every termi assert(nodes(active).some((n) => n.props.children === 'Read')); assert(nodes(active).some((n) => n.props.children === 'Bash')); for (const interrupted of [false, true]) { - const ended = c.api.ToolProcess({ tools, working: false, interrupted, t: (k) => k }); + const ended = new Hooks().render(() => + c.api.ToolProcess({ tools, working: false, interrupted, t: (k) => k }), + ); assert(!nodes(ended).some((n) => n.props.className?.includes('is-running'))); assert.equal( nodes(ended).some((n) => n.props.className === 'lc-process-step is-stopped'), @@ -554,15 +610,304 @@ await test('approval stays directly visible between separate folded tool blocks' assert.equal(nodes(tree).filter((n) => n.type === c.api.ToolProcess).length, 2); assert.equal(nodes(tree).filter((n) => n.type === c.api.ApprovalCard).length, 1); }); -await test('window controls are first, agents are text only and activity is not duplicated', async () => { - const c = context(false); +await test('window controls lead the sidebar and the inspector only summarizes the turn', async () => { + const c = context(true); + c.render(); + await tick(); + c.emit({ kind: 'user', text: 'task', fresh: true, seq: 1 }); + c.emit({ kind: 'tool', name: 'Read', seq: 2 }); + c.emit({ kind: 'tool', name: 'Bash', seq: 3 }); + c.emit({ kind: 'approval', token: 'one', command: 'rm -rf build', reason: 'x', seq: 4 }); const tree = c.render(); - const shell = find(tree, (n) => n.props.className === 'lc-desktop'); - const first = shell.props.children.find(Boolean); - assert.equal(first.type, 'header'); - assert.equal(first.props.children[0].props.className, 'lc-window-controls'); + const shell = find(tree, (n) => n.props.className?.startsWith('lc-desktop')); + const sidebar = shell.props.children.find(Boolean); + assert.equal(sidebar.type, 'aside'); + const head = sidebar.props.children.find(Boolean); + assert.equal(head.props.className, 'lc-sidebar-head'); + assert.equal(head.props.children[0].props.className, 'lc-window-controls'); assert(!nodes(tree).some((n) => n.type === 'AgentBuddy')); assert(!nodes(tree).some((n) => n.props.className === 'lc-activity')); + const inspector = find(tree, (n) => n.type === 'LessComputerInspector'); + assert.deepEqual( + { + tone: inspector.props.summary.tone, + toolCount: inspector.props.summary.toolCount, + pendingApprovals: inspector.props.summary.pendingApprovals, + }, + { tone: 'waiting', toolCount: 2, pendingApprovals: 1 }, + ); + const serialized = JSON.stringify(inspector.props.summary); + assert(!serialized.includes('Read') && !serialized.includes('rm -rf'), 'no second activity feed'); + const current = find(tree, (n) => n.type === 'li' && n.props['aria-current'] === 'true'); + assert(nodes(current).some((n) => n.props.children === 'Codex')); + c.hooks.unmount(); +}); +await test('finished dictation lands in the draft exactly once; silence shows a notice', async () => { + const c = context(true); + const committed = { + kind: 'voice_state', + sessionId: `dictation-${Date.now()}`, + phase: 'idle', + level: 0, + elapsedMs: 900, + mode: 'dictate', + transcript: 'open settings', + outcome: 'committed', + seq: 9, + }; + const h = new Hooks(); + const render = (voice) => h.render(() => c.api.Composer({ working: false, voice, t: (k) => k })); + let tree = render(null); + find(tree, (n) => n.type === 'textarea').props.onChange({ currentTarget: { value: 'please' } }); + render(committed); + tree = render(committed); + assert.equal(find(tree, (n) => n.type === 'textarea').props.value, 'please open settings'); + tree = render({ ...committed }); + assert.equal( + find(tree, (n) => n.type === 'textarea').props.value, + 'please open settings', + 'a replayed idle snapshot is not inserted twice', + ); + const remount = new Hooks(); + tree = remount.render(() => c.api.Composer({ working: false, voice: committed, t: (k) => k })); + tree = remount.render(() => c.api.Composer({ working: false, voice: committed, t: (k) => k })); + assert.equal(find(tree, (n) => n.type === 'textarea').props.value, ''); + const silent = { + ...committed, + sessionId: `${committed.sessionId}-silent`, + transcript: '', + outcome: 'empty', + }; + const quiet = new Hooks(); + quiet.render(() => c.api.Composer({ working: false, voice: silent, t: (k) => k })); + tree = quiet.render(() => c.api.Composer({ working: false, voice: silent, t: (k) => k })); + assert( + nodes(tree).some( + (n) => n.props.role === 'alert' && n.props.children === 'lessComputer.voice.empty', + ), + ); + assert.equal(find(tree, (n) => n.type === 'textarea').props.value, ''); +}); +await test('composer voice controls call their own commands and report start failures inline', async () => { + const c = context(true); + const h = new Hooks(); + const props = { working: false, voice: null, t: (k) => k }; + const render = (overrides = {}) => h.render(() => c.api.Composer({ ...props, ...overrides })); + let tree = render(); + const byClass = (cls) => + find(tree, (n) => n.type === 'button' && n.props.className?.split(' ').includes(cls)); + byClass('lc-mic').props.onClick(); + await tick(); + tree = render(); + byClass('lc-voice').props.onClick(); + await tick(); + assert.deepEqual(c.calls.voiceStart, ['dictate', 'submit']); + const failure = deferred(); + c.voiceStartResult(failure); + tree = render(); + byClass('lc-mic').props.onClick(); + tree = render(); + assert.equal(byClass('lc-mic').props.disabled, true, 'one start request at a time'); + byClass('lc-mic').props.onClick(); + assert.equal(c.calls.voiceStart.length, 3, 'a second click while starting is ignored'); + failure.reject(new Error('mic denied')); + await tick(); + tree = render(); + assert( + nodes(tree).some( + (n) => n.props.role === 'alert' && n.props.children === 'lessComputer.voice.startFailed', + ), + ); + c.voiceStartResult(undefined); + + tree = render({ working: true }); + await byClass('lc-stop').props.onClick(); + assert.equal(c.calls.taskCancel, 1); + + const recording = { + kind: 'voice_state', + sessionId: 'live', + phase: 'recording', + level: 0.4, + elapsedMs: 1200, + mode: 'dictate', + transcript: 'hello wor', + }; + tree = render({ voice: recording }); + assert( + nodes(tree).some((n) => n.props.children === 'hello wor'), + 'live transcript is visible', + ); + const stage = find(tree, (n) => n.props.className === 'lc-voice-stage'); + const [cancel, confirm] = nodes(stage).filter((n) => n.type === 'button'); + cancel.props.onClick(); + confirm.props.onClick(); + assert.deepEqual(c.calls.voiceCancel, ['live']); + assert.deepEqual(c.calls.voiceStop, ['live'], 'confirm stops only the displayed recording'); + tree = render({ voice: { ...recording, phase: 'transcribing' } }); + const [, finishing] = nodes(find(tree, (n) => n.props.className === 'lc-voice-stage')).filter( + (n) => n.type === 'button', + ); + assert.equal(finishing.props.disabled, true, 'a finishing capture cannot be stopped twice'); + tree = render({ voice: { ...recording, sessionId: 'next-recording' } }); + confirm.props.onClick(); + const nextConfirm = nodes(find(tree, (n) => n.props.className === 'lc-voice-stage')).filter( + (n) => n.type === 'button', + )[1]; + nextConfirm.props.onClick(); + assert.deepEqual( + c.calls.voiceStop, + ['live', 'live', 'next-recording'], + 'a delayed control keeps its original session ID instead of targeting the next capture', + ); +}); +await test('voice startup and text submission exclude each other before React rerenders', async () => { + const c = context(true); + const h = new Hooks(); + const render = () => h.render(() => c.api.Composer({ working: false, voice: null, t: (k) => k })); + let tree = render(); + find(tree, (n) => n.type === 'textarea').props.onChange({ + currentTarget: { value: 'keep this draft' }, + }); + tree = render(); + const mic = find(tree, (n) => n.props.className === 'lc-round lc-mic'); + const input = find(tree, (n) => n.type === 'textarea'); + const form = find(tree, (n) => n.type === 'form'); + const pending = deferred(); + c.voiceStartResult(pending); + mic.props.onClick(); + input.props.onKeyDown({ + key: 'Enter', + shiftKey: false, + keyCode: 13, + nativeEvent: { isComposing: false }, + preventDefault() {}, + }); + form.props.onSubmit({ preventDefault() {} }); + await tick(); + assert.deepEqual(c.calls.submit, [], 'same-render send cannot race an accepted microphone start'); + tree = render(); + assert.equal( + find(tree, (n) => n.type === 'button' && n.props.type === 'submit').props.disabled, + true, + ); + assert.equal(find(tree, (n) => n.type === 'textarea').props.value, 'keep this draft'); + pending.reject(new Error('fixture microphone failed')); + await tick(); + tree = render(); + assert.equal(find(tree, (n) => n.type === 'textarea').props.value, 'keep this draft'); + + const sending = deferred(); + c.submission(sending); + const currentMic = find(tree, (n) => n.props.className === 'lc-round lc-mic'); + find(tree, (n) => n.type === 'form').props.onSubmit({ preventDefault() {} }); + currentMic.props.onClick(); + assert.deepEqual( + c.calls.voiceStart, + ['dictate'], + 'pending text send also blocks same-render microphone start', + ); + sending.resolve(); + await tick(); + h.unmount(); +}); +await test('dictation updates a blocked composer without stealing DOM or native focus', async () => { + const c = context(true); + const h = new Hooks(); + let domFocus = 0; + const render = (voice, focusAllowed) => + h.render(() => c.api.Composer({ working: false, voice, focusAllowed, t: (k) => k })); + let tree = render(null, false); + const input = find(tree, (n) => n.type === 'textarea'); + input.props.ref.current = { + style: {}, + scrollHeight: 36, + value: 'fixture dictation', + focus() { + domFocus += 1; + }, + setSelectionRange() {}, + }; + input.props.onFocus(); + input.props.onPointerDown(); + assert.equal(c.calls.focus, 0, 'blocked input handlers cannot request native focus either'); + const terminal = { + kind: 'voice_state', + sessionId: `focus-blocked-${Date.now()}`, + phase: 'idle', + mode: 'dictate', + outcome: 'committed', + transcript: 'fixture dictation', + level: 0, + elapsedMs: 500, + }; + render(terminal, false); + tree = render(terminal, false); + assert.equal(find(tree, (n) => n.type === 'textarea').props.value, 'fixture dictation'); + assert.equal(domFocus, 0); + assert.equal(c.calls.focus, 0); + render(terminal, true); + assert.equal(domFocus, 0, 'closing a dialog does not replay an old focus request'); + assert.equal(c.calls.focus, 0); + const next = { ...terminal, sessionId: `${terminal.sessionId}-next`, transcript: 'next' }; + render(next, true); + render(next, true); + assert.equal(domFocus, 1, 'a subsequent foreground dictation still focuses the input'); + assert.equal(c.calls.focus, 1); + h.unmount(); +}); +await test('an open login dialog disables composer focus', async () => { + const c = context(true); + let tree = c.render(); + assert.equal(find(tree, (n) => n.type === c.api.Composer).props.focusAllowed, true); + find(tree, (n) => n.props.className === 'lc-github').props.onClick(); + tree = c.render(); + assert.equal(find(tree, (n) => n.type === c.api.Composer).props.focusAllowed, false); + c.hooks.unmount(); +}); +await test('abandoned captures never relabel a finished turn; capture errors get their own row', async () => { + const c = context(true); + c.render(); + await tick(); + c.emit({ kind: 'user', text: 'task', fresh: true, seq: 1 }); + c.emit({ kind: 'completed', text: 'answer', costUsd: null, seq: 2 }); + c.emit({ kind: 'cancelled', seq: 3 }); + let turns = c.turns(c.render()); + assert.equal(turns.length, 1); + assert.equal(turns[0].turn.status, 'done'); + c.emit({ kind: 'error', message: 'Less Computer voice input failed. Please try again.', seq: 4 }); + turns = c.turns(c.render()); + assert.equal(turns.length, 2); + assert.equal(turns[0].turn.status, 'done'); + assert.equal(turns[1].turn.status, 'error'); + assert.equal(turns[1].turn.user, ''); + c.hooks.unmount(); +}); +await test('Escape during a recording cancels only that recording', async () => { + const c = context(true); + c.render(); + await tick(); + c.emit({ + kind: 'voice_state', + sessionId: 'rec', + phase: 'recording', + level: 0.2, + elapsedMs: 300, + mode: 'submit', + seq: 1, + }); + c.render(); + for (const key of c.keys) + key({ + key: 'Escape', + isComposing: false, + keyCode: 27, + preventDefault() {}, + stopPropagation() {}, + }); + await tick(); + assert.deepEqual(c.calls.voiceCancel, ['rec']); + assert.deepEqual(c.calls.windows, []); c.hooks.unmount(); }); console.log(`${passed} actual-component behavior tests passed`); diff --git a/openless-all/app/src/pages/LessComputerPanel.tsx b/openless-all/app/src/pages/LessComputerPanel.tsx index b5e49b72..9eeb1d3e 100644 --- a/openless-all/app/src/pages/LessComputerPanel.tsx +++ b/openless-all/app/src/pages/LessComputerPanel.tsx @@ -4,15 +4,24 @@ import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } import { useTranslation } from 'react-i18next'; import { ArrowUpIcon, + AudioLinesIcon, CheckIcon, ChevronRightIcon, - HistoryIcon, + CircleAlertIcon, + FileTextIcon, + GlobeIcon, LayersIcon, Maximize2Icon, MicIcon, MinusIcon, - PlusIcon, + PanelRightOpenIcon, + PencilLineIcon, + SearchIcon, ShieldCheckIcon, + SquareIcon, + SquarePenIcon, + TerminalIcon, + WrenchIcon, XIcon, } from 'lucide-react'; import { @@ -23,10 +32,11 @@ import { MessageScrollerProvider, MessageScrollerViewport, } from '../components/chat/ui/message-scroller'; -import { VoiceWaveform } from '../components/chat/VoiceWaveform'; +import { LiveWaveform } from '../components/chat/LiveWaveform'; import { AssistantMarkdown } from '../components/chat/markdown'; import { useChatPanelLifecycle } from '../components/chat/lifecycle'; import { GithubLoginModal } from '../components/GithubLoginModal'; +import { Tooltip } from '../components/Tooltip'; import { chatPanelFocusKeyboard, getSettings, @@ -34,17 +44,33 @@ import { lessComputerApprove, lessComputerSubmitText, lessComputerSync, + lessComputerTaskCancel, + lessComputerVoiceCancel, + lessComputerVoiceStart, + lessComputerVoiceStop, lessComputerWindowDismiss, marketplaceAuthStatus, } from '../lib/ipc'; import { reconcileLessComputerReplay, reduceLessComputerVoice } from '../lib/lessComputerReplay'; +import { + claimDictationResult, + mergeDictation, + transcriptTail, + voiceHintKey, +} from '../lib/lessComputerComposer'; +import { formatComboLabel } from '../lib/hotkey'; +import { applyThemeFromPreference } from '../lib/themeMode'; +import { useExitMount } from '../lib/useExitMount'; import type { CodingAgentProviderId, + HotkeyMode, LessComputerEvent, LessComputerVoiceEvent, + LessComputerVoiceMode, UserPreferences, } from '../lib/types'; import { groupToolActivities, toolActivityCategory } from '../lib/lessComputerToolActivity'; +import { AgentAvatar, CopyAction, LessComputerInspector, type RunTone } from './LessComputerChrome'; import './less-computer-panel.css'; type Translate = ReturnType['t']; @@ -55,6 +81,20 @@ const AGENTS: { id: CodingAgentProviderId; name: string }[] = [ { id: 'dsh-cli', name: 'dsh' }, ]; +const CATEGORY_ICONS = { + search: SearchIcon, + read: FileTextIcon, + command: TerminalIcon, + edit: PencilLineIcon, + web: GlobeIcon, + other: WrenchIcon, +}; + +const INSPECTOR_KEY = 'ol.lc.inspector'; +const NARROW_QUERY = '(max-width: 899px)'; +const LOGIN_EXIT_MS = 180; +const MAX_INPUT_HEIGHT = 168; + type RunStatus = 'idle' | 'working' | 'done' | 'error' | 'cancelled'; interface TextSegment { @@ -96,6 +136,11 @@ interface Turn { costUsd: number | null; } +interface VoiceShortcut { + label: string; + mode: HotkeyMode; +} + function emptyTurn(user: string): Turn { return { user, segments: [], status: 'working', errorMsg: '', costUsd: null }; } @@ -115,12 +160,50 @@ function updateLastTurn(turns: Turn[], fn: (t: Turn) => Turn): Turn[] { return [...list.slice(0, -1), fn(list[list.length - 1])]; } +function hasFinishedLastTurn(turns: Turn[]): boolean { + const last = turns[turns.length - 1]; + return last !== undefined && last.status !== 'working'; +} + /** 把流里还在扫光的工具行停下来(下一个事件到达仅停止工具活动指示)。 */ function settleRunningTools(segments: Segment[]): Segment[] { if (!segments.some((s) => s.kind === 'tool' && s.running)) return segments; return segments.map((s) => (s.kind === 'tool' && s.running ? { ...s, running: false } : s)); } +function readInspectorPreference(): boolean { + try { + return window.localStorage?.getItem(INSPECTOR_KEY) !== '0'; + } catch { + return true; + } +} + +function writeInspectorPreference(open: boolean) { + try { + window.localStorage?.setItem(INSPECTOR_KEY, open ? '1' : '0'); + } catch { + /* the in-memory preference still applies for this window */ + } +} + +function matchesNarrow(): boolean { + return typeof window.matchMedia === 'function' && window.matchMedia(NARROW_QUERY).matches; +} + +function prefersReducedMotion(): boolean { + return ( + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); +} + +function voiceShortcutFrom(preferences: UserPreferences): VoiceShortcut | null { + const binding = preferences.codingAgentVoiceHotkey; + if (!binding) return null; + return { label: formatComboLabel(binding), mode: preferences.hotkey?.mode ?? 'hold' }; +} + // Keep the replay watermark across StrictMode/HMR effect remounts. A whole // WebView reload resets both state and watermark so the backend can replay it. let lcAppliedSeq = 0; @@ -131,12 +214,19 @@ export function LessComputerPanel() { const [voice, setVoice] = useState(null); const [sessionSeq, setSessionSeq] = useState(0); const [provider, setProvider] = useState(null); + const [voiceShortcut, setVoiceShortcut] = useState(null); const [signedIn, setSignedIn] = useState(null); const [loginOpen, setLoginOpen] = useState(false); const [windowError, setWindowError] = useState(false); + const [inspectorPreference, setInspectorPreference] = useState(readInspectorPreference); + const [narrow, setNarrow] = useState(matchesNarrow); + const [inspectorOverlay, setInspectorOverlay] = useState(false); const turnEpoch = useRef(0); const approvalRequests = useRef(new Map()); + const shellRef = useRef(null); + const closedRef = useRef(false); const { enterEpoch, closing } = useChatPanelLifecycle(); + const login = useExitMount(loginOpen, LOGIN_EXIT_MS); // Read actual configuration and account status; browser previews stay unavailable. useEffect(() => { @@ -144,12 +234,21 @@ export function LessComputerPanel() { let cancelled = false; let revision = 0; let unlisten: (() => void) | undefined; + const applyPreferences = (preferences: UserPreferences) => { + setProvider(preferences.codingAgentProvider); + setVoiceShortcut(voiceShortcutFrom(preferences)); + if (preferences.themeMode) applyThemeFromPreference(preferences.themeMode); + }; const refresh = async () => { const current = ++revision; const results = await Promise.allSettled([getSettings(), marketplaceAuthStatus()]); if (cancelled || current !== revision) return; const [settings, account] = results; - setProvider(settings.status === 'fulfilled' ? settings.value.codingAgentProvider : null); + if (settings.status === 'fulfilled') applyPreferences(settings.value); + else { + setProvider(null); + setVoiceShortcut(null); + } setSignedIn(account.status === 'fulfilled' ? account.value.signedIn : null); }; void (async () => { @@ -157,7 +256,7 @@ export function LessComputerPanel() { const { listen } = await import('@tauri-apps/api/event'); const handle = await listen('prefs:changed', (event) => { revision += 1; - setProvider(event.payload.codingAgentProvider); + applyPreferences(event.payload); }); if (cancelled) { handle(); @@ -177,6 +276,40 @@ export function LessComputerPanel() { }; }, [loginOpen]); + // Narrow windows show the inspector as an overlay that starts closed. + useEffect(() => { + if (typeof window.matchMedia !== 'function') return; + const media = window.matchMedia(NARROW_QUERY); + const onChange = () => { + setNarrow(media.matches); + setInspectorOverlay(false); + }; + media.addEventListener('change', onChange); + return () => media.removeEventListener('change', onChange); + }, []); + + // The WebView is reused across show/hide. Replay only the entrance motion so + // drafts, scroll position and inspector state survive reopening. Text and + // voice turns also call the native show path while the panel is visible; + // those must not flash the window, so only a preceding close replays it. + useEffect(() => { + if (closing) closedRef.current = true; + }, [closing]); + useEffect(() => { + if (enterEpoch === 0 || !closedRef.current) return; + closedRef.current = false; + if (prefersReducedMotion()) return; + const shell = shellRef.current; + if (!shell || typeof shell.animate !== 'function') return; + shell.animate( + [ + { opacity: 0, transform: 'translateY(6px) scale(0.985)' }, + { opacity: 1, transform: 'none' }, + ], + { duration: 240, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }, + ); + }, [enterEpoch]); + // ── 后端事件订阅(mount 一次)──────────────────────────────────────── // // 冷加载竞态补偿:webview 首次创建需要数百毫秒,后端在此期间 emit 的事件 @@ -329,21 +462,28 @@ export function LessComputerPanel() { break; case 'error': setTurns((prev) => - updateLastTurn(prev, (tn) => ({ - ...tn, - segments: settleRunningTools(tn.segments), - errorMsg: ev.message, - status: 'error', - })), + // A capture that fails before any turn starts must not relabel the + // previous finished answer; it gets its own error row instead. + hasFinishedLastTurn(prev) + ? [...prev, { ...emptyTurn(''), status: 'error', errorMsg: ev.message }] + : updateLastTurn(prev, (tn) => ({ + ...tn, + segments: settleRunningTools(tn.segments), + errorMsg: ev.message, + status: 'error', + })), ); break; case 'cancelled': setTurns((prev) => - updateLastTurn(prev, (tn) => ({ - ...tn, - segments: settleRunningTools(tn.segments), - status: 'cancelled', - })), + // Abandoning a recording cancels only the capture, not a finished turn. + hasFinishedLastTurn(prev) + ? prev + : updateLastTurn(prev, (tn) => ({ + ...tn, + segments: settleRunningTools(tn.segments), + status: 'cancelled', + })), ); break; } @@ -399,6 +539,7 @@ export function LessComputerPanel() { } }; + const activeVoiceSession = voice && voice.phase !== 'idle' ? voice.sessionId : null; useEffect(() => { const onKey = (event: KeyboardEvent) => { if (event.key !== 'Escape' || event.isComposing || event.keyCode === 229) return; @@ -406,159 +547,178 @@ export function LessComputerPanel() { if (loginOpen) { event.stopPropagation(); setLoginOpen(false); + } else if (activeVoiceSession && isTauri) { + // Esc during a recording only abandons that recording, never the window or task. + event.stopPropagation(); + void lessComputerVoiceCancel(activeVoiceSession).catch(() => undefined); } else void windowAction('hide'); }; window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); - }, [loginOpen]); + }, [loginOpen, activeVoiceSession]); const working = turns.some((turn) => turn.status === 'working'); const latestTurn = turns[turns.length - 1]; - const status = runStatusLabel(latestTurn, t); + const status = runStatus(latestTurn, t); + const agentName = AGENTS.find((agent) => agent.id === provider)?.name ?? null; + const voiceHint = voiceShortcut + ? t(`lessComputer.voice.${voiceHintKey(voiceShortcut.mode)}`, { key: voiceShortcut.label }) + : null; + const inspectorOpen = narrow ? inspectorOverlay : inspectorPreference; + const toggleInspector = () => { + if (narrow) { + setInspectorOverlay((open) => !open); + return; + } + const next = !inspectorPreference; + setInspectorPreference(next); + writeInspectorPreference(next); + }; + const showPillStatus = + status.tone === 'working' || status.tone === 'waiting' || status.tone === 'error'; + const openInspectorLabel = t('lessComputer.desktop.showInspector'); return ( <>
-
-
- - - -
-
- - {t('lessComputer.desktop.currentSession')} -
-
+
+
+ + {agentName ?? t('lessComputer.title')} + + +
+
+ {!inspectorOpen && ( + + + + )} +
+
{windowError && (

{t('lessComputer.desktop.windowError')}

)} -
-

{t('lessComputer.title')}

- - - {status} - -
{turns.length === 0 ? (
+

{t('lessComputer.subtitle')}

{t('lessComputer.desktop.emptyHint')}

- - - {t('lessComputer.desktop.voiceHint')} -
) : ( @@ -587,11 +747,42 @@ export function LessComputerPanel() { )}
- +
+ + segment.kind === 'tool').length ?? 0, + pendingApprovals: + latestTurn?.status === 'working' + ? latestTurn.segments.filter( + (segment) => segment.kind === 'approval' && !segment.decision, + ).length + : 0, + costUsd: latestTurn?.costUsd ?? null, + voiceHint, + }} + />
- {loginOpen && isTauri && ( + {login.mounted && isTauri && ( setLoginOpen(false)} onSuccess={() => { setSignedIn(true); @@ -603,23 +794,51 @@ export function LessComputerPanel() { ); } -function runStatusLabel(turn: Turn | undefined, t: Translate): string { - if (!turn) return t('lessComputer.desktop.idle'); +function runStatus(turn: Turn | undefined, t: Translate): { label: string; tone: RunTone } { + if (!turn) return { label: t('lessComputer.desktop.idle'), tone: 'idle' }; if ( turn.status === 'working' && turn.segments.some((segment) => segment.kind === 'approval' && !segment.decision) ) - return t('lessComputer.desktop.waitingApproval'); + return { label: t('lessComputer.desktop.waitingApproval'), tone: 'waiting' }; if (turn.status === 'working') { const active = turn.segments.find((segment) => segment.kind === 'tool' && segment.running); - return active?.kind === 'tool' - ? t(`lessComputer.activity.${toolActivityCategory(active.name)}Running`) - : t('lessComputer.working'); + return { + label: + active?.kind === 'tool' + ? t(`lessComputer.activity.${toolActivityCategory(active.name)}Running`) + : t('lessComputer.working'), + tone: 'working', + }; } - if (turn.status === 'done') return t('lessComputer.done'); - if (turn.status === 'cancelled') return t('common.cancelled'); - if (turn.status === 'error') return t('lessComputer.error'); - return t('lessComputer.desktop.idle'); + if (turn.status === 'done') return { label: t('lessComputer.done'), tone: 'done' }; + if (turn.status === 'cancelled') return { label: t('common.cancelled'), tone: 'cancelled' }; + if (turn.status === 'error') return { label: t('lessComputer.error'), tone: 'error' }; + return { label: t('lessComputer.desktop.idle'), tone: 'idle' }; +} + +function plainPreview(markdown: string): string { + return markdown + .replace(/```[\s\S]*?```/g, ' ') + .replace(/[`*_>#~[\]()|]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +/** Sidebar preview of the one real session: live status while working, else its latest words. */ +function sessionPreview(turns: Turn[], statusLabel: string, t: Translate): string { + const turn = turns[turns.length - 1]; + if (!turn) return t('lessComputer.desktop.idle'); + if (turn.status === 'working') return statusLabel; + for (let i = turn.segments.length - 1; i >= 0; i -= 1) { + const segment = turn.segments[i]; + if (segment.kind === 'text') { + const preview = plainPreview(segment.content); + if (preview) return preview; + } + } + if (turn.status === 'error') return turn.errorMsg || statusLabel; + return turn.user.trim() || statusLabel; } function voiceTime(elapsedMs: number): string { @@ -627,34 +846,92 @@ function voiceTime(elapsedMs: number): string { return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; } +function errorText(error: unknown): string { + if (error instanceof Error) return error.message; + return typeof error === 'string' ? error : String(error); +} + function Composer({ working, voice, t, + agentName = null, + voiceHint = null, + focusAllowed = true, }: { working: boolean; voice: LessComputerVoiceEvent | null; t: Translate; + agentName?: string | null; + voiceHint?: string | null; + focusAllowed?: boolean; }) { const [text, setText] = useState(''); const [submitting, setSubmitting] = useState(false); const [failed, setFailed] = useState(false); + const [voiceNotice, setVoiceNotice] = useState(null); + const [voicePending, setVoicePending] = useState(false); + const [stoppingTask, setStoppingTask] = useState(false); const submittingRef = useRef(false); const composingRef = useRef(false); - const speaking = voice !== null && voice.phase !== 'idle'; - const busy = working || speaking || submitting; + const voiceRequestRef = useRef(false); + const focusPendingRef = useRef(false); + const inputRef = useRef(null); + const phase = voice?.phase ?? 'idle'; + const speaking = voice !== null && phase !== 'idle'; + const dictating = speaking && voice?.mode === 'dictate'; + const busy = working || speaking || submitting || voicePending; + const hasText = text.trim().length > 0; const voiceLabel = - voice?.phase === 'recording' - ? t('overview.inAppDictation.recording') - : voice?.phase === 'starting' - ? t('common.loading') - : t('overview.inAppDictation.processing'); + phase === 'recording' + ? t('lessComputer.voice.listening') + : phase === 'starting' + ? t('lessComputer.voice.starting') + : t('lessComputer.voice.transcribing'); + + // A finished dictation lands in the draft exactly once, even when replayed. + useEffect(() => { + if (!voice || voice.phase !== 'idle' || voice.mode !== 'dictate' || !voice.outcome) return; + if (!claimDictationResult(voice.sessionId)) return; + const transcript = voice.transcript?.trim() ?? ''; + if (voice.outcome === 'committed' && transcript) { + setText((current) => mergeDictation(current, transcript)); + setVoiceNotice(null); + focusPendingRef.current = true; + } else if (voice.outcome === 'empty') setVoiceNotice(t('lessComputer.voice.empty')); + else if (voice.outcome === 'failed') setVoiceNotice(t('lessComputer.voice.failed')); + }, [voice]); + + useEffect(() => { + const input = inputRef.current; + if (!input) return; + input.style.height = 'auto'; + input.style.height = `${Math.min(input.scrollHeight, MAX_INPUT_HEIGHT)}px`; + if (!focusPendingRef.current) return; + focusPendingRef.current = false; + // A dialog or closing window owns focus. Consume this request rather than + // unexpectedly replaying it when that surface is dismissed later. + if (!focusAllowed) return; + if (isTauri) void chatPanelFocusKeyboard().catch(() => undefined); + input.focus({ preventScroll: true }); + input.setSelectionRange(input.value.length, input.value.length); + }, [text, focusAllowed]); + const send = async () => { const trimmed = text.trim(); - if (!isTauri || !trimmed || busy || submittingRef.current || composingRef.current) return; + if ( + !isTauri || + !trimmed || + busy || + submittingRef.current || + voiceRequestRef.current || + composingRef.current + ) + return; submittingRef.current = true; setSubmitting(true); setFailed(false); + setVoiceNotice(null); try { await lessComputerSubmitText(trimmed); // Keep a draft typed while the IPC was pending. The actual user event owns the chat. @@ -666,83 +943,204 @@ function Composer({ setSubmitting(false); } }; + const startVoice = async (mode: LessComputerVoiceMode) => { + if (!isTauri || busy || voiceRequestRef.current || submittingRef.current) return; + voiceRequestRef.current = true; + setVoicePending(true); + setVoiceNotice(null); + setFailed(false); + try { + await lessComputerVoiceStart(mode); + } catch (error) { + setVoiceNotice(t('lessComputer.voice.startFailed', { message: errorText(error) })); + } finally { + voiceRequestRef.current = false; + setVoicePending(false); + } + }; + const stopVoice = () => { + if (!isTauri || !voice || !speaking || phase === 'transcribing') return; + void lessComputerVoiceStop(voice.sessionId).catch(() => + setVoiceNotice(t('lessComputer.voice.failed')), + ); + }; + const cancelVoice = () => { + if (!isTauri || !voice || !speaking) return; + void lessComputerVoiceCancel(voice.sessionId).catch(() => undefined); + }; + const stopTask = async () => { + if (!isTauri || !working || stoppingTask) return; + setStoppingTask(true); + setVoiceNotice(null); + try { + await lessComputerTaskCancel(); + } catch { + setVoiceNotice(t('lessComputer.voice.taskCancelFailed')); + } finally { + setStoppingTask(false); + } + }; const onKeyDown = (event: ReactKeyboardEvent) => { if (event.key !== 'Enter' || event.shiftKey) return; event.preventDefault(); if (composingRef.current || event.nativeEvent.isComposing || event.keyCode === 229) return; void send(); }; + const liveTranscript = voice?.transcript ? transcriptTail(voice.transcript) : ''; + const placeholder = agentName + ? t('lessComputer.desktop.messagePlaceholder', { agent: agentName }) + : t('lessComputer.inputPlaceholder'); + const primary = working ? 'stop' : hasText ? 'send' : 'voice'; + const primaryLabel = + primary === 'stop' + ? t('lessComputer.voice.stopTask') + : primary === 'send' + ? t('lessComputer.send') + : t('lessComputer.voice.voiceMode'); + const confirmLabel = dictating + ? t('lessComputer.voice.confirmDictation') + : t('lessComputer.voice.stopAndSend'); + const caption = failed ? ( + {t('lessComputer.desktop.sendError')} + ) : voiceNotice ? ( + {voiceNotice} + ) : !isTauri ? ( + t('lessComputer.desktop.browserUnavailable') + ) : speaking ? ( + dictating ? ( + t('lessComputer.voice.dictateCaption') + ) : ( + t('lessComputer.voice.submitCaption') + ) + ) : working ? ( + t('lessComputer.voice.stopHint') + ) : ( + [t('lessComputer.desktop.inputHint'), voiceHint].filter(Boolean).join(' · ') + ); return (
{ event.preventDefault(); void send(); }} > -
-