@@ -2015,6 +2015,105 @@ fn ensure_qa_volcengine_credentials() -> Result<(), String> {
20152015
20162016/// 润色文本;失败时返回原文 + 失败原因,调用方据此弹错误胶囊 + 写历史 error_code。
20172017/// 之前固定返回 String,调用方拿不到失败信号 → 用户感知"为什么风格设置没生效"。issue #57。
2018+ /// 流式润色的三态结果。让上层(dictation pipeline)能区分「已经流出去了」、
2019+ /// 「降级到一次性」和「真失败了走 raw 兜底」三种 case。
2020+ pub enum StreamingPolishOutcome {
2021+ /// 流式润色成功,`String` 是已经一边流一边交给 `on_delta` 的全部文本(用于写
2022+ /// history、做词条命中统计)。调用方不应再 `inserter.insert(&text)`,因为字符
2023+ /// 已经通过键盘事件落到光标处。
2024+ Streamed ( String ) ,
2025+ /// 当前配置不支持流式:用户没开 streaming_insert / Gemini provider / Codex
2026+ /// provider / Raw 模式 / 翻译模式 / 不是 macOS。调用方应回到现有的
2027+ /// `polish_or_passthrough` 一次性路径,跟历史行为完全一致。
2028+ UnsupportedFallback ,
2029+ /// 流式过程中失败(HTTP / 解析 / 空流等)。`String` 是失败原因,调用方应当
2030+ /// 走 raw 兜底(同 `polish_or_passthrough` 失败分支的语义)。
2031+ Failed ( String ) ,
2032+ }
2033+
2034+ /// 流式润色入口。在不支持流式的所有 case 都返回 `UnsupportedFallback`,让调用方
2035+ /// 透明降级。不修改任何持久化 / 焦点 / 光标状态。
2036+ ///
2037+ /// `on_delta` 每收到一个 SSE chunk 就被调用一次(同步),调用方负责把 chunk 实际
2038+ /// 模拟键盘事件落到光标 —— 见 `coordinator/dictation.rs` 的流式分支。
2039+ /// `should_cancel` 用户取消时返回 true,立即 break SSE 读循环避免烧 quota。
2040+ pub async fn polish_or_passthrough_streaming < F , C > (
2041+ raw : & RawTranscript ,
2042+ mode : PolishMode ,
2043+ hotwords : & [ String ] ,
2044+ working_languages : & [ String ] ,
2045+ chinese_script_preference : ChineseScriptPreference ,
2046+ output_language_preference : OutputLanguagePreference ,
2047+ llm_thinking_enabled : bool ,
2048+ front_app : Option < & str > ,
2049+ prior_turns : & [ ( String , String ) ] ,
2050+ on_delta : F ,
2051+ should_cancel : C ,
2052+ ) -> StreamingPolishOutcome
2053+ where
2054+ F : Fn ( & str ) + Send + Sync ,
2055+ C : Fn ( ) -> bool + Send + Sync ,
2056+ {
2057+ if mode == PolishMode :: Raw {
2058+ log:: info!( "[coord] streaming polish skipped: mode=Raw, fall back to one-shot" ) ;
2059+ return StreamingPolishOutcome :: UnsupportedFallback ;
2060+ }
2061+ let active_llm = CredentialsVault :: get_active_llm ( ) ;
2062+ if active_llm == "gemini" {
2063+ log:: info!(
2064+ "[coord] streaming polish skipped: active LLM provider=gemini (v1 not implemented), fall back to one-shot"
2065+ ) ;
2066+ return StreamingPolishOutcome :: UnsupportedFallback ;
2067+ }
2068+ let provider = match build_active_llm_provider ( llm_thinking_enabled) {
2069+ Ok ( p) => p,
2070+ Err ( e) => {
2071+ log:: error!( "[coord] streaming polish: build provider failed: {e}" ) ;
2072+ return StreamingPolishOutcome :: Failed ( e. to_string ( ) ) ;
2073+ }
2074+ } ;
2075+ if !provider. supports_streaming_polish ( ) {
2076+ log:: info!(
2077+ "[coord] streaming polish skipped: provider does not support streaming (likely codex OAuth), fall back to one-shot"
2078+ ) ;
2079+ return StreamingPolishOutcome :: UnsupportedFallback ;
2080+ }
2081+ log:: info!(
2082+ "[coord] streaming polish START: provider=openai-compatible mode={:?} raw_chars={} prior_turns={}" ,
2083+ mode,
2084+ raw. text. chars( ) . count( ) ,
2085+ prior_turns. len( )
2086+ ) ;
2087+ match provider
2088+ . polish_streaming (
2089+ & raw . text,
2090+ mode,
2091+ hotwords,
2092+ working_languages,
2093+ chinese_script_preference,
2094+ output_language_preference,
2095+ front_app,
2096+ prior_turns,
2097+ on_delta,
2098+ should_cancel,
2099+ )
2100+ . await
2101+ {
2102+ Ok ( text) => {
2103+ log:: info!(
2104+ "[coord] streaming polish OK: final_chars={}" ,
2105+ text. chars( ) . count( )
2106+ ) ;
2107+ StreamingPolishOutcome :: Streamed ( text)
2108+ }
2109+ Err ( e) => {
2110+ let reason = e. to_string ( ) ;
2111+ log:: error!( "[coord] streaming polish FAILED: {reason}" ) ;
2112+ StreamingPolishOutcome :: Failed ( reason)
2113+ }
2114+ }
2115+ }
2116+
20182117async fn polish_or_passthrough (
20192118 raw : & RawTranscript ,
20202119 mode : PolishMode ,
0 commit comments