Skip to content

Commit e59c413

Browse files
committed
fix(ai): 流式收尾校验与整包回退/预算余量/授权文案(2026-09-05 审查批B)
1 parent a3a7b78 commit e59c413

4 files changed

Lines changed: 79 additions & 18 deletions

File tree

‎app/src-tauri/src/ai_chat_stream.rs‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ pub struct StreamOutcome {
5858
pub content: String,
5959
pub usage_json: Option<String>,
6060
pub cancelled: bool,
61+
/// REQ-290①:是否收到 SSE [DONE] 正常收尾(false=断流/服务端 error——
62+
/// 调用方应视作失败走重试/回退,禁止把截断当成功)
63+
pub completed: bool,
6164
}
6265

6366
/// 发送流式 chat/completions(SSE),逐 delta 回调 emit。
@@ -104,21 +107,25 @@ pub fn stream_chat(
104107
let mut content = String::new();
105108
let mut usage_json: Option<String> = None;
106109
let mut cancelled = false;
110+
let mut completed = false;
107111
for line in reader.lines() {
108112
if cancel.is_cancelled() {
109113
cancelled = true;
110114
break;
111115
}
112116
let line = match line {
113117
Ok(l) => l,
114-
Err(_) => break, // 传输中途断流:以已累积内容为准(无 usage)
118+
Err(_) => break, // 传输中途断流:completed=false——调用方不得当成功
115119
};
116120
match parse_sse_line(&line) {
117121
SseEvent::Delta(d) => {
118122
content.push_str(&d);
119123
emit(ChatStreamEvent::Chunk { delta: d });
120124
}
121-
SseEvent::Done => break,
125+
SseEvent::Done => {
126+
completed = true;
127+
break;
128+
}
122129
SseEvent::Ignore => {
123130
// usage 可能挂在非 delta 的 data 行(OpenAI 兼容末 chunk)
124131
if let Some(usage) = extract_usage(&line) {
@@ -127,7 +134,7 @@ pub fn stream_chat(
127134
}
128135
}
129136
}
130-
Ok(StreamOutcome { content, usage_json, cancelled })
137+
Ok(StreamOutcome { content, usage_json, cancelled, completed })
131138
}
132139

133140
/// 从 data 行提取 usage(纯函数;无 usage → None)。
@@ -170,25 +177,29 @@ pub fn stream_sse_content(
170177
let reader = BufReader::new(resp.into_reader());
171178
let mut content = String::new();
172179
let mut usage_json: Option<String> = None;
180+
let mut completed = false;
173181
for line in reader.lines() {
174182
let line = match line {
175183
Ok(l) => l,
176-
Err(_) => break, // 传输中途断流:以已累积内容为准
184+
Err(_) => break, // 传输中途断流:completed=false——调用方不得当成功
177185
};
178186
match parse_sse_line(&line) {
179187
SseEvent::Delta(d) => {
180188
content.push_str(&d);
181189
emit(&d);
182190
}
183-
SseEvent::Done => break,
191+
SseEvent::Done => {
192+
completed = true;
193+
break;
194+
}
184195
SseEvent::Ignore => {
185196
if let Some(usage) = extract_usage(&line) {
186197
usage_json = Some(usage);
187198
}
188199
}
189200
}
190201
}
191-
Ok(StreamOutcome { content, usage_json, cancelled: false })
202+
Ok(StreamOutcome { content, usage_json, cancelled: false, completed })
192203
}
193204

194205
/// HTTP 状态 → AiClientError(与 post_completions 同归一口径——四下一致)。

‎app/src-tauri/src/ai_note_refine.rs‎

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,14 +221,16 @@ impl AiNoteRefineAdapter {
221221
let mut system = self.prompt.build_system(&request.profile, dims);
222222
system.push_str("\n\n");
223223
system.push_str(crate::ai_refine_protocol::NDJSON_SYSTEM_SUFFIX);
224-
// REQ-290② 预算(与 refine_vision 同口径——流式不豁免上限)
224+
// REQ-290② 预算(与 refine_vision 同口径——流式不豁免上限;含引导段)
225225
let budget = crate::refine_budget::output_budget(
226226
dims.map(|d| d.preset_id.as_str()).unwrap_or("standard"),
227227
request.content.chars().count(),
228228
);
229+
system.push_str("\n\n");
230+
system.push_str(&crate::refine_budget::guidance_suffix(&budget));
229231
let user = serde_json::to_string(request)
230232
.map_err(|e| AiClientError::Parse(format!("精修请求序列化失败: {}", e)))?;
231-
let payload = if images.is_empty() {
233+
let mut payload = if images.is_empty() {
232234
crate::ai_client::build_chat_payload(
233235
&self.client.config.model, &system, &user, budget.max_tokens,
234236
)
@@ -237,6 +239,11 @@ impl AiNoteRefineAdapter {
237239
&self.client.config.model, &system, &user, images, budget.max_tokens,
238240
)
239241
};
242+
// B1(审查):json_object=单一 JSON 值约束与 NDJSON 多行对象冲突——
243+
// 流式拍必须去除 response_format(非流式回退路径保留既有约束)
244+
if let Some(o) = payload.as_object_mut() {
245+
o.remove("response_format");
246+
}
240247
let mut sections: Vec<crate::ai_refine_protocol::AiRefineSection> = Vec::new();
241248
let mut pending = String::new();
242249
let outcome = crate::ai_chat_stream::stream_sse_content(
@@ -258,16 +265,47 @@ impl AiNoteRefineAdapter {
258265
sections.push(sec.clone());
259266
on_section(sec);
260267
}
261-
let _ = outcome.content;
268+
// B2(审查):未收到 [DONE] 即断流——已累积节不可信(尾节可能丢失),
269+
// 整体视作失败走同拍非流式回退(禁止静默截断当成功)
270+
if !outcome.completed {
271+
return Err(AiClientError::Network(
272+
"流式响应未正常收尾(SSE 中断)——回退非流式".to_string(),
273+
));
274+
}
275+
// B3(审查):整包回退承诺兑现——模型输出完整数组(紧凑/pretty)时
276+
// 对已累积全文做整包解析(零额外请求;仅此路径无 Delta 帧)
277+
if sections.is_empty() {
278+
let text = outcome.content.trim();
279+
if !text.is_empty() {
280+
let v: serde_json::Value = serde_json::from_str(text)
281+
.map_err(|e| AiClientError::Parse(format!("流式整包解析失败: {}", e)))?;
282+
let arr = v
283+
.get("sections")
284+
.cloned()
285+
.unwrap_or_else(|| v.clone());
286+
let parsed: Vec<crate::ai_refine_protocol::AiRefineSection> =
287+
serde_json::from_value(arr).map_err(|e| {
288+
AiClientError::Parse(format!("流式整包结构非法: {}", e))
289+
})?;
290+
if !parsed.is_empty() {
291+
sections = parsed;
292+
}
293+
}
294+
}
262295
if sections.is_empty() {
263296
return Err(AiClientError::Parse(
264297
"流式响应未解析出章节(模型未按逐节输出——回退非流式)".to_string(),
265298
));
266299
}
267-
Ok(crate::ai_refine_protocol::AiRefineResponse {
300+
let resp = crate::ai_refine_protocol::AiRefineResponse {
268301
schema_version: crate::ai_refine_protocol::SCHEMA_VERSION_V2,
269302
sections,
270-
})
303+
};
304+
// B6(审查):与 refine_vision 同口径——组装后过整包校验
305+
resp.validate().map_err(|e| {
306+
AiClientError::Parse(format!("流式响应校验失败(已丢弃回退): {}", e))
307+
})?;
308+
Ok(resp)
271309
}
272310
}
273311

‎app/src-tauri/src/refine_budget.rs‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@ pub struct OutputBudget {
2323
/// 保持高上限(防截断毁 JSON 校验),极简提取激进收敛。
2424
fn budget_params(preset_id: &str) -> (f64, u32) {
2525
match preset_id {
26-
// faithful=忠实整理(输出≈输入长度)· deep=深度改写(转述+导语≈输入)
27-
"faithful" => (1.0, 16_000),
28-
"standard" | "" => (0.9, 16_000),
29-
"deep" => (0.95, 16_000),
26+
// faithful=忠实整理(输出≈输入长度)· deep=深度改写(转述+导语≈输入);
27+
// 最坏档 8000 字片需 ~15k token 且 20k 为旧上限——18k 保留 ≥3k 包装余量
28+
// (审查 B5:16k 余量 <1k 会让合法长输出先被截断再 Parse 失败)
29+
"faithful" => (1.0, 18_000),
30+
"standard" | "" => (0.9, 18_000),
31+
"deep" => (0.95, 18_000),
3032
"minimal" => (0.3, 9_000),
3133
// custom 无档位语义 → 按标准兜底(REQ-279 已声明后端兜底口径)
32-
_ => (0.9, 16_000),
34+
_ => (0.9, 18_000),
3335
}
3436
}
3537

‎app/src/components/RefineLaunchDialog.tsx‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,11 +172,21 @@ export default function RefineLaunchDialog({
172172
<span style={{ fontSize: 11, color: "#6b7280" }}>{isNote ? "目标:当前笔记" : "目标:来源会话"}</span>
173173
</div>
174174

175-
{/* 授权卡(首次:上传说明 + 同意) */}
175+
{/* 授权卡(首次:上传说明 + 同意)——审查 B11:文案按画面理解生效态条件化,
176+
开启时明确"关键帧将随本次上传"(不再声称图像永不出本机) */}
176177
{!settings?.authorized && (
177178
<div style={{ border: "1px solid #f59e0b", background: "#fffbeb", borderRadius: 8, padding: 10, marginBottom: 10, fontSize: 12 }}>
178179
<div style={{ fontWeight: 600, marginBottom: 4 }}>首次使用需授权</div>
179-
精修将上传<strong>转写文本、笔记内容与最小上下文</strong>至 AI 服务;本地优先铁律:<strong>音视频/图像永不出本机</strong>。是否同意?
180+
{effectiveVision ? (
181+
<>
182+
精修将上传<strong>转写文本、笔记内容与关键帧图片</strong>至 AI 服务(画面理解已开启——关键帧仅本次随请求上云,不落云端存储)。
183+
</>
184+
) : (
185+
<>
186+
精修将上传<strong>转写文本、笔记内容与最小上下文</strong>至 AI 服务;本地优先铁律:<strong>音视频/图像永不出本机</strong>。
187+
</>
188+
)}
189+
是否同意?
180190
<div style={{ marginTop: 8, display: "flex", gap: 6 }}>
181191
<button style={{ ...btn, background: "#0d9488", color: "#fff", border: "none" }} onClick={() => void consent()}>同意并继续</button>
182192
<button style={btn} onClick={onClose}>暂不</button>

0 commit comments

Comments
 (0)