feat(ui): integrate desktop panel refresh and session-safe voice controls

This commit is contained in:
sim committed 2026-09-26 17:07:38 +08:00
1 parent b38fc8a656
commit 51f6f4cfa7
49 files changed
+5307 -1364

No files matched your search

+5 -1
View File
@@ -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"],
+624 -46
View File
@@ -132,6 +132,36 @@ pub struct LessComputerVoiceSession {
request: crate::domains::LessComputerRunRequest,
partials: Arc<VoiceTranscriptSink>,
archive_successful_recording: bool,
mode: crate::events::LessComputerVoiceMode,
feedback: Arc<LessVoiceFeedback>,
}
/// 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<crate::types::TranscriptAccumulator>,
/// Less Computer mirrors the accumulated transcript into its voice
/// projection; other voice surfaces only publish `TranscriptDelta`.
feedback: Option<Arc<LessVoiceFeedback>>,
}
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<LessVoiceFeedbackState>,
}
struct LessVoiceFeedbackState {
phase: crate::events::LessComputerVoicePhase,
elapsed_ms: u64,
level: f32,
transcript: String,
outcome: Option<crate::events::LessComputerVoiceOutcome>,
}
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<String>) {
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<LessComputerRunResult, BackendError>> {
) -> futures_util::future::BoxFuture<'static, Result<LessComputerVoiceFinish, BackendError>>
{
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<dyn crate::domains::LessComputerApi>,
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<dyn crate::ports::RecordingControlSink>,
) -> Result<LessComputerVoiceSession, BackendError> {
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<dyn crate::ports::RecordingControlSink>,
options: LessComputerVoiceOptions,
) -> Result<LessComputerVoiceSession, BackendError> {
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<Option<Arc<dyn TextStreamSink>>>,
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<crate::TranscriptOutput, BackendError>> {
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<DictationTranscription>);
impl DictationEngine for DictationOnlyEngine {
fn start(
&self,
_session_id: SessionId,
_context: Arc<DictationContext>,
_progress: Arc<dyn EngineProgressSink>,
) -> BoxFuture<'static, Result<(), BackendError>> {
boxed(async { Ok(()) })
}
fn start_transcription(
&self,
_session_id: SessionId,
_context: Arc<DictationContext>,
partials: Arc<dyn TextStreamSink>,
) -> BoxFuture<'static, Result<Arc<dyn TranscriptionSession>, BackendError>> {
*self.0.partials.lock().unwrap() = Some(partials);
let session: Arc<dyn TranscriptionSession> = self.0.clone();
boxed(async move { Ok(session) })
}
fn start_voice_capture(
&self,
_session_id: SessionId,
_context: Arc<DictationContext>,
_partials: Arc<dyn TextStreamSink>,
_progress: Arc<dyn crate::ports::RecordingProgressSink>,
_cancel: crate::CancellationToken,
) -> BoxFuture<'static, Result<crate::ports::VoiceCapture, BackendError>> {
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<dyn EngineProgressSink>,
) -> BoxFuture<'static, Result<EngineResult, EngineFailure>> {
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<DictationTranscription>,
Arc<LessComputerCaptureRuntime>,
) {
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<crate::events::LessComputerEventKind> {
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<crate::events::LessComputerEventKind>| {
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<dyn crate::ports::RecordingControlSink>,
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<dyn crate::ports::RecordingControlSink>,
task_spawner: Arc::new(TokioTaskSpawner),
@@ -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<LessComputerVoiceOutcome>,
},
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,
},
}),
);
@@ -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,
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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();
@@ -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,
@@ -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\)/,
+94 -11
View File
@@ -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<openless_core::SessionId>, enforce_context: Option<bool>) -> Result<(), String> {
pub async fn qa_submit_text(
core: CoreState<'_>,
text: String,
expected_session_id: Option<openless_core::SessionId>,
enforce_context: Option<bool>,
) -> 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<openless_core::events::QaStateEvent, String> {
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<openless_core::events::QaStateEvent, String> {
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<open
#[tauri::command]
pub async fn qa_window_set_expanded(window: Window, expanded: bool) -> 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(
@@ -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;
}
@@ -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<Inner>,
options: openless_core::LessComputerVoiceOptions,
) -> Result<openless_core::SessionId, openless_core::BackendError> {
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<dyn openless_core::RecordingControlSink>,
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<Inner>,
session_id: openless_core::SessionId,
) -> Result<bool, openless_core::BackendError> {
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<Inner>,
session_id: openless_core::SessionId,
) -> Result<bool, openless_core::BackendError> {
request_less_computer_voice_stop_with(inner, session_id, |task| {
inner.host.spawn(task);
})
}
fn request_less_computer_voice_stop_with(
inner: &Arc<Inner>,
session_id: openless_core::SessionId,
spawn: impl FnOnce(futures_util::future::BoxFuture<'static, ()>),
) -> Result<bool, openless_core::BackendError> {
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<Inner>,
expected_session_id: Option<openless_core::SessionId>,
@@ -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<openless_core::testing::FixtureAudioRecorder>,
pub(crate) startup_delay: std::time::Duration,
}
impl openless_core::AudioRecorder for DelayedFixtureRecorder {
fn start(
&self,
session_id: openless_core::SessionId,
context: Arc<openless_core::DictationContext>,
consumer: Arc<dyn openless_core::AudioConsumer>,
progress: Arc<dyn openless_core::RecordingProgressSink>,
) -> futures_util::future::BoxFuture<
'static,
Result<Box<dyn openless_core::ActiveRecording>, 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<openless_core::LessComputerEventKind> {
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<openless_core::testing::FixtureAudioRecorder>,
startup_delay: std::time::Duration,
}
impl openless_core::AudioRecorder for DelayedFixtureRecorder {
fn start(
&self,
session_id: openless_core::SessionId,
context: Arc<openless_core::DictationContext>,
consumer: Arc<dyn openless_core::AudioConsumer>,
progress: Arc<dyn openless_core::RecordingProgressSink>,
) -> futures_util::future::BoxFuture<
'static,
Result<Box<dyn openless_core::ActiveRecording>, 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) =
+4
View File
@@ -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,
@@ -65,9 +65,9 @@ pub fn start(app: AppHandle, backend: Arc<OpenLessBackend>) {
}
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(
@@ -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')}
</span>
)}
<span>{t('shell.footer.version', { version: APP_VERSION_LABEL })}</span>
</div>
{/* nav — 扁平项 + 可展开分组(用户拍板结构)。扁平项:概览/历史/词汇。
@@ -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<Phase>({ kind: 'starting' });
const [copied, setCopied] = useState(false);
const cancelledRef = useRef(false);
@@ -175,7 +185,14 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps)
};
return (
<Modal onClose={close} zIndex={60} width="min(440px, 100%)">
<Modal
onClose={close}
zIndex={60}
width="min(440px, 100%)"
closing={closing}
overlayClassName={overlayClassName}
labelledBy={titleId}
>
<div
style={{
display: 'flex',
@@ -185,7 +202,9 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps)
gap: 12,
}}
>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 650 }}>{t('marketplace.oauth.title')}</h2>
<h2 id={titleId} style={{ margin: 0, fontSize: 16, fontWeight: 650 }}>
{t('marketplace.oauth.title')}
</h2>
<button
type="button"
aria-label={t('common.close')}
@@ -194,18 +213,16 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps)
style={{
width: 28,
height: 28,
borderRadius: 8,
borderRadius: 999,
display: 'inline-grid',
placeItems: 'center',
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-surface)',
color: 'var(--ol-ink-2)',
cursor: 'pointer',
fontSize: 16,
lineHeight: 1,
}}
>
×
<XIcon size={15} strokeWidth={2} aria-hidden />
</button>
</div>
@@ -298,8 +315,13 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps)
{phase.kind === 'success' && (
<div style={{ flex: 1, display: 'grid', placeItems: 'center', textAlign: 'center' }}>
<div>
<div style={{ fontSize: 24, color: 'var(--ol-blue)', marginBottom: 8 }}>✓</div>
<div style={{ display: 'grid', justifyItems: 'center' }}>
<CircleCheckIcon
size={30}
strokeWidth={1.8}
aria-hidden
style={{ color: 'var(--ol-ok, var(--ol-blue))', marginBottom: 10 }}
/>
<div style={{ fontSize: 14, fontWeight: 650, color: 'var(--ol-ink)' }}>
{t('marketplace.oauth.successAs', { login: phase.login })}
</div>
@@ -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',
+30 -4
View File
@@ -14,6 +14,9 @@ interface SavedToastProps {
message: string;
offsetStyle?: Pick<CSSProperties, 'top' | 'right' | 'left' | 'bottom' | 'position'>;
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 && (
<button
type="button"
onClick={onAction}
style={{
marginLeft: 4,
border: 0,
background: 'transparent',
color: 'inherit',
font: 'inherit',
fontWeight: 700,
textDecoration: 'underline',
cursor: 'pointer',
}}
>
{actionLabel}
</button>
)}
</motion.div>
)}
</AnimatePresence>
@@ -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<HTMLCanvasElement | null>(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 (
<div
className={`lc-live-waveform${processing ? ' is-processing' : ''}`}
role="status"
aria-label={label}
>
<canvas ref={canvasRef} aria-hidden="true" />
</div>
);
}
+68 -11
View File
@@ -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<HTMLElement>(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<HTMLDivElement>(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(
<div
className="ol-dialog-overlay"
className={`ol-dialog-overlay${closing ? ' is-closing' : ''}${overlayClassName ? ` ${overlayClassName}` : ''}`}
onClick={onClose}
style={{
position: 'fixed',
@@ -46,13 +102,16 @@ export function Modal({
placeItems: 'center',
zIndex,
padding: 20,
animation: closing
? 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft) reverse both'
: 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)',
pointerEvents: closing ? 'none' : undefined,
}}
>
<div
ref={cardRef}
className="ol-dialog-card"
role="dialog"
aria-modal="true"
aria-labelledby={labelledBy}
tabIndex={-1}
onClick={(e) => 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,
}}
>
+64 -22
View File
@@ -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',
+65 -20
View File
@@ -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',
+64 -22
View File
@@ -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',
+69 -23
View File
@@ -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',
+66 -21
View File
@@ -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: '検索をクリア',
+63 -20
View File
@@ -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: '검색 지우기',
+60 -20
View File
@@ -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: '清除搜索',
+60 -20
View File
@@ -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: '清除搜尋',
+4
View File
@@ -190,6 +190,10 @@ export {
lessComputerApprove,
lessComputerSubmitText,
lessComputerSync,
lessComputerVoiceStart,
lessComputerVoiceStop,
lessComputerVoiceCancel,
lessComputerTaskCancel,
} from './less-computer';
// chat-panel(QA / Less Computer 共用)
+22 -1
View File
@@ -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<void> {
@@ -21,6 +21,27 @@ export function lessComputerSubmitText(text: string): Promise<void> {
return invokeOrMock('less_computer_submit_text', { text }, () => undefined);
}
/** 面板内开麦。dictate:转写只填进输入框;submit:说完直接交给 Agent(与快捷键一致)。
* 启动失败(麦克风权限、其它语音会话占用)以 reject 返回,面板内联提示。 */
export function lessComputerVoiceStart(mode: LessComputerVoiceMode): Promise<void> {
return invokeOrMock('less_computer_voice_start', { mode }, () => undefined);
}
/** 只结束指定录音并按其 mode 收尾;迟到请求不会停止后来开始的会话。 */
export function lessComputerVoiceStop(sessionId: string): Promise<void> {
return invokeOrMock('less_computer_voice_stop', { sessionId }, () => undefined);
}
/** 只取消指定录音会话,不会波及其它会话或已在运行的任务。 */
export function lessComputerVoiceCancel(sessionId: string): Promise<void> {
return invokeOrMock('less_computer_voice_cancel', { sessionId }, () => undefined);
}
/** 停止正在运行的 Agent 任务。 */
export function lessComputerTaskCancel(): Promise<void> {
return invokeOrMock('less_computer_task_cancel', undefined, () => undefined);
}
/** 浮窗 mount 时拉取当前会话的事件缓冲(seq 升序),重放 webview 冷加载期间
* 丢掉的事件(尤其首条 user —— 用户说的话)。 */
export function lessComputerSync(afterSequence: number): Promise<LessComputerSyncResult> {
@@ -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');
@@ -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;
}
@@ -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: [],
+12 -6
View File
@@ -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<LessComputerEvent, { kind: 'voice_state' }>;
/** 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 {
@@ -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<typeof useTranslation>['t'];
const AGENT_MARKS: Record<CodingAgentProviderId, string> = {
'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 (
<span className={`lc-avatar is-${size}${active ? ' is-active' : ''}`} aria-hidden="true">
{agentId ? AGENT_MARKS[agentId] : 'LC'}
</span>
);
}
export function CopyAction({ text, t }: { text: string; t: Translate }) {
const [copied, setCopied] = useState(false);
const timer = useRef<number | null>(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 (
<div className="lc-message-actions">
<Tooltip content={label} placement="top">
<button
type="button"
className={`lc-icon-button is-small${copied ? ' is-confirmed' : ''}`}
aria-label={label}
onClick={() => void copy()}
>
{copied ? <CheckIcon /> : <CopyIcon />}
</button>
</Tooltip>
</div>
);
}
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 (
<aside
className="lc-inspector"
aria-label={t('lessComputer.desktop.inspectorTitle')}
aria-hidden={!open || undefined}
>
<div className="lc-inspector-inner">
<div className="lc-inspector-head" data-tauri-drag-region>
<span data-tauri-drag-region>{t('lessComputer.desktop.inspectorTitle')}</span>
<Tooltip content={closeLabel} placement="bottom">
<button
type="button"
className="lc-icon-button"
aria-label={closeLabel}
tabIndex={open ? undefined : -1}
onClick={onClose}
>
<ChevronsRightIcon />
</button>
</Tooltip>
</div>
<section className="lc-inspector-card lc-inspector-agent">
<AgentAvatar agentId={summary.agentId} size="lg" active={summary.agentId != null} />
<div>
<strong>{summary.agentName ?? t('lessComputer.title')}</strong>
<span>
{summary.agentId
? t('lessComputer.desktop.configured')
: t('lessComputer.desktop.agentSettings')}
</span>
</div>
</section>
<section className="lc-inspector-section">
<h3>{t('lessComputer.desktop.inspectorStatus')}</h3>
<div className={`lc-inspector-status is-${summary.tone}`}>
<span aria-hidden="true" />
{summary.statusLabel}
</div>
</section>
<section className="lc-inspector-section">
<h3>{t('lessComputer.desktop.inspectorTurn')}</h3>
<dl className="lc-inspector-stats">
<div>
<dt>{t('lessComputer.desktop.toolCalls')}</dt>
<dd>{summary.toolCount}</dd>
</div>
<div>
<dt>{t('lessComputer.desktop.pendingApprovals')}</dt>
<dd className={summary.pendingApprovals > 0 ? 'is-attention' : undefined}>
{summary.pendingApprovals}
</dd>
</div>
<div>
<dt>{t('lessComputer.desktop.apiCost')}</dt>
<dd>
{summary.costUsd != null
? t('lessComputer.cost', { cost: summary.costUsd.toFixed(3) })
: '—'}
</dd>
</div>
</dl>
</section>
<section className="lc-inspector-section">
<h3>{t('lessComputer.desktop.inspectorVoice')}</h3>
<p className="lc-inspector-text">
{summary.voiceHint ?? t('lessComputer.desktop.voiceShortcutOff')}
</p>
<p className="lc-inspector-note">{t('lessComputer.desktop.voiceModesHint')}</p>
</section>
</div>
</aside>
);
}
@@ -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`);
File diff suppressed because it is too large. Load diff
+150
View File
@@ -0,0 +1,150 @@
// @ts-nocheck — exercise the real page handlers with fake stores and controlled timers.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import ts from 'typescript';
const source = ts.createSourceFile(
'Style.tsx',
readFileSync(new URL('./Style.tsx', import.meta.url), 'utf8'),
ts.ScriptTarget.ES2022,
true,
ts.ScriptKind.TSX,
);
const page = source.statements.find(
(node) => ts.isFunctionDeclaration(node) && node.name?.text === 'Style',
);
const names = ['loadPacks', 'restoreDeletedPack', 'commitDeletedPack', 'handleDeleteImportedPack'];
const handlers = page.body.statements.filter(
(node) =>
ts.isVariableStatement(node) &&
node.declarationList.declarations.some((value) => names.includes(value.name.getText(source))),
);
assert.equal(handlers.length, names.length);
const compiled = ts.transpileModule(handlers.map((node) => node.getText(source)).join('\n'), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
}).outputText;
const instantiate = new Function(
'deps',
`const {loadSequence,packsLoaded,pendingDeletes,listStylePacks,setPacks,setSelectedId,
setBusy,showSaveStatus,t,window,setUndoDelete,deleteStylePack,packs,editorOpen,
selectedId,startEditorClose,getStylePackPresentation}=deps;
${compiled}; return {${names.join(',')}};`,
);
const tick = () => new Promise((resolve) => setImmediate(resolve));
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
function context() {
const pack = { id: 'imported', name: 'Fixture', kind: 'imported', active: true };
const other = { id: 'builtin', name: 'Builtin', kind: 'builtin', active: false };
const state = { rows: [pack, other], selected: pack.id, undo: null, errors: [], deleted: [] };
const pendingDeletes = { current: new Map() };
const timers = new Map();
let timerId = 0;
let listing = () => Promise.resolve([pack, other]);
let deletion = () => Promise.resolve();
const apply = (field, value) => {
state[field] = typeof value === 'function' ? value(state[field]) : value;
};
const api = instantiate({
loadSequence: { current: 0 },
packsLoaded: { current: true },
pendingDeletes,
listStylePacks: () => listing(),
deleteStylePack: (id) => {
state.deleted.push(id);
return deletion();
},
setPacks: (value) => apply('rows', value),
setSelectedId: (value) => apply('selected', value),
setUndoDelete: (value) => apply('undo', value),
setBusy: () => {},
showSaveStatus: (...value) => state.errors.push(value),
t: (key) => key,
window: {
setTimeout: (fn) => {
timers.set(++timerId, fn);
return timerId;
},
clearTimeout: (id) => timers.delete(id),
},
packs: state.rows,
editorOpen: false,
selectedId: pack.id,
startEditorClose: () => {},
getStylePackPresentation: (value) => ({ name: value.name }),
});
return {
api,
state,
pack,
other,
pendingDeletes,
timers,
listing: (fn) => {
listing = fn;
},
deletion: (fn) => {
deletion = fn;
},
};
}
// Refresh/prefs:changed cannot resurrect or select an optimistically removed row.
{
const c = context();
c.api.handleDeleteImportedPack(c.pack);
await c.api.loadPacks(c.pack.id);
assert.deepEqual(c.state.rows, [c.other]);
assert.equal(c.state.selected, c.other.id);
assert.deepEqual(c.state.deleted, []);
c.api.restoreDeletedPack(c.pack.id);
assert.deepEqual(c.state.rows, [c.pack, c.other]);
assert.equal(c.timers.size, 0);
}
// Committing rows remain hidden; stale Undo and repeated timer callbacks cannot race the RPC.
{
const c = context();
const done = deferred();
c.deletion(() => done.promise);
c.api.handleDeleteImportedPack(c.pack);
c.api.commitDeletedPack(c.pack.id);
c.api.commitDeletedPack(c.pack.id);
c.api.restoreDeletedPack(c.pack.id);
await c.api.loadPacks(c.pack.id);
assert.deepEqual(c.state.rows, [c.other]);
assert.deepEqual(c.state.deleted, [c.pack.id]);
assert(c.pendingDeletes.current.has(c.pack.id));
const stale = deferred();
c.listing(() => stale.promise);
const refresh = c.api.loadPacks(c.pack.id);
c.listing(() => Promise.resolve([c.other]));
done.resolve();
await tick();
stale.resolve([c.pack, c.other]);
await refresh;
assert.deepEqual(c.state.rows, [c.other]);
assert.equal(c.pendingDeletes.current.size, 0);
}
// A failed delete restores exactly one copy and reports the failure.
{
const c = context();
const done = deferred();
c.deletion(() => done.promise);
c.api.handleDeleteImportedPack(c.pack);
c.api.commitDeletedPack(c.pack.id);
await c.api.loadPacks();
done.reject(new Error('fixture failure'));
await tick();
assert.deepEqual(c.state.rows, [c.pack, c.other]);
assert.equal(c.pendingDeletes.current.size, 0);
assert.equal(c.state.errors[0][0], 'failed');
}
console.log('Style deletion lifecycle regressions passed');
+96 -38
View File
@@ -163,6 +163,10 @@ export function Style() {
const [saveState, setSaveState] = useState<SaveToastState>('idle');
const [saveMessage, setSaveMessage] = useState('');
const statusTimer = useRef<number | null>(null);
const pendingDeletes = useRef(
new Map<string, { pack: StylePack; timer: number; index: number; committing: boolean }>(),
);
const [undoDelete, setUndoDelete] = useState<{ id: string; name: string } | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [editorClosing, setEditorClosing] = useState(false);
const editorCloseTimer = useRef<number | null>(null);
@@ -191,6 +195,11 @@ export function Style() {
() => () => {
if (statusTimer.current !== null) window.clearTimeout(statusTimer.current);
if (editorCloseTimer.current !== null) window.clearTimeout(editorCloseTimer.current);
for (const [id, pending] of pendingDeletes.current) {
window.clearTimeout(pending.timer);
if (!pending.committing) void deleteStylePack(id).catch(() => {});
}
pendingDeletes.current.clear();
},
[],
);
@@ -219,8 +228,10 @@ export function Style() {
const initialLoad = !packsLoaded.current;
if (initialLoad) setBusy('loading');
try {
const next = await listStylePacks();
const listed = await listStylePacks();
if (sequence !== loadSequence.current) return;
// A refresh must not resurrect a row whose undo window or delete is active.
const next = listed.filter((pack) => !pendingDeletes.current.has(pack.id));
packsLoaded.current = true;
setPacks(next);
const nextSelectedId =
@@ -471,32 +482,51 @@ export function Style() {
}
};
const handleDeleteImportedPack = async (pack: StylePack) => {
if (pack.kind !== 'imported') return;
if (
!window.confirm(
t('style.pack.deleteConfirm', { name: getStylePackPresentation(pack, t).name }),
)
) {
return;
}
setBusy('deleting');
try {
await deleteStylePack(pack.id);
showSaveStatus(
'saved',
t('style.pack.deleteSuccess', { name: getStylePackPresentation(pack, t).name }),
true,
);
if (editorOpen && selectedId === pack.id) {
startEditorClose();
}
await loadPacks();
} catch (deleteError) {
showSaveStatus('failed', t('style.pack.deleteFailed', { err: String(deleteError) }));
} finally {
setBusy(null);
}
const restoreDeletedPack = (id: string) => {
const pending = pendingDeletes.current.get(id);
if (!pending || pending.committing) return;
window.clearTimeout(pending.timer);
pendingDeletes.current.delete(id);
setPacks((current) => {
if (current.some((pack) => pack.id === id)) return current;
const next = current.slice();
next.splice(Math.min(pending.index, next.length), 0, pending.pack);
return next;
});
setUndoDelete((current) => (current?.id === id ? null : current));
};
const commitDeletedPack = (id: string) => {
const pending = pendingDeletes.current.get(id);
if (!pending || pending.committing) return;
pending.committing = true;
setUndoDelete((current) => (current?.id === id ? null : current));
void deleteStylePack(id)
.then(() => {
pendingDeletes.current.delete(id);
return loadPacks();
})
.catch((deleteError) => {
pendingDeletes.current.delete(id);
setPacks((current) => {
if (current.some((pack) => pack.id === id)) return current;
const next = current.slice();
next.splice(Math.min(pending.index, next.length), 0, pending.pack);
return next;
});
showSaveStatus('failed', t('style.pack.deleteFailed', { err: String(deleteError) }));
});
};
const handleDeleteImportedPack = (pack: StylePack) => {
if (pack.kind !== 'imported' || pendingDeletes.current.has(pack.id)) return;
const index = packs.findIndex((item) => item.id === pack.id);
const name = getStylePackPresentation(pack, t).name;
setPacks((current) => current.filter((item) => item.id !== pack.id));
if (editorOpen && selectedId === pack.id) startEditorClose();
const timer = window.setTimeout(() => commitDeletedPack(pack.id), 6000);
pendingDeletes.current.set(pack.id, { pack, timer, index, committing: false });
setUndoDelete({ id: pack.id, name });
};
const handleDeleteImported = async () => {
@@ -754,7 +784,15 @@ export function Style() {
{/* 控制台卡右上角锚定 —— 与「风格市场 / 刷新 / 导入 ZIP」按钮同区;
淡蓝 pill 只闪现 0.8s,不长期遮挡按钮。 */}
<SavedToast saveState={saveState} message={saveMessage} />
<SavedToast
saveState={undoDelete ? 'saved' : saveState}
message={
undoDelete ? t('style.pack.deleteSuccess', { name: undoDelete.name }) : saveMessage
}
actionLabel={undoDelete ? t('style.pack.undoDelete') : undefined}
onAction={undoDelete ? () => restoreDeletedPack(undoDelete.id) : undefined}
durationMs={undoDelete ? 6000 : undefined}
/>
<Card
padding={0}
@@ -929,7 +967,7 @@ export function Style() {
flexDirection: 'column',
textAlign: 'left',
position: 'relative',
border: '0.5px solid',
border: isCurrentForView ? '1.5px solid' : '0.5px solid',
borderColor: isCurrentForView
? 'var(--ol-style-card-border-active)'
: 'var(--ol-style-card-border)',
@@ -989,9 +1027,19 @@ export function Style() {
</span>
)}
{isCurrentForView && (
<Pill tone="dark" size="sm">
{t('style.pack.current')}
</Pill>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
color: 'var(--ol-blue)',
}}
>
<Icon name="check" size={14} />
<Pill tone="outline" size="sm">
{t('style.pack.current')}
</Pill>
</span>
)}
</div>
<div
@@ -1042,17 +1090,27 @@ export function Style() {
? t('style.pack.writtenPolish')
: t(`style.modes.${pack.baseMode}.name`)}
</Pill>
{presentation.tags.slice(0, 1).map((tag) => (
<Pill key={`${pack.id}-${tag}`} tone="default" size="sm">
{tag}
</Pill>
))}
{presentation.tags
.filter(
(tag) =>
tag !== presentation.name &&
tag !==
(workflowView === 'selection'
? t('style.pack.writtenPolish')
: t(`style.modes.${pack.baseMode}.name`)),
)
.slice(0, 1)
.map((tag) => (
<Pill key={`${pack.id}-${tag}`} tone="default" size="sm">
{tag}
</Pill>
))}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 'auto' }}>
<Btn
size="sm"
variant={isCurrentForView ? 'soft' : 'ghost'}
variant={isCurrentForView ? 'soft' : 'blue'}
disabled={isCurrentForView || busy === 'activating'}
onClick={() =>
void (workflowView === 'selection'
+19 -7
View File
@@ -67,6 +67,7 @@ export function Vocab() {
const prevCardTops = useRef(new Map<string, number>());
const [removingIds, setRemovingIds] = useState<Set<string>>(new Set());
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [selecting, setSelecting] = useState(false);
const [batchBusy, setBatchBusy] = useState(false);
const refresh = async () => {
@@ -453,7 +454,18 @@ export function Vocab() {
</button>
))}
</div>
<label className="ol-vocab-select-all">
<Btn
size="sm"
onClick={() => {
setSelecting((current) => {
if (current) setSelectedIds(new Set());
return !current;
});
}}
>
{selecting ? t('vocab.doneSelecting') : t('vocab.selecting')}
</Btn>
<label className="ol-vocab-select-all" hidden={!selecting}>
<input
type="checkbox"
disabled={batchBusy || visibleEntries.length === 0}
@@ -600,6 +612,7 @@ export function Vocab() {
auto={sourceOf(entry) === 'auto'}
removing={removingIds.has(entry.id) || batchBusy}
selected={selectedIds.has(entry.id)}
selecting={selecting}
onSelect={() => toggleSelection(entry.id)}
cardRef={(element) => {
if (element) cardRefs.current.set(entry.id, element);
@@ -920,6 +933,7 @@ interface WordCardProps {
onToggle: () => void;
onEdit: () => void;
selected: boolean;
selecting: boolean;
onSelect: () => void;
}
@@ -929,6 +943,7 @@ function WordCard({
auto,
removing,
selected,
selecting,
cardRef,
onToggle,
onEdit,
@@ -942,19 +957,16 @@ function WordCard({
className="ol-word-card"
data-disabled={enabled ? undefined : 'true'}
data-selected={selected ? 'true' : undefined}
data-selecting={selecting ? 'true' : undefined}
style={removing ? { pointerEvents: 'none' } : undefined}
>
<span className="ol-word-card-icon" aria-hidden>
<Icon name={auto ? 'sparkle' : 'feather'} size={14} />
</span>
<button
type="button"
className="ol-word-card-text"
onClick={onToggle}
title={enabled ? t('vocab.tipDisabled') : t('vocab.tipEnabled')}
>
<button type="button" className="ol-word-card-text" onClick={onToggle} title={entry.phrase}>
{entry.phrase}
</button>
{!enabled && <span className="ol-word-card-state">{t('vocab.disabledWord')}</span>}
<span className="ol-word-card-hits">{entry.hits}</span>
<span className="ol-word-card-actions">
<Tooltip content={t('vocab.edit')} placement="top">
+6 -4
View File
@@ -235,6 +235,8 @@ export function Btn({
sm: { padding: '5px 10px', fontSize: 13 },
md: { padding: '7px 14px', fontSize: 13.5 },
};
// 主按钮禁用时改成浅灰,避免半透明深色仍像可以点击。
const muted = disabled && (variant === 'primary' || variant === 'blue');
return (
<button
onClick={disabled ? undefined : onClick}
@@ -246,15 +248,15 @@ export function Btn({
display: 'inline-flex',
alignItems: 'center',
gap: 6,
background: v.bg,
color: v.color,
background: muted ? 'var(--ol-control-muted)' : v.bg,
color: muted ? 'var(--ol-ink-3)' : v.color,
border: v.bd === 'transparent' ? '0.5px solid transparent' : `0.5px solid ${v.bd}`,
borderRadius: 8,
boxShadow: v.sh,
boxShadow: muted ? 'none' : v.sh,
fontFamily: 'inherit',
fontWeight: 500,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.55 : 1,
opacity: disabled && !muted ? 0.55 : 1,
transition:
'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft), transform 0.12s var(--ol-motion-quick)',
...sizes[size],
File diff suppressed because it is too large. Load diff
@@ -764,7 +764,9 @@ export function ChannelList({
{t(
testingIds[channel.id]
? 'settings.channels.verifying'
: 'settings.channels.verify',
: channel.lastTest && !channel.lastTest.ok
? 'settings.channels.reverify'
: 'settings.channels.verify',
)}
</Btn>
)}
@@ -877,13 +879,22 @@ function ChannelTestResult({
<>
<span
style={{
color: stale ? 'var(--ol-ink-3)' : passed ? 'var(--ol-ok)' : 'var(--ol-warn)',
color: stale ? 'var(--ol-ink-2)' : passed ? 'var(--ol-ok)' : 'var(--ol-err)',
}}
>
{passed
? t('settings.channels.passed')
: t('settings.channels.failed', { reason: shortErrorLabel(last?.error ?? null, t) })}
: shortErrorLabel(last?.error ?? null, t) === t('settings.channels.errGeneric')
? t('settings.channels.failedPlain')
: t('settings.channels.failed', {
reason: shortErrorLabel(last?.error ?? null, t),
})}
</span>
{!passed && (
<span style={{ color: 'var(--ol-ink-2)' }}>
{t('settings.channels.failureKeepsEnabled')}
</span>
)}
{passed && elapsed != null && (
<span>{t('settings.channels.elapsed', { ms: elapsed })}</span>
)}
@@ -164,6 +164,7 @@ const dependencies: Record<string, any> = {
Modal: 'modal',
Btn: 'button',
Card: 'card',
Toggle: 'toggle',
isTauri: true,
marketplaceAuthStatus: async () => ({ signedIn: true }),
cloudSyncE2eeStatus: async () => deferredStatus ?? { ...server },
@@ -276,7 +277,7 @@ try {
assert.equal(find(consent, (node) => node.type === 'button').props.disabled, true);
const protocolHooks = new Hooks();
const protocolProps = { busy: false, onConfirm() {}, onBack() {} };
const protocolProps = { busy: false, onConfirm() {}, onCancel() {} };
let protocolTree = protocolHooks.render(() => components.ProtocolWarning(protocolProps));
const confirmProtocol = () =>
find(protocolTree, (node) => node.props.children === 'cloudSyncE2ee.protocolConfirm');
@@ -296,11 +297,10 @@ try {
render();
await settle();
let tree = render();
assert.equal(find(tree, (node) => node.props.role === 'switch').props.checked, false);
const syncSwitch = (view: unknown) => find(view, (node) => node.type === 'toggle');
assert.equal(syncSwitch(tree).props.on, false);
assert.deepEqual(calls, [], 'mount must not enable, upload or consent');
find(tree, (node) => node.props.role === 'switch').props.onChange({
currentTarget: { checked: true },
});
syncSwitch(tree).props.onToggle(true);
tree = render();
find(tree, (node) => node.type === components.ConsentForm).props.onConfirm();
await settle();
@@ -310,9 +310,12 @@ try {
[],
'scope consent alone must not prepare or upload before the privacy warning',
);
find(tree, (node) => node.type === components.ProtocolWarning).props.onBack();
find(tree, (node) => node.type === components.ProtocolWarning).props.onCancel();
tree = render();
assert.deepEqual(calls, [], 'canceling the warning must not perform native sync work');
assert(!nodes(tree).some((node) => node.type === components.ProtocolWarning));
syncSwitch(tree).props.onToggle(true);
tree = render();
assert.deepEqual(calls, [], 'returning from the warning must not perform native sync work');
find(tree, (node) => node.type === components.ConsentForm).props.onConfirm();
tree = render();
find(tree, (node) => node.type === components.ProtocolWarning).props.onConfirm();
@@ -387,7 +390,7 @@ try {
await settle();
deferredStatus = null;
tree = render();
assert.equal(find(tree, (node) => node.props.role === 'switch').props.checked, true);
assert.equal(syncSwitch(tree).props.on, true);
events.get('cloud-sync-e2ee:conflict')!({
payload: {
sequence: (BigInt(newer.sequence) + 1n).toString(),
@@ -441,9 +444,7 @@ try {
await settle();
tree = fresh();
assert.deepEqual(calls, []);
find(tree, (node) => node.props.role === 'switch').props.onChange({
currentTarget: { checked: true },
});
find(tree, (node) => node.type === 'toggle').props.onToggle(true);
tree = fresh();
find(tree, (node) => node.type === components.ConsentForm).props.onConfirm();
await settle();
@@ -4,6 +4,7 @@ import { Icon } from '../../components/Icon';
import { GithubLoginModal } from '../../components/GithubLoginModal';
import { Modal } from '../../components/ui/Modal';
import { Btn, Card } from '../_atoms';
import { Toggle } from './shared';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { marketplaceAuthStatus } from '../../lib/ipc';
import { isTauri } from '../../lib/ipc/shared';
@@ -70,6 +71,51 @@ const categoryKeys = [
function categoryKey(kind: string): string {
return categoryKeys.some((key) => key === kind) ? kind : 'other';
}
type SetupFocus = 'enable' | 'password' | 'unlock' | 'closed' | 'done' | 'preparing' | 'state';
const REFRESH_ERRORS = new Set([
'localData',
'unknown',
'network',
'tooLarge',
'rolledBack',
'changed',
'accountChanged',
'invalidData',
'rateLimited',
'unavailable',
]);
function setupFocus(
status: EncryptedSyncStatus,
signedIn: boolean,
preparing: boolean,
reached: 'password' | 'unlock' | null,
): SetupFocus {
if (preparing) return 'preparing';
if (!signedIn || status.syncState === 'sign_in_required' || status.authState === 'expired')
return 'enable';
if (
status.syncState === 'ready' ||
status.syncState === 'pending' ||
status.syncState === 'syncing' ||
status.syncState === 'conflict' ||
status.syncState === 'failed' ||
status.syncState === 'outcome_unknown' ||
status.syncState === 'recovery_required' ||
status.recoveryRequired
)
return 'state';
const noSnapshot = status.hasCloudSnapshot !== true;
const locked = status.keyState !== 'unlocked';
if (!status.enabled) {
if (reached === 'password' || (noSnapshot && status.consentVersion)) return 'password';
if (reached === 'unlock' || (status.hasCloudSnapshot === true && locked)) return 'unlock';
if (status.hasCloudSnapshot === true && !locked) return 'closed';
return 'enable';
}
if (locked || reached === 'unlock') return 'unlock';
if (noSnapshot) return 'password';
return 'done';
}
function localError(reason: string): unknown {
return { details: { reason } };
}
@@ -89,6 +135,8 @@ export function CloudSyncSection() {
const [notice, setNotice] = useState<Notice>(null);
const [dialog, setDialog] = useState<Dialog | null>(null);
const [showLogin, setShowLogin] = useState(false);
const [preparingStep, setPreparingStep] = useState(false);
const [setupReached, setSetupReached] = useState<'password' | 'unlock' | null>(null);
const current = useRef<EncryptedSyncStatus | null>(null);
const watermark = useRef('0');
const alive = useRef(false);
@@ -292,8 +340,10 @@ export function CloudSyncSection() {
prepared.status.remoteRevision === null
)
throw localError('invalid_response');
setSetupReached('password');
setDialog({ kind: 'create', observedRevision: prepared.status.remoteRevision });
} else if (prepared.nextStep === 'unlock') {
setSetupReached('unlock');
setDialog({ kind: 'unlock', intent });
} else if (prepared.nextStep === 'restore_review') {
await previewRestore(intent === 'enable', valid);
@@ -304,6 +354,8 @@ export function CloudSyncSection() {
const next = await cloudSyncE2eeSetEnabled(true);
if (!valid()) return;
acceptStatus(next);
if (next.enabled && next.keyState === 'unlocked' && next.hasCloudSnapshot === true)
setSetupReached(null);
}
if (valid()) {
setDialog(null);
@@ -426,6 +478,26 @@ export function CloudSyncSection() {
const available = status !== null;
const lastSync = status?.lastSuccessfulSyncAt ? new Date(status.lastSuccessfulSyncAt) : null;
const lastError = status?.lastError ? encryptedSyncErrorKey(status.lastError) : null;
const noticeError = notice?.error ? notice.key.replace(/^errors\./, '') : null;
const visibleError = noticeError ?? (!notice ? lastError : null);
const focus = status
? setupFocus(status, signedIn, preparingStep, setupReached)
: signedIn
? 'enable'
: 'enable';
const focusTitle =
focus === 'state' && status
? `cloudSyncE2ee.states.${status.recoveryRequired ? 'recovery_required' : status.syncState}`
: `cloudSyncE2ee.step${focus[0].toUpperCase()}${focus.slice(1)}Title`;
const focusDetail =
focus === 'state'
? 'cloudSyncE2ee.stepFollowDetail'
: `cloudSyncE2ee.step${focus[0].toUpperCase()}${focus.slice(1)}Detail`;
const canSync =
!working &&
!status?.recoveryRequired &&
(Boolean(status?.pendingOperationId) ||
(Boolean(status?.enabled) && unlocked && status?.syncState !== 'conflict'));
const dialogTitle =
dialog?.kind === 'create'
? 'createTitle'
@@ -468,18 +540,15 @@ export function CloudSyncSection() {
<Icon name="refresh" size={16} />
</button>
</header>
<label className="ol-cloud-sync-account" style={{ justifyContent: 'space-between' }}>
<strong>{t('cloudSyncE2ee.enable')}</strong>
<input
type="checkbox"
role="switch"
checked={status?.enabled ?? false}
<div className="ol-cloud-sync-account" style={{ justifyContent: 'space-between' }}>
<strong id="cloud-sync-e2ee-enable">{t('cloudSyncE2ee.enable')}</strong>
<Toggle
on={status?.enabled ?? false}
label={t('cloudSyncE2ee.enable')}
disabled={!available || !signedIn || working || loading || status?.recoveryRequired}
onChange={(event) =>
event.currentTarget.checked ? begin('enable') : setDialog({ kind: 'disable' })
}
onToggle={(next) => (next ? begin('enable') : setDialog({ kind: 'disable' }))}
/>
</label>
</div>
{loading && <p role="status">{t('cloudSyncE2ee.loading')}</p>}
{!loading && !signedIn && (
<Btn
@@ -509,17 +578,44 @@ export function CloudSyncSection() {
)}
{status && (
<div className="ol-cloud-sync-backup" role="status" aria-live="polite">
<strong>{t(`cloudSyncE2ee.states.${status.syncState}`)}</strong>
<p>{t(unlocked ? 'cloudSyncE2ee.keyUnlocked' : 'cloudSyncE2ee.keyLocked')}</p>
<p>
{t(
status.hasCloudSnapshot === true
? 'cloudSyncE2ee.hasSnapshot'
: status.hasCloudSnapshot === false
? 'cloudSyncE2ee.noSnapshot'
: 'cloudSyncE2ee.snapshotUnknown',
<strong>{t(focusTitle)}</strong>
<p>{t(focusDetail)}</p>
<div className="ol-cloud-sync-status-line">
<span>{t(unlocked ? 'cloudSyncE2ee.keyUnlocked' : 'cloudSyncE2ee.keyLocked')}</span>
{!unlocked && signedIn && (
<Btn
size="sm"
variant="blue"
disabled={working || loading}
onClick={() => begin(status.hasCloudSnapshot ? 'unlock' : 'enable')}
>
{t(
status.hasCloudSnapshot ? 'cloudSyncE2ee.unlock' : 'cloudSyncE2ee.setPassword',
)}
</Btn>
)}
</p>
</div>
<div className="ol-cloud-sync-status-line">
<span>
{t(
status.hasCloudSnapshot === true
? 'cloudSyncE2ee.hasSnapshot'
: status.hasCloudSnapshot === false
? 'cloudSyncE2ee.noSnapshot'
: 'cloudSyncE2ee.snapshotUnknown',
)}
</span>
{status.hasCloudSnapshot === false && signedIn && unlocked && (
<Btn
size="sm"
variant="blue"
disabled={working || loading}
onClick={() => begin('enable')}
>
{t('cloudSyncE2ee.setPassword')}
</Btn>
)}
</div>
{lastSync && Number.isFinite(lastSync.getTime()) && (
<small>
{t('cloudSyncE2ee.lastSync', {
@@ -537,13 +633,8 @@ export function CloudSyncSection() {
</Btn>
)}
<Btn
variant="primary"
disabled={
working ||
status?.recoveryRequired ||
(!status?.pendingOperationId &&
(!status?.enabled || !unlocked || status?.syncState === 'conflict'))
}
variant={canSync ? 'blue' : 'soft'}
disabled={!canSync}
onClick={() => simple(cloudSyncE2eeSyncNow)}
>
{t(
@@ -598,19 +689,38 @@ export function CloudSyncSection() {
{taskControls}
</div>
)}
{notice ? (
<p
role={notice.error ? 'alert' : 'status'}
className={notice.error ? 'ol-cloud-sync-error' : undefined}
>
{t(`cloudSyncE2ee.${notice.key}`)}
</p>
) : (
lastError && (
{notice && !notice.error ? <p role="status">{t(`cloudSyncE2ee.${notice.key}`)}</p> : null}
{visibleError && (
<div className="ol-cloud-sync-status-line">
<p role="alert" className="ol-cloud-sync-error">
{t(`cloudSyncE2ee.errors.${lastError}`)}
{t(`cloudSyncE2ee.errors.${visibleError}`)}
</p>
)
{REFRESH_ERRORS.has(visibleError) && (
<Btn
size="sm"
variant="blue"
disabled={loading || working}
onClick={() => void load()}
>
{t('cloudSyncE2ee.refresh')}
</Btn>
)}
{(visibleError === 'unlock' ||
visibleError === 'invalidPassword' ||
visibleError === 'secureStorage') &&
signedIn && (
<Btn
size="sm"
variant="blue"
disabled={working}
onClick={() => begin(status?.hasCloudSnapshot ? 'unlock' : 'enable')}
>
{t(
status?.hasCloudSnapshot ? 'cloudSyncE2ee.unlock' : 'cloudSyncE2ee.setPassword',
)}
</Btn>
)}
</div>
)}
{status?.lastError?.retryAfterSeconds != null &&
Number.isSafeInteger(status.lastError.retryAfterSeconds) &&
@@ -636,6 +746,7 @@ export function CloudSyncSection() {
key={dialog.kind}
title={t(`cloudSyncE2ee.${dialogTitle}`)}
busy={working}
showClose={dialog.kind !== 'protocol'}
onClose={closeDialog}
>
{notice?.error && (
@@ -655,15 +766,21 @@ export function CloudSyncSection() {
{dialog.kind === 'protocol' && (
<ProtocolWarning
busy={working}
onBack={() => {
onCancel={() => {
setNotice(null);
setDialog({ kind: 'consent', intent: dialog.intent });
setDialog(null);
}}
onConfirm={() => {
const intent = dialog.intent;
setNotice(null);
setPreparingStep(true);
setDialog(null);
void perform(async (valid) => {
await routePreparation(await prepareEncryptedSync(), intent, valid);
}).finally(() => {
if (alive.current) setPreparingStep(false);
});
}}
onConfirm={() =>
void perform(async (valid) =>
routePreparation(await prepareEncryptedSync(), dialog.intent, valid),
)
}
/>
)}
{(dialog.kind === 'create' || dialog.kind === 'unlock' || dialog.kind === 'password') && (
@@ -713,7 +830,7 @@ export function CloudSyncSection() {
}
/>
)}
{working && (
{working && status?.taskId && dialog.kind !== 'protocol' && (
<div className="ol-cloud-sync-actions">
<span role="status">{t('cloudSyncE2ee.working')}</span>
{taskControls}
@@ -761,17 +878,17 @@ function ConsentForm({ busy, onConfirm }: { busy: boolean; onConfirm: () => void
function ProtocolWarning({
busy,
onConfirm,
onBack,
onCancel,
}: {
busy: boolean;
onConfirm: () => void;
onBack: () => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [acknowledged, setAcknowledged] = useState(false);
return (
<div style={{ display: 'grid', gap: 18 }}>
<p style={{ margin: 0, color: 'var(--ol-ink-3)', lineHeight: 1.65 }}>
<p style={{ margin: 0, color: 'var(--ol-ink-2)', lineHeight: 1.65 }}>
{t('cloudSyncE2ee.protocolIntro')}
</p>
<div
@@ -789,7 +906,7 @@ function ProtocolWarning({
<h4 style={{ fontSize: 14, margin: '0 0 6px', color: 'var(--ol-ink)' }}>
{t(`cloudSyncE2ee.protocol${section}Title`)}
</h4>
<p style={{ margin: 0, lineHeight: 1.7, color: 'var(--ol-ink-3)' }}>
<p style={{ margin: 0, lineHeight: 1.7, color: 'var(--ol-ink-2)' }}>
{t(`cloudSyncE2ee.protocol${section}`)}
</p>
</section>
@@ -805,10 +922,10 @@ function ProtocolWarning({
{t('cloudSyncE2ee.protocolCheck')}
</label>
<div className="ol-cloud-sync-actions" style={{ justifyContent: 'flex-end' }}>
<Btn disabled={busy} onClick={onBack}>
{t('cloudSyncE2ee.protocolBack')}
<Btn disabled={busy} onClick={onCancel}>
{t('common.cancel')}
</Btn>
<Btn variant="primary" disabled={!acknowledged || busy} onClick={onConfirm}>
<Btn variant="blue" disabled={!acknowledged || busy} onClick={onConfirm}>
{t('cloudSyncE2ee.protocolConfirm')}
</Btn>
</div>
@@ -1157,11 +1274,13 @@ function DeleteConfirmation({
function SyncDialog({
title,
busy,
showClose = true,
onClose,
children,
}: {
title: string;
busy: boolean;
showClose?: boolean;
onClose: () => void;
children: ReactNode;
}) {
@@ -1233,22 +1352,19 @@ function SyncDialog({
<h3 id={titleId} style={{ margin: 0, fontSize: 17 }}>
{title}
</h3>
<button
type="button"
disabled={busy}
onClick={onClose}
aria-label={t('common.close')}
className="ol-tool-close"
>
<Icon name="close" size={17} />
</button>
{showClose && (
<button
type="button"
disabled={busy}
onClick={onClose}
aria-label={t('common.close')}
className="ol-tool-close"
>
<Icon name="close" size={17} />
</button>
)}
</header>
{children}
<div>
<Btn disabled={busy} onClick={onClose}>
{t('common.close')}
</Btn>
</div>
</div>
</Modal>
);
@@ -678,7 +678,7 @@ export function RecordingInputSection() {
)}
{/* ─── 启动(折叠) ──────────────────────────────────────────── */}
{showDesktopStartup && (
<Collapsible title={t('settings.recording.startupGroupTitle')}>
<Collapsible title={t('settings.recording.startupGroupTitle')} defaultOpen>
<AutostartRow />
<SettingRow label={t('settings.recording.startMinimizedLabel')}>
<Toggle on={prefs.startMinimized} onToggle={onStartMinimizedChange} />
@@ -738,7 +738,10 @@ function AutostartRow() {
};
return (
<SettingRow label={t('settings.recording.startupAtBoot')}>
<SettingRow
label={t('settings.recording.startupAtBoot')}
desc={t('settings.recording.startupAtBootDesc')}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{loaded ? <Toggle on={enabled} onToggle={onToggle} /> : null}
{error && (
+21 -3
View File
@@ -164,10 +164,27 @@ export function SettingRow({ label, desc, children, controlWidth }: SettingRowPr
);
}
export function Toggle({ on, onToggle }: { on: boolean; onToggle?: (next: boolean) => void }) {
export function Toggle({
on,
onToggle,
disabled = false,
label,
}: {
on: boolean;
onToggle?: (next: boolean) => void;
disabled?: boolean;
label?: string;
}) {
return (
<button
onClick={() => onToggle?.(!on)}
type="button"
role="switch"
aria-checked={on}
aria-label={label}
disabled={disabled}
onClick={() => {
if (!disabled) onToggle?.(!on);
}}
style={{
position: 'relative',
// 此前写死 flex: 0 0 36px,在列方向的设置行包装里
@@ -182,7 +199,8 @@ export function Toggle({ on, onToggle }: { on: boolean; onToggle?: (next: boolea
border: 0,
background: on ? 'var(--ol-blue)' : 'var(--ol-toggle-off-bg)',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.06)',
cursor: 'default',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.45 : 1,
transition: 'background 0.16s var(--ol-motion-quick)',
}}
>
@@ -141,6 +141,15 @@ export function ServicesTab() {
type="button"
aria-pressed={selectedView === id}
onClick={() => setView(id)}
aria-label={
required
? `${t(`modal.serviceViews.${id}`)}。${t(
configured
? 'modal.serviceViews.statusConfigured'
: 'modal.serviceViews.statusMissing',
)}`
: t(`modal.serviceViews.${id}`)
}
title={
required
? t(
+39 -4
View File
@@ -160,6 +160,17 @@ a {
}
}
@keyframes ol-modal-card-out {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(6px) scale(0.985);
}
}
/* 设置弹窗切换 tab 时右侧内容的轻微淡入。 */
@keyframes ol-tab-fade {
from {
@@ -719,7 +730,15 @@ html[data-ol-conservative-layout='true'] .ol-conservative-scope .ol-select-lite-
border-color: var(--ol-line-strong);
}
.ol-word-card[data-disabled='true'] {
opacity: 0.55;
background: var(--ol-surface-2);
}
.ol-word-card[data-disabled='true'] .ol-word-card-text {
color: var(--ol-ink-2);
}
.ol-word-card-state {
flex-shrink: 0;
font-size: 11px;
color: var(--ol-ink-2);
}
.ol-word-card-icon {
flex-shrink: 0;
@@ -817,7 +836,7 @@ html[data-ol-conservative-layout='true'] .ol-conservative-scope .ol-select-lite-
cursor: pointer;
}
/* 必配服务(语言模型/语音识别)tab 上的状态点:未配置红、已配置黄。 */
/* 必配服务(语言模型/语音识别)tab 上的状态点:未启用红、已有启用渠道绿。 */
.ol-service-status-dot {
width: 7px;
height: 7px;
@@ -829,7 +848,7 @@ html[data-ol-conservative-layout='true'] .ol-conservative-scope .ol-select-lite-
background: var(--ol-red, #ef4444);
}
.ol-service-status-dot[data-state='ok'] {
background: var(--ol-warn);
background: var(--ol-ok, #16a34a);
}
.ol-service-views > button[aria-pressed='true'] {
@@ -1417,6 +1436,10 @@ html[data-ol-platform='android'] .ol-capsule-pill {
cursor: pointer;
flex-shrink: 0;
}
.ol-word-card:not([data-selecting='true']) .ol-word-card-select,
.ol-vocab-select-all[hidden] {
display: none;
}
.ol-word-card-select {
margin: 0 0 0 8px;
}
@@ -1550,7 +1573,19 @@ html:lang(de) {
font-size: 13px;
}
.ol-cloud-sync-backup small {
color: var(--ol-ink-3);
color: var(--ol-ink-2);
}
.ol-cloud-sync-status-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
}
.ol-cloud-sync-status-line > span,
.ol-cloud-sync-status-line > p {
margin: 0;
min-width: 0;
}
.ol-cloud-sync-actions {
display: flex;
+9
View File
@@ -12,11 +12,19 @@
.ol-dialog-overlay {
backdrop-filter: blur(8px) saturate(115%);
-webkit-backdrop-filter: blur(8px) saturate(115%);
animation: ol-modal-backdrop-in 0.18s var(--ol-motion-soft) both;
}
.ol-dialog-overlay.is-closing {
animation: ol-modal-backdrop-out 0.18s var(--ol-motion-soft) both;
}
.ol-dialog-card {
color: var(--ol-ink);
isolation: isolate;
background-image: linear-gradient(150deg, var(--ol-surface) 45%, var(--ol-dialog-wash));
animation: ol-modal-card-in 0.26s var(--ol-motion-spring) both;
}
.ol-dialog-overlay.is-closing .ol-dialog-card {
animation: ol-modal-card-out 0.18s var(--ol-motion-soft) both;
}
.ol-dialog-card :is(button, input, textarea, select):focus-visible {
outline: 2px solid var(--ol-blue);
@@ -297,6 +305,7 @@
.ol-tool-window,
.ol-tool-close,
.ol-tool-button,
.ol-dialog-overlay,
.ol-dialog-card {
animation: none !important;
transition: none !important;
+7 -8
View File
@@ -31,10 +31,9 @@
/* Ink(zinc 阶梯) */
--ol-ink: #09090b;
--ol-ink-2: #3f3f46;
--ol-ink-3: #71717a;
/* zinc 规格:ink-4 取 zinc-400(对白底约 2.6:1),仅用于占位符/装饰性弱文字;
需要满足 AA 的弱化正文请用 ink-3(zinc-500,对白底约 4.7:1)及以上。 */
--ol-ink-4: #a1a1aa;
--ol-ink-3: #52525b;
/* 辅助说明至少要达到小字号的对比度。占位符仍用 ink-4。 */
--ol-ink-4: #71717a;
--ol-ink-5: #d4d4d8;
/* Blue accent */
@@ -191,9 +190,9 @@
--ol-overlay-bg: rgba(15, 17, 22, 0.32);
--ol-style-card-bg: #fafafa;
--ol-style-card-bg-active: #e4e4e7;
--ol-style-card-bg-active: #ffffff;
--ol-style-card-border: #e4e4e7;
--ol-style-card-border-active: #bfc0c5;
--ol-style-card-border-active: #2563eb;
--ol-style-card-ink: #09090b;
--ol-style-card-icon-bg: rgba(228, 228, 231, 0.62);
--ol-style-card-icon-bg-active: #d4d4d8;
@@ -347,9 +346,9 @@
--ol-overlay-bg: rgba(5, 8, 13, 0.64);
--ol-style-card-bg: #252528;
--ol-style-card-bg-active: #37373c;
--ol-style-card-bg-active: #252528;
--ol-style-card-border: rgba(226, 232, 240, 0.09);
--ol-style-card-border-active: #62626a;
--ol-style-card-border-active: #74b7ff;
--ol-style-card-ink: #fafafa;
--ol-style-card-icon-bg: rgba(226, 232, 240, 0.07);
--ol-style-card-icon-bg-active: #45454d;