Skip to content
Closed
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
34 changes: 34 additions & 0 deletions memory-graph.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Memory Graph — OpenLess

- slug: openless
- path: F:/编程/openless
- updated: 2026-09-20

## Summary

Tauri 2 跨平台 AI 语音输入与选区助手,前端使用 React / TypeScript,原生层使用 Rust。

## Entities

- OpenLess (Project): 唯一工作副本为 F:/编程/openless。
- LiveTranscriptPill (Module): 共享实时转写胶囊,控制文字位置、入场和外框尺寸。
- insertTextAnimation (Module): 字素差分、传播延迟和胶囊目标几何的纯函数模型。

## Relations

- Capsule / TypelessCapsule --renders--> LiveTranscriptPill。
- LiveTranscriptPill --uses--> insertTextAnimation / framer-motion。

## Facts

- 文字使用稳定字素 key;水平位置以右边缘为锚,独立于垂直入场动画。
- 新字从下方淡入并轻微上弹;字距 1.4px,旧字按新增批次分为 2–4 字组向左传播,组间 26ms,传播延迟上限 96ms。
- 胶囊回中比左扩晚 140ms 启动,允许中途偏左,最终居中。
- 文本可视区域跟随实时胶囊宽度,长文本保留右侧并裁剪左侧。
- scripts/insert-text-motion-h5.mjs 在独立无头 Chrome 中验证轨迹、回中、快速纠错、长文本和减少动态效果。

## Decisions

- 流式原文默认开启,字号默认14px、范围12–20px;通过UserPreferences保存并由prefs:changed即时同步。
- 原文仅接收transcript_delta,忽略润色输出;关闭后保留波形与状态,原生窗口尺寸不变。
- 浏览器验证不能替代打包后的原生窗口体验验证。
1 change: 1 addition & 0 deletions openless-all/app/crates/openless-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2542,6 +2542,7 @@ impl OpenLessBackend {
&self.deps.task_spawner,
Arc::clone(&resources),
Arc::clone(&self.deps.dictation_engine).start_transcription_with_progress(
Arc::clone(&self.deps.task_spawner),
session_id,
Arc::clone(&context),
Arc::clone(&partials) as Arc<dyn TextStreamSink>,
Expand Down
95 changes: 59 additions & 36 deletions openless-all/app/crates/openless-core/src/asr/bailian.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,15 @@ const PER_ADDR_TCP_TIMEOUT: Duration = Duration::from_millis(1500);

fn default_port_for_request(
request: &tokio_tungstenite::tungstenite::handshake::client::Request,
) -> Result<u16, WsError> {
) -> Result<u16, Box<WsError>> {
let default_port = match request.uri().scheme_str() {
Some("ws") => 80,
Some("wss") => 443,
_ => {
return Err(WsError::Url(
tokio_tungstenite::tungstenite::error::UrlError::UnsupportedUrlScheme,
))
)
.into())
}
};
Ok(request.uri().port_u16().unwrap_or(default_port))
Expand All @@ -72,13 +73,14 @@ async fn connect_ws_to_addrs(
WsStream,
tokio_tungstenite::tungstenite::handshake::client::Response,
),
WsError,
Box<WsError>,
> {
if addrs.is_empty() {
return Err(WsError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no addresses for websocket endpoint",
)));
))
.into());
}

let mut last_err = None;
Expand All @@ -101,12 +103,14 @@ async fn connect_ws_to_addrs(
}
}

Err(last_err.unwrap_or_else(|| {
WsError::Io(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"no tcp candidate",
))
}))
Err(last_err
.unwrap_or_else(|| {
WsError::Io(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"no tcp candidate",
))
})
.into())
}

