Skip to content

feat: cross-platform streaming polish output (Win/Linux/macOS)#412

Merged
appergb merged 3 commits into
betafrom
pr/streaming-cross-platform
May 11, 2026
Merged

feat: cross-platform streaming polish output (Win/Linux/macOS)#412
appergb merged 3 commits into
betafrom
pr/streaming-cross-platform

Conversation

@appergb
Copy link
Copy Markdown
Collaborator

@appergb appergb commented May 11, 2026

User description

Summary

  • 让润色 LLM 的 SSE chunk 边到达边逐字模拟键盘事件落到光标,用户感知到的处理时延显著降低(第一个 token 即开始落字)
  • 三平台都覆盖:macOS(CGEvent + 临时切 ABC 输入源)、Windows(SendInput Unicode,绕过 TSF/IME)、Linux 实验(enigo + XTest)
  • Opt-in(默认 false),不开等价于历史行为;条件不满足(Codex/Gemini provider / Raw / 翻译 / Secure Input 框)自动回落一次性插入

主要改动

  • 新增 unicode_keystroke.rs,跨平台 Unicode 按键合成(macOS TIS 调度到主线程规避 macOS 14+ TSM 断言)
  • polish.rs OpenAI-compatible provider 加 polish_streaming + 共用 SSE 解析(与 QA streaming 同款)
  • coordinator.rs 加 StreamingPolishOutcome 三态 + polish_or_passthrough_streaming 分发器
  • coordinator/dictation.rs::run_streaming_polish 主体:mpsc + spawn_blocking typer + 输入源恢复 + already_streamed 跳过 inserter
  • UserPreferences 加 streaming_insert(默认 false)+ streaming_insert_save_clipboard(默认 true)
  • 前端 Settings → Advanced 新增流式 Card(按平台分发 hint 文案),5 个 locale 完全对齐

Test plan

  • cargo check --manifest-path src-tauri/Cargo.toml macOS host pass
  • cargo test --lib 222 passed
  • npm run build (tsc + vite) pass
  • Windows-host CI 跑 cfg(target_os=windows) 分支 cargo check(依赖 release-tauri.yml)
  • Linux-host CI 同上
  • macOS 真机:中文系统输入法下流式正常,session 结束自动切回原输入源
  • Windows 真机:流式 + 切到 Word / 浏览器 / 终端各跑一遍
  • Linux Wayland 真机:失败回落到一次性插入

已知限制

  • v1 仅 OpenAI-compatible provider 实装;Codex/Gemini 透明降级
  • 密码框 / 1Password / SSH prompt 等 Secure Input 框拒绝合成按键 → 失败回落
  • 流式路径下 apply_chinese_script_preference / apply_correction_rules v1 跳过(字已经流出去不好回退)

PR Type

Enhancement, Bug fix


Description

  • Add opt-in streaming insertion pipeline

    • SSE deltas type directly to cursor
  • Implement cross-platform Unicode keystrokes

    • macOS ABC, Windows SendInput, Linux enigo
  • Preserve fallback on unsupported streaming

    • Raw path remains unchanged
  • Add prefs, UI, translations

    • New toggles and localized labels

Diagram Walkthrough

flowchart LR
  A["Settings toggle"] -- "enable streaming_insert" --> B["end_session dispatch"]
  B -- "eligible" --> C["run_streaming_polish"]
  C -- "SSE deltas" --> D["polish_streaming"]
  D -- "on_delta" --> E["unicode_keystroke"]
  C -- "fallback / success" --> F["one-shot insert or skip"]
Loading

File Walkthrough

Relevant files
Enhancement
6 files
coordinator.rs
Dispatch streaming polish outcomes and fallbacks                 
+99/-0   
dictation.rs
Stream deltas through keyboard typing                                       
+300/-6 
polish.rs
Add streaming polish provider support                                       
+217/-0 
unicode_keystroke.rs
Implement cross-platform Unicode typing helpers                   
+413/-0 
types.ts
Extend user preferences TypeScript interface                         
+7/-0     
Settings.tsx
Add streaming settings section to UI                                         
+47/-0   
Miscellaneous
1 files
lib.rs
Register Unicode keystroke helper module                                 
+1/-0     
Configuration changes
2 files
types.rs
Add streaming preference and clipboard fields                       
+32/-0   
ipc.ts
Update mock settings for new fields                                           
+2/-0     
Documentation
5 files
en.ts
Add streaming settings English copy                                           
+13/-0   
ja.ts
Add streaming settings Japanese copy                                         
+13/-0   
ko.ts
Add streaming settings Korean copy                                             
+13/-0   
zh-CN.ts
Add streaming settings Simplified Chinese copy                     
+13/-0   
zh-TW.ts
Add streaming settings Traditional Chinese copy                   
+13/-0   
Tests
1 files
stylePrefs.test.ts
Extend preference fixtures for streaming                                 
+2/-0     

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 11, 2026

