Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# OpenLess 2.0 架构

状态:canonical,当前实现说明;更新:2026-09-08。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。
状态:canonical,当前实现说明;更新:2026-09-13。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。

## 1. 分层与工作区

Expand Down Expand Up @@ -69,6 +69,10 @@ Tauri 在 `src-tauri/src/coordinator.rs` 构造 Core,`core_adapters.rs` 组装

Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺寸与点击范围在保存偏好时同步。胶囊按显示器工作区底部定位,避开未自动隐藏的 Dock/任务栏;可见期间重新检查工作区。带正文的浮窗使用不透明底色,聊天面板另叠加细噪点纹理,圆角外部仍保留透明区域。

思考动画覆盖转写、润色和原生文字写入,输入完成后才收尾。macOS 流式键盘输入在可读取 AX 光标的控件上等待原控件光标到达本批文字末尾,再完成写入和恢复输入源;仅读选区范围,不读正文。不可读、提交型 Return 或等待超过 10 秒时回到按键发送完成语义,本次会话停止继续探测该控件。目标应用的实际输入表现仍需设备验收。

选区直接润色在捕获文字和原输入目标后显示处理中提示,重复快捷键的 Busy 返回不覆盖该提示。已有语音选区入口在松开快捷键后继续显示思考动画,直到处理/替换完成,或交给确认和预览面板。录音提示音的 Web Audio context 在恢复超时或音频时钟冻结时丢弃并最多重试一次,重试沿用原请求的取消和迟到边界。

界面启动等待所选语言资源就绪;语言选择持久化到 `ol.locale`,其他 WebView 通过存储事件同步,日期、数字和默认风格展示随语言变化。用户修改的风格名称、说明和内容保持原文。旧版两种强制排版字段只保留数据兼容,界面清除其布局效果,窄屏改由响应式布局处理。

## 6. 存储与外部服务
Expand Down
125 changes: 110 additions & 15 deletions openless-all/app/crates/openless-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8663,7 +8663,10 @@ mod tests {
)
.unwrap();
let first = backend.start().await.expect("first start must not fail");
let second = backend.start().await.expect("handshake start must not fail");
let second = backend
.start()
.await
.expect("handshake start must not fail");
assert!(first.backend.running);
assert!(second.backend.running);
let _ = data_dir;
Expand Down Expand Up @@ -10705,9 +10708,16 @@ mod tests {
assert_streaming_polish_deltas_flush_before_final_insert(true).await;
}