async fn connect_ws_prefer_ipv4(
Expand All @@ -116,7 +120,7 @@ async fn connect_ws_prefer_ipv4(
WsStream,
tokio_tungstenite::tungstenite::handshake::client::Response,
),
WsError,
Box<WsError>,
> {
let port = default_port_for_request(&request)?;
let host = request.uri().host().unwrap_or("").to_string();
Expand Down Expand Up @@ -528,36 +532,24 @@ impl BailianRealtimeASR {
.and_then(Value::as_i64)
.unwrap_or(0);

let mut delta: Option<String> = None;
{
let snapshot = {
let mut st = self.state.lock();
st.last_result_text = trimmed.to_string();

if is_sentence_final {
// 所有 final 结果(含 sentence_id == 0)都存入 final_segments。
// BTreeMap 覆盖语义保证同一 sentence_id 不会重复追加。
st.final_segments.insert(sentence_id, trimmed.to_string());
st.partial_segments.remove(&sentence_id);
} else {
let previous = st
.partial_segments
.get(&sentence_id)
.map(String::as_str)
.unwrap_or("");
delta = trimmed
.strip_prefix(previous)
.filter(|suffix| !suffix.is_empty())
.map(str::to_string);
st.partial_segments.insert(sentence_id, trimmed.to_string());
}
}
if let Some(delta) = delta {
if let Some(sink) = self.partial_sink.lock().clone() {
let _ = sink.publish(TextStreamChunk {
text: delta,
offset: 0,
});
}
let mut segments = st.partial_segments.clone();
segments.extend(st.final_segments.clone());
merge_segments(&segments.into_values().collect::<Vec<_>>())
};
if let Some(sink) = self.partial_sink.lock().clone() {
let _ = sink.publish(TextStreamChunk {
text: snapshot,
offset: 0,
});
}
}

Expand Down Expand Up @@ -813,6 +805,33 @@ async fn close_writer(writer: &SharedWriter) -> Result<(), BailianASRError> {
mod tests {
use super::*;

#[test]
fn transcript_snapshots_keep_prefix_and_recognition_corrections() {
let asr = create_test_asr();
let sink = Arc::new(super::super::TranscriptCapture::default());
asr.set_partial_sink(sink.clone());
for (id, text, final_result) in [
(0, "你", false),
(0, "你好", false),
(0, "您好", false),
(0, "您好。", true),
(1, "世", false),
(1, "世界", false),
(1, "世界!", true),
] {
asr.record_result(&make_result_event(id, text, final_result));
}
sink.assert_snapshots(&[
"你",
"你好",
"您好",
"您好。",
"您好。世",
"您好。世界",
"您好。世界!",
]);
}

// ---- helpers ----

fn make_result_event(sentence_id: i64, text: &str, is_final: bool) -> Value {
Expand Down Expand Up @@ -869,13 +888,17 @@ mod tests {
let request = "https://localhost/path".into_client_request().unwrap();
let explicit_port = "https://localhost:443/path".into_client_request().unwrap();
assert!(matches!(
default_port_for_request(&request),
default_port_for_request(&request)
.as_ref()
.map_err(|error| error.as_ref()),
Err(WsError::Url(
tokio_tungstenite::tungstenite::error::UrlError::UnsupportedUrlScheme
))
));
assert!(matches!(
default_port_for_request(&explicit_port),
default_port_for_request(&explicit_port)
.as_ref()
.map_err(|error| error.as_ref()),
Err(WsError::Url(
tokio_tungstenite::tungstenite::error::UrlError::UnsupportedUrlScheme
))
Expand Down Expand Up @@ -943,7 +966,7 @@ mod tests {
let request = "ws://localhost/path".into_client_request().unwrap();
let result = connect_ws_to_addrs(request, Vec::new()).await;
assert!(
matches!(result, Err(WsError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound)
matches!(result.as_ref().map_err(|error| error.as_ref()), Err(WsError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound)
);
}

Expand Down
25 changes: 25 additions & 0 deletions openless-all/app/crates/openless-core/src/asr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,28 @@ pub struct DictionaryHotword {
pub phrase: String,
pub enabled: bool,
}

#[cfg(test)]
#[derive(Default)]
pub(crate) struct TranscriptCapture(std::sync::Mutex<Vec<crate::ports::TextStreamChunk>>);
#[cfg(test)]
impl crate::ports::TextStreamSink for TranscriptCapture {
fn publish(&self, chunk: crate::ports::TextStreamChunk) -> Result<(), crate::BackendError> {
self.0.lock().unwrap().push(chunk);
Ok(())
}
}
#[cfg(test)]
impl TranscriptCapture {
pub(crate) fn assert_snapshots(&self, expected: &[&str]) {
let chunks = self.0.lock().unwrap();
assert!(chunks.iter().all(|chunk| chunk.offset == 0));
assert_eq!(
chunks
.iter()
.map(|chunk| chunk.text.as_str())
.collect::<Vec<_>>(),
expected
);
}
}
75 changes: 62 additions & 13 deletions openless-all/app/crates/openless-core/src/asr/qwen_realtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ struct SyncState {
send_tx: Option<mpsc::UnboundedSender<SendItem>>,
/// VAD 断句后按到达顺序累积的已完成句段(completed.transcript)。
completed_segments: Vec<String>,
// Per connection: ignore updates/repeats for completed item IDs.
completed_item_ids: std::collections::HashSet<String>,
/// 当前未完成句段的最新 interim 文本;completed 到达后清空。
/// 服务端在句段开放期把累积文本放 `stash`、精修期放 `text`,取非空者。
partial_text: String,
Expand Down Expand Up @@ -473,22 +475,26 @@ impl Qwen3RealtimeASR {
});
if let Some(text) = text {
let text = text.trim();
let delta = {
let snapshot = {
let mut state = self.state.lock();
let delta = text
.strip_prefix(&state.partial_text)
.unwrap_or("")
.to_string();
if state.session_finished
|| value
.get("item_id")
.and_then(Value::as_str)
.is_some_and(|id| state.completed_item_ids.contains(id))
{
return;
}
state.partial_text = text.to_string();
delta
let mut segments = state.completed_segments.clone();
segments.push(state.partial_text.clone());
join_segments(&segments)
};
if !delta.is_empty() {
if let Some(sink) = self.partial_sink.lock().clone() {
let _ = sink.publish(TextStreamChunk {
text: delta,
offset: 0,
});
}
if let Some(sink) = self.partial_sink.lock().clone() {
let _ = sink.publish(TextStreamChunk {
text: snapshot,
offset: 0,
});
}
}
}
Expand All @@ -499,10 +505,26 @@ impl Qwen3RealtimeASR {
};
let trimmed = transcript.trim();
let mut st = self.state.lock();
if st.session_finished {
return;
}
if let Some(id) = value.get("item_id").and_then(Value::as_str) {
if !st.completed_item_ids.insert(id.to_owned()) {
return;
}
}
if !trimmed.is_empty() {
st.completed_segments.push(trimmed.to_string());
}
st.partial_text.clear();
let snapshot = join_segments(&st.completed_segments);
drop(st);
if let Some(sink) = self.partial_sink.lock().clone() {
let _ = sink.publish(TextStreamChunk {
text: snapshot,
offset: 0,
});
}
}

fn finish_success(&self) {
Expand Down Expand Up @@ -697,6 +719,33 @@ async fn close_writer(writer: &SharedWriter) -> Result<(), Qwen3ASRError> {
mod tests {
use super::*;

#[test]
fn transcript_snapshots_keep_prefix_and_recognition_corrections() {
let asr = create_test_asr();
let sink = Arc::new(super::super::TranscriptCapture::default());
asr.set_partial_sink(sink.clone());
for text in ["你", "你好", "您好"] {
asr.record_partial(&serde_json::json!({"text": text}));
}
asr.record_completed(&serde_json::json!({"transcript": "您好。"}));
asr.record_partial(&serde_json::json!({"stash": "世界"}));
asr.record_completed(&serde_json::json!({"transcript": "世界!"}));
sink.assert_snapshots(&["你", "你好", "您好", "您好。", "您好。世界", "您好。世界!"]);
}

#[test]
fn completed_item_ignores_late_partial_and_duplicate_but_accepts_next_item() {
let asr = create_test_asr();
let sink = Arc::new(super::super::TranscriptCapture::default());
asr.set_partial_sink(sink.clone());
asr.record_partial(&serde_json::json!({"item_id":"a", "text":"你好"}));
asr.record_completed(&serde_json::json!({"item_id":"a", "transcript":"你好。"}));
asr.record_partial(&serde_json::json!({"item_id":"a", "text":"旧文字"}));
asr.record_completed(&serde_json::json!({"item_id":"a", "transcript":"你好。"}));
asr.record_partial(&serde_json::json!({"item_id":"b", "text":"世界"}));
sink.assert_snapshots(&["你好", "你好。", "你好。世界"]);
}

fn create_test_asr() -> Qwen3RealtimeASR {
Qwen3RealtimeASR::new(Qwen3RealtimeCredentials {
api_key: "sk-test".to_string(),
Expand Down
Loading
Loading