PR Reviewer Guide 🔍

(Review updated until commit ceda94e)

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Partial chunk loss

typed_text is only updated after type_unicode_chunk(&delta) succeeds for the whole SSE delta. If the platform keystroke API fails after already emitting part of a multi-character delta, the successfully typed prefix is lost from typed_text. That makes the saved history/clipboard shorter than what the user actually saw, and if the failure happens on the first delta it can also fall back to one-shot insertion with characters already visible on screen, causing duplicates.

let typer_handle = tokio::task::spawn_blocking(move || {
    let mut rx = rx;
    let mut typed_text = String::new();
    let mut first_failure: Option<String> = None;
    while let Some(delta) = rx.blocking_recv() {
        if first_failure.is_some() {
            // 一旦类型链路出错(如 Secure Input 启用),后续 delta 全部丢弃,但仍
            // 把 mpsc drain 完,避免发送端阻塞。
            continue;
        }
        match crate::unicode_keystroke::type_unicode_chunk(&delta) {
            Ok(()) => {
                typed_text.push_str(&delta);
            }
            Err(e) => {
                log::error!(
                    "[coord] streaming_insert: type_unicode_chunk failed at typed={} chars: {e}; \
                     dropping remaining deltas",
                    typed_text.chars().count()
                );
                first_failure = Some(e.to_string());
            }
        }
    }
    (typed_text, first_failure)

appergb pushed a commit that referenced this pull request May 11, 2026
…typed

pr-agent #412 反馈两条都修:

1. \"Missing fallback\" —— typer 在第一字就失败(最常见:session 开始时已处
   于 macOS Secure Input,或 SendInput / enigo 拒绝),已往返流式但 typed_chars=0。
   旧逻辑仍返 already_streamed=true → 上层跳过 inserter → 用户看不到任何内容。
   新逻辑:typed_chars=0 且 typer_failure 存在时,回退到一次性 inserter 路径
   兜底,让 polish 文本通过正常 insertion 写出。

2. \"Wrong final text\" —— SSE 中途失败但已落字(typed_chars > 0),旧逻辑给
   history 写 raw.text 而屏幕显示 partial polish,二者分叉。新逻辑:typer 持续
   累积 typed_text(屏幕实际落字内容),history 与屏幕保持一致 —— 写 typed_text,
   polish_error 注明 \"streaming polish failed mid-stream after N chars\"。

实现:typer 任务返回值由 (typed_chars, first_failure) 变为 (typed_text, first_failure),
顶层用 typed_text.chars().count() 派生 typed_chars,并把 typed_text 在两个分支
都用上。

测试:cargo test --lib 222 passed。
@github-actions
Copy link
Copy Markdown

Persistent review updated to latest commit 822a0fb

appergb pushed a commit that referenced this pull request May 11, 2026
pr-agent #412 二次反馈 \"Clipboard Mismatch\":原来无条件先写 polish 完整 text
到剪贴板,再决定 typer 是否中途失败 → typer 中途失败时屏幕 / history 是
partial typed_text,但剪贴板是完整 polish,Cmd+V 会粘出用户从未见过的内容。

修复:先把 final_text 算出来(typer 失败时 = typed_text,正常时 = 完整 polish),
然后剪贴板、history、返回值统统用同一个 final_text,三处保持一致。

测试:cargo test --lib 222 passed。
@github-actions
Copy link
Copy Markdown

Persistent review updated to latest commit 4030380

baiqing added 3 commits May 12, 2026 01:33
让润色 LLM 的 SSE chunk 边到达边逐字模拟键盘事件落到光标,用户感知到的
处理时延显著降低(第一个 token 即开始落字)。Opt-in,不开等价于历史行为。

后端(src-tauri/src/)
- unicode_keystroke.rs(新):跨平台 Unicode 按键合成
  - macOS:CGEvent + 切到 ABC 输入源(规避 CJK/日文 IME 拦截 Unicode 事件)+
    Secure Input 探测;TIS 调用强制调度到主线程,规避 macOS 14+ TSM 断言。
  - Windows:SendInput(KEYEVENTF_UNICODE),绕过 TSF / IME,不切输入法。
  - Linux(实验):enigo Keyboard::text。X11 / XTest 稳定;Wayland 看 compositor
    给不给 libei 权限,失败回落由调用方处理。
- polish.rs:OpenAI-compatible provider 新增 polish_streaming + 共用 SSE 解析
  skeleton(与 QA streaming 同款);Codex provider 返 not-implemented 让上层降级
- coordinator.rs:StreamingPolishOutcome { Streamed, UnsupportedFallback, Failed }
  + polish_or_passthrough_streaming 分发器
- coordinator/dictation.rs::run_streaming_polish:
  · 切到 ABC(macOS)→ spawn_blocking typer 串行读 mpsc → 调流式润色 →
    drop tx → typer drain 退出 → 恢复输入源
  · 中途取消 / typer 失败 / Secure Input 启用:drain 不爆,已流出去的字保留
  · 流式成功:可选写回剪贴板(streaming_insert_save_clipboard,默认 on)
  · 流式失败:已流的部分保留,未流时回落到 raw 一次性
  · end_session 的 cancel 检查识别 already_streamed=true 走完收尾(撤销不掉)
- types.rs:UserPreferences 新增 streaming_insert(默认 false)+
  streaming_insert_save_clipboard(默认 true),Wire/Deserialize/Default 三处同步
- lib.rs:mod unicode_keystroke

前端(src/)
- lib/types.ts + ipc.ts:UserPreferences 同步两字段 + mock 默认值
- lib/stylePrefs.test.ts:测试 fixture previousPrefs 补两字段
- pages/Settings.tsx:AdvancedSection 加流式 Card(Toggle ×2),按平台分发 hint
  文案(mac / win / linux 三套)
- i18n/{zh-CN,en,ja,ko,zh-TW}:advanced 新增 8 个 streamingInsert* key,5 个
  locale 完全对齐

验证范围
- macOS host:cargo check + cargo test --lib (222 pass) + npm run build (tsc + vite)
- Windows/Linux cfg 分支只走过 cargo check(cross-target 验证依赖 CI runner)
…typed

pr-agent #412 反馈两条都修:

1. \"Missing fallback\" —— typer 在第一字就失败(最常见:session 开始时已处
   于 macOS Secure Input,或 SendInput / enigo 拒绝),已往返流式但 typed_chars=0。
   旧逻辑仍返 already_streamed=true → 上层跳过 inserter → 用户看不到任何内容。
   新逻辑:typed_chars=0 且 typer_failure 存在时,回退到一次性 inserter 路径
   兜底,让 polish 文本通过正常 insertion 写出。