async fn assert_cancel_drains_native_insertion(
#[derive(Clone, Copy, PartialEq, Eq)]
enum NativeInsertionEnd {
Complete,
Cancel,
DropStopAndCancel,
}

async fn assert_native_insertion_lifecycle(
streaming: bool,
drop_stop: bool,
ending: NativeInsertionEnd,
translation: bool,
) {
struct BlockingInsertion {
Expand Down Expand Up @@ -10740,10 +10750,12 @@ mod tests {
&self,
text: String,
) -> BoxFuture<'static, Result<InsertOutcome, BackendError>> {
let writing = self.write(text);
let writing = (!text.is_empty()).then(|| self.write(text));
let actions = Arc::clone(&self.actions);
boxed(async move {
writing.await?;
if let Some(writing) = writing {
writing.await?;
}
actions.lock().unwrap().push("input source restored");
Ok(InsertOutcome::Inserted)
})
Expand All @@ -10769,25 +10781,30 @@ mod tests {
let actions = Arc::new(Mutex::new(Vec::new()));
let started = Arc::new(tokio::sync::Semaphore::new(0));
let release = Arc::new(tokio::sync::Semaphore::new(0));
let data_dir = TestDataDir::new("stream-cancel-drain");
let data_dir = TestDataDir::new("native-insertion-lifecycle");
let prefix = "长文字🙂\r\n".repeat(256);
let output = format!("{prefix}{}", "等待最后一段🌍\n".repeat(256));
let host = Arc::new(FakeHost::default());
let mut deps = BackendDependencies::unsupported();
deps.host_actions = host.clone();
deps.credential_store = Arc::new(crate::credentials::InMemoryCredentialStore::default());
deps.text_inserter = Arc::new(BlockingInserter(Arc::new(BlockingInsertion {
actions: Arc::clone(&actions),
started: Arc::clone(&started),
release: Arc::clone(&release),
})));
deps.dictation_engine = Arc::new(
crate::testing::FixtureDictationEngine::successful("raw", "streamed")
.with_polish_deltas(if streaming {
crate::testing::FixtureDictationEngine::successful("raw", &output).with_polish_deltas(
if streaming {
vec![crate::types::PolishDelta {
text: "streamed".into(),
text: prefix,
offset: 0,
is_final: false,
}]
} else {
Vec::new()
}),
},
),
);
deps.task_spawner = Arc::new(TokioTaskSpawner);
let backend = Arc::new(
Expand All @@ -10807,6 +10824,7 @@ mod tests {
preferences.working_languages = vec!["简体中文".into()];
backend.set_preferences(preferences).unwrap();
backend.start().await.unwrap();
let mut events = backend.subscribe();
let session_id = backend
.start_dictation_with_options(DictationStartOptions {
translation_requested: translation,
Expand All @@ -10816,7 +10834,68 @@ mod tests {
.unwrap();
let stopping_backend = Arc::clone(&backend);
let stop = tokio::spawn(async move { stopping_backend.stop_dictation().await });
started.acquire().await.unwrap().forget();
tokio::time::timeout(std::time::Duration::from_secs(2), started.acquire())
.await
.unwrap()
.unwrap()
.forget();
if ending == NativeInsertionEnd::Complete {
// Hold both the streamed prefix and the final reconciliation tail.
// An LLM final delta must not end feedback before either native write.
for write_index in 0..if streaming { 2 } else { 1 } {
if write_index > 0 {
tokio::time::timeout(std::time::Duration::from_secs(2), started.acquire())
.await
.unwrap()
.unwrap()
.forget();
}
assert!(!stop.is_finished());
assert_eq!(
backend.snapshot().dictation.phase,
DictationPhase::Inserting
);
assert!(!host
.0
.lock()
.unwrap()
.contains(&HostAction::HideDictationFeedback));
while let Ok(event) = events.try_recv() {
assert!(!matches!(
event.kind,
BackendEventKind::DictationCompleted(_)
| BackendEventKind::DictationStateChanged(DictationStateSnapshot {
phase: DictationPhase::Completed | DictationPhase::Idle,
..
})
));
}
release.add_permits(1);
}
let result = tokio::time::timeout(std::time::Duration::from_secs(2), stop)
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(result.polished_text, output);
assert_eq!(
actions.lock().unwrap().last(),
Some(&"input source restored")
);
assert_eq!(
std::iter::from_fn(|| events.try_recv().ok())
.filter(|event| matches!(event.kind, BackendEventKind::DictationCompleted(_)))
.count(),
1
);
assert!(host
.0
.lock()
.unwrap()
.contains(&HostAction::HideDictationFeedback));
return;
}
let drop_stop = ending == NativeInsertionEnd::DropStopAndCancel;
if drop_stop {
stop.abort();
tokio::task::yield_now().await;
Expand Down Expand Up @@ -10855,22 +10934,38 @@ mod tests {

#[tokio::test]
async fn streaming_cancel_drains_native_write_before_restoring_and_releasing_voice() {
assert_cancel_drains_native_insertion(true, false, false).await;
assert_native_insertion_lifecycle(true, NativeInsertionEnd::Cancel, false).await;
}

#[tokio::test]
async fn final_insert_cancel_waits_for_the_committed_native_effect() {
assert_cancel_drains_native_insertion(false, false, false).await;
assert_native_insertion_lifecycle(false, NativeInsertionEnd::Cancel, false).await;
}

#[tokio::test]
async fn final_insert_cancellation_survives_a_dropped_stop_caller() {
assert_cancel_drains_native_insertion(false, true, false).await;
assert_native_insertion_lifecycle(false, NativeInsertionEnd::DropStopAndCancel, false)
.await;
}

#[tokio::test]
async fn streaming_translation_cancel_drains_native_write() {
assert_cancel_drains_native_insertion(true, false, true).await;
assert_native_insertion_lifecycle(true, NativeInsertionEnd::Cancel, true).await;
}

#[tokio::test]
async fn long_text_feedback_waits_for_stream_and_final_tail() {
assert_native_insertion_lifecycle(true, NativeInsertionEnd::Complete, false).await;
}

#[tokio::test]
async fn translation_feedback_waits_for_stream_and_final_tail() {
assert_native_insertion_lifecycle(true, NativeInsertionEnd::Complete, true).await;
}

#[tokio::test]
async fn non_streaming_feedback_waits_for_native_completion() {
assert_native_insertion_lifecycle(false, NativeInsertionEnd::Complete, false).await;
}

#[tokio::test]
Expand Down
154 changes: 154 additions & 0 deletions openless-all/app/scripts/audio-cue-runtime.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import assert from 'node:assert/strict';
import { mock } from 'node:test';
import { tsImport } from 'tsx/esm/api';
const { playRecordStartCue, stopAudioCue } = await tsImport(
'../src/lib/audioCue.ts',
import.meta.url,
);
// Exercise the actual playback lifecycle, including promises that never resolve.
let now = 0;
mock.timers.enable({ apis: ['setTimeout'] });
mock.method(performance, 'now', () => now);
function tick(ms) {
now += ms;
mock.timers.tick(ms);
}
class FakeContext {
get currentTime() {
return this.frozen ? 0 : now / 1000;
}
constructor() {
this.state = FakeContext.initialState;
this.frozen = false;
this.voices = 0;
this.closeCalls = 0;
this.resumeImpl = FakeContext.initialResumeImpl;
this.destination = {};
FakeContext.instances.push(this);
}
resume() {
return this.resumeImpl();
}
close() {
this.closeCalls++;
this.state = 'closed';
return Promise.resolve();
}
createOscillator() {
this.voices++;
return {
frequency: { setValueAtTime() {} },
connect: (gain) => gain,
start() {},
stop() {},
disconnect() {},
};
}
createGain() {
return {
gain: {
setValueAtTime() {},
exponentialRampToValueAtTime() {},
cancelScheduledValues() {},
},
connect() {},
disconnect() {},
};
}
}
FakeContext.instances = [];
FakeContext.initialState = 'running';
FakeContext.initialResumeImpl = () => new Promise(() => {});
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { AudioContext: FakeContext },
});
try {
FakeContext.initialState = 'suspended';
playRecordStartCue();
const stuck = FakeContext.instances[0];
let releaseOldResume;
stuck.resumeImpl = () => new Promise((resolve) => (releaseOldResume = resolve));
playRecordStartCue();
FakeContext.initialState = 'running';
tick(250);
const healthy = FakeContext.instances[1];
assert.equal(stuck.closeCalls, 1, 'a hung resume must close the old context');
assert.equal(healthy.voices, 2, 'the same recording recovers its cue');
stuck.state = 'running';
releaseOldResume();
await Promise.resolve();
assert.equal(stuck.voices, 0, 'late resolution cannot play through a discarded context');
tick(315);
// Every request invalidates old work, including a new synchronous playback.
healthy.state = 'suspended';
let releaseSuperseded;
healthy.resumeImpl = () => new Promise((resolve) => (releaseSuperseded = resolve));
playRecordStartCue();
healthy.state = 'running';
playRecordStartCue();
releaseSuperseded();
await Promise.resolve();
assert.equal(healthy.voices, 4, 'a superseded resume must not duplicate the newer cue');
tick(315);
healthy.frozen = true;
playRecordStartCue();
tick(315);
const recovered = FakeContext.instances[2];
assert.equal(healthy.closeCalls, 1, 'running with a frozen clock must recover');
assert.equal(recovered.voices, 2);
tick(315);
// The original stop/time boundary survives recreation, so a late retry stays silent.
recovered.state = 'suspended';
playRecordStartCue();
stopAudioCue();
tick(1000);
assert.equal(recovered.closeCalls, 1, 'even a dropped late cue discards its broken context');
assert.equal(FakeContext.instances.length, 3, 'stopped recordings do not replay late');
playRecordStartCue();
const next = FakeContext.instances[3];
assert.equal(next.voices, 2, 'the next recording still works after a dropped cue');
tick(315);
next.state = 'suspended';
next.resumeImpl = () => {
throw new Error('audio interrupted');
};
FakeContext.initialState = 'suspended';
assert.doesNotThrow(playRecordStartCue);
tick(250);
assert.equal(FakeContext.instances.length, 5, 'recovery retries at most once per request');
assert.equal(FakeContext.instances[4].closeCalls, 1, 'failed retry releases audio resources');

// The 400ms deadline belongs to the recording, not to each resume attempt.
playRecordStartCue();
let releaseRetry;
FakeContext.initialResumeImpl = () => new Promise((resolve) => (releaseRetry = resolve));
tick(250);
const retry = FakeContext.instances[6];
stopAudioCue();
tick(151);
retry.state = 'running';
releaseRetry();
await Promise.resolve();
assert.equal(retry.voices, 0, 'a recreated context cannot reset the late-cue deadline');
playRecordStartCue();
assert.equal(retry.voices, 2, 'dropping the old cue must not disable the next recording');
tick(315);

retry.state = 'suspended';
retry.resumeImpl = () => Promise.reject(new Error('device changed'));
FakeContext.initialState = 'running';
playRecordStartCue();
await Promise.resolve();
assert.equal(retry.closeCalls, 1, 'asynchronous resume rejection also releases the context');
assert.equal(FakeContext.instances[7].voices, 2, 'device-change recovery plays only one cue');
tick(315);
console.log('[audioCue.runtime.test] playback recovery assertions passed');
} finally {
stopAudioCue();
mock.restoreAll();
mock.timers.reset();
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else Reflect.deleteProperty(globalThis, 'window');
}
Loading
Loading