2. \"Wrong final text\" —— SSE 中途失败但已落字(typed_chars > 0),旧逻辑给
   history 写 raw.text 而屏幕显示 partial polish,二者分叉。新逻辑:typer 持续
   累积 typed_text(屏幕实际落字内容),history 与屏幕保持一致 —— 写 typed_text,
   polish_error 注明 \"streaming polish failed mid-stream after N chars\"。

实现:typer 任务返回值由 (typed_chars, first_failure) 变为 (typed_text, first_failure),
顶层用 typed_text.chars().count() 派生 typed_chars,并把 typed_text 在两个分支
都用上。

测试:cargo test --lib 222 passed。
pr-agent #412 二次反馈 \"Clipboard Mismatch\":原来无条件先写 polish 完整 text
到剪贴板,再决定 typer 是否中途失败 → typer 中途失败时屏幕 / history 是
partial typed_text,但剪贴板是完整 polish,Cmd+V 会粘出用户从未见过的内容。

修复:先把 final_text 算出来(typer 失败时 = typed_text,正常时 = 完整 polish),
然后剪贴板、history、返回值统统用同一个 final_text,三处保持一致。

测试:cargo test --lib 222 passed。
@github-actions
Copy link
Copy Markdown

Persistent review updated to latest commit ceda94e

@appergb appergb merged commit d4162cf into beta May 11, 2026
4 checks passed
@appergb appergb deleted the pr/streaming-cross-platform branch May 11, 2026 18:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant