Skip to content

Commit 66367b3

Browse files
committed
fix(ai): 彻底检测修复——余额接口改 /v1/user/info + 任务 panic 兜底 + 前端闭包过期
1 parent b4518f3 commit 66367b3

5 files changed

Lines changed: 172 additions & 122 deletions

File tree

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

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,32 +22,32 @@ pub struct AiBalance {
2222
pub currency: String,
2323
}
2424

25-
/// 解析余额响应(纯函数;容错:字段缺失/非法 → 0,currency 缺失 → "CNY")。
25+
/// 解析余额响应(纯函数;容错:字段缺失/非法 → 0)。
2626
///
27-
/// @ai-context: 返回 Err 仅当整体非 JSON 对象(无法尽力而为);
28-
/// 字段缺失按 0 处理——SiliconFlow 分项字段可能因账户类型缺失。
27+
/// @ai-context: 2026-08-21 彻底检测修复:硅基流动余额接口已从
28+
/// /user/balance 改为 GET /v1/user/info(旧路径 404——
29+
/// linux.do 有 user/info 相关下线通知),响应为
30+
/// {"code":20000,"data":{"totalBalance":"0","chargeBalance":"0","balance":"0"}};
31+
/// 映射:total_balance←totalBalance、topped_up←chargeBalance、
32+
/// grants←balance(赠额/免费额度)。数值为字符串——解析容错。
2933
pub fn parse_balance(body: &str) -> Result<AiBalance, String> {
3034
let v: serde_json::Value =
3135
serde_json::from_str(body).map_err(|e| format!("余额响应 JSON 解析失败: {}", e))?;
3236
let obj = v
33-
.as_object()
34-
.ok_or_else(|| "余额响应不是 JSON 对象".to_string())?;
37+
.get("data")
38+
.and_then(|d| d.as_object())
39+
.ok_or_else(|| "余额响应缺少 data 对象".to_string())?;
3540
let num = |k: &str| -> f64 {
36-
obj.get(k).and_then(|x| x.as_f64()).or_else(|| {
37-
obj.get(k).and_then(|x| x.as_str()).and_then(|s| s.parse().ok())
38-
}).unwrap_or(0.0)
41+
obj.get(k)
42+
.and_then(|x| x.as_f64())
43+
.or_else(|| obj.get(k).and_then(|x| x.as_str()).and_then(|s| s.parse().ok()))
44+
.unwrap_or(0.0)
3945
};
40-
let currency = obj
41-
.get("currency")
42-
.and_then(|c| c.as_str())
43-
.filter(|s| !s.is_empty())
44-
.unwrap_or("CNY")
45-
.to_string();
4646
Ok(AiBalance {
47-
total_balance: num("total_balance"),
48-
grants_balance: num("grants_balance"),
49-
topped_up_balance: num("topped_up_balance"),
50-
currency,
47+
total_balance: num("totalBalance"),
48+
grants_balance: num("balance"),
49+
topped_up_balance: num("chargeBalance"),
50+
currency: "CNY".to_string(),
5151
})
5252
}
5353

@@ -81,12 +81,8 @@ impl AiBalanceAdapter {
8181
if self.api_key.trim().is_empty() {
8282
return Err("未配置 API 密钥(设置页保存密钥或配置环境变量 SILICONFLOW_API_KEY)".to_string());
8383
}
84-
// 审查修复(2026-08-21):只修剪尾斜杠,不得 trim "/v1"(同 ai_client
85-
// ——base_url 含 /v1 是 OpenAI 兼容端点约定,删掉会 404)
86-
let url = format!(
87-
"{}/user/balance",
88-
self.base_url.trim_end_matches('/')
89-
);
84+
// 2026-08-21 彻底检测修复:余额接口 = /v1/user/info(旧 /user/balance 404)
85+
let url = format!("{}/user/info", self.base_url.trim_end_matches('/'));
9086
let agent = ureq::AgentBuilder::new()
9187
.timeout(std::time::Duration::from_secs(self.timeout_secs.max(5)))
9288
.build();

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ use crate::ai_balance::{low_balance_warning, parse_balance, AiBalance};
88

99
#[test]
1010
fn parse_full_response() {
11-
let raw = r#"{"total_balance":12.34,"grants_balance":10.0,"topped_up_balance":2.34,"currency":"CNY"}"#;
11+
// 2026-08-21 接口修复:/v1/user/info → data.totalBalance/chargeBalance/balance(字符串数值)
12+
let raw = r#"{"code":20000,"message":"Ok","status":true,"data":{"id":"u1","balance":"10","chargeBalance":"2.34","totalBalance":"12.34"}}"#;
1213
let b = parse_balance(raw).expect("合法响应");
1314
assert_eq!(
1415
b,
@@ -24,8 +25,9 @@ fn parse_full_response() {
2425
#[test]
2526
fn parse_missing_fields_default_zero() {
2627
// 字段缺失容错:分项缺失/非法 → 0(账户类型差异不整单丢弃)
27-
let raw = r#"{"total_balance":5.0}"#;
28+
let raw = r#"{"code":20000,"data":{"totalBalance":"5"}}"#;
2829
let b = parse_balance(raw).expect("缺失字段容错解析");
30+
assert_eq!(b.total_balance, 5.0);
2931
assert_eq!(b.grants_balance, 0.0);
3032
assert_eq!(b.topped_up_balance, 0.0);
3133
assert_eq!(b.currency, "CNY");
@@ -34,18 +36,18 @@ fn parse_missing_fields_default_zero() {
3436
#[test]
3537
fn parse_invalid_number_treated_as_zero() {
3638
// 非法数值(字符串"abc")→ 0,不 panic
37-
let raw = r#"{"total_balance":"abc","grants_balance":1,"currency":"USD"}"#;
39+
let raw = r#"{"code":20000,"data":{"totalBalance":"abc","balance":"1"}}"#;
3840
let b = parse_balance(raw).expect("非法数值容错");
3941
assert_eq!(b.total_balance, 0.0);
4042
assert_eq!(b.grants_balance, 1.0);
41-
assert_eq!(b.currency, "USD");
4243
}
4344

4445
#[test]
4546
fn parse_non_json_is_error() {
46-
// 整体非 JSON → Err(无法尽力而为)
47+
// 整体非 JSON / 缺 data 对象 → Err(无法尽力而为)
4748
assert!(parse_balance("not json").is_err());
4849
assert!(parse_balance(r#"[1,2,3]"#).is_err());
50+
assert!(parse_balance(r#"{"code":20000}"#).is_err(), "缺 data 对象必须报错");
4951
}
5052

5153
#[test]

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

Lines changed: 107 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -238,87 +238,20 @@ pub fn ai_refine_apply(
238238
// ────────────────────────────────────────────────────────────
239239

240240
/// 后台精修任务:规则草稿 → 切片 → 逐片精修(mock/云端)→ 合并 → diff。
241+
///
242+
/// @ai-context: 彻底检测加固(2026-08-21):spawn_blocking 的 JoinHandle 未被
243+
/// await——闭包内 panic 会被 tokio 吞掉,任务状态永久停在
244+
/// Pending(前端永久显示"任务排队中",无失败可重试)。
245+
/// catch_unwind 把 panic 归一为 Failed 状态,状态流转永不失联。
241246
fn run_refine_task(st: AppState, task_id: u64, session_id: i64, mock: bool) {
242-
let env = PurifyEnv {
243-
config: st.purify.clone(),
244-
symbol: st.symbol_normalize.clone(),
245-
corrections: st.ocr_corrections.clone(),
246-
};
247-
let outcome: Result<AiRefineResult, AiTaskFailure> = (|| {
248-
// ① 规则草稿 + 结构分析一次完成(审查修复 2026-08-21:build_rule_draft_
249-
// with_analysis 返回 analysis——章节/术语直接复用,消除二次 analyze 双跑)
250-
let (draft, analysis) =
251-
build_rule_draft_with_analysis(&st.db, &st.ui_junk, &env, &st.data_dir, session_id, None)
252-
.map_err(AiTaskFailure::Other)?;
253-
// ② 精修上下文(档案/章节/术语——analysis 已含章节边界与术语表)
254-
let session = st
255-
.db
256-
.get_session(session_id)
257-
.map_err(|e| AiTaskFailure::Other(e.to_string()))?
258-
.ok_or_else(|| AiTaskFailure::Other("会话不存在".to_string()))?;
259-
let kind = session
260-
.profile
261-
.as_deref()
262-
.map(ProfileKind::parse)
263-
.unwrap_or(ProfileKind::Lecture);
264-
let ocr_blocks = st.db.list_ocr_blocks(session_id).map_err(|e| AiTaskFailure::Other(e.to_string()))?;
265-
let outline = detect_outline_smart(&ocr_blocks, &draft.ocr_screens, &OutlineConfig::default());
266-
let chapters: Vec<String> = if outline.is_empty() {
267-
analysis
268-
.chapters
269-
.iter()
270-
.enumerate()
271-
.map(|(i, _)| format!("第 {} 节", i + 1))
272-
.collect()
273-
} else {
274-
outline.iter().map(|e| e.text.clone()).collect()
275-
};
276-
let glossary: Vec<String> = analysis.glossary.iter().map(|g| g.term.clone()).collect();
277-
// ③ 切片(≤8000 字/片;进度按片上报)
278-
let slices = slice_note(&draft.markdown, SLICE_MAX_CHARS);
279-
let total = slices.len();
280-
set_task(&st, task_id, AiTaskState::Running { finished_slices: 0, total_slices: total });
281-
let settings = st.ai_settings.lock().map_err(|e| AiTaskFailure::Other(e.to_string()))?.clone();
282-
let client = AiClient::from_settings(&settings, st.ai_credentials.load_key().ok().flatten());
283-
let adapter = AiNoteRefineAdapter::new(client.clone());
284-
let mock_adapter = AiMockAdapter;
285-
let mut refined = String::new();
286-
for (i, slice) in slices.iter().enumerate() {
287-
let req = AiRefineRequest {
288-
content: slice.clone(),
289-
profile: kind.as_str().to_string(),
290-
glossary: glossary.clone(),
291-
chapters: chapters.clone(),
292-
};
293-
let resp = if mock {
294-
mock_adapter.refine(&req)
295-
} else {
296-
adapter.refine(&req).map_err(AiTaskFailure::from)?
297-
};
298-
if !refined.is_empty() {
299-
refined.push_str("\n\n");
300-
}
301-
refined.push_str(&resp.to_markdown());
302-
set_task(
303-
&st,
304-
task_id,
305-
AiTaskState::Running { finished_slices: i + 1, total_slices: total },
306-
);
307-
}
308-
// ④ 合并 + 与规则版 diff(基线=本地版,AI 变化点高亮)
309-
let diff = diff_markdown(&draft.markdown, &refined);
310-
let (added, removed, _) = diff_stats(&diff);
311-
Ok(AiRefineResult {
312-
title: draft.title.clone(),
313-
base_markdown: draft.markdown.clone(),
314-
refined_markdown: refined,
315-
diff,
316-
added_lines: added,
317-
removed_lines: removed,
318-
slices: total,
319-
model: client.config.model,
320-
})
321-
})();
247+
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
248+
run_refine_task_inner(&st, task_id, session_id, mock)
249+
}))
250+
.unwrap_or_else(|_| {
251+
Err(AiTaskFailure::Other(
252+
"精修任务内部错误(panic)——请重试;若复现请反馈".to_string(),
253+
))
254+
});
322255
match outcome {
323256
Ok(result) => {
324257
{
@@ -333,6 +266,100 @@ fn run_refine_task(st: AppState, task_id: u64, session_id: i64, mock: bool) {
333266
}
334267
}
335268

269+
/// 精修任务主体(返回 Result;panic 由外层 catch_unwind 兜底)。
270+
fn run_refine_task_inner(
271+
st: &AppState,
272+
task_id: u64,
273+
session_id: i64,
274+
mock: bool,
275+
) -> Result<AiRefineResult, AiTaskFailure> {
276+
let env = PurifyEnv {
277+
config: st.purify.clone(),
278+
symbol: st.symbol_normalize.clone(),
279+
corrections: st.ocr_corrections.clone(),
280+
};
281+
// ① 规则草稿 + 结构分析一次完成(审查修复 2026-08-21:build_rule_draft_
282+
// with_analysis 返回 analysis——章节/术语直接复用,消除二次 analyze 双跑)
283+
let (draft, analysis) =
284+
build_rule_draft_with_analysis(&st.db, &st.ui_junk, &env, &st.data_dir, session_id, None)
285+
.map_err(AiTaskFailure::Other)?;
286+
// ② 精修上下文(档案/章节/术语——analysis 已含章节边界与术语表)
287+
let session = st
288+
.db
289+
.get_session(session_id)
290+
.map_err(|e| AiTaskFailure::Other(e.to_string()))?
291+
.ok_or_else(|| AiTaskFailure::Other("会话不存在".to_string()))?;
292+
let kind = session
293+
.profile
294+
.as_deref()
295+
.map(ProfileKind::parse)
296+
.unwrap_or(ProfileKind::Lecture);
297+
let ocr_blocks = st
298+
.db
299+
.list_ocr_blocks(session_id)
300+
.map_err(|e| AiTaskFailure::Other(e.to_string()))?;
301+
let outline = detect_outline_smart(&ocr_blocks, &draft.ocr_screens, &OutlineConfig::default());
302+
let chapters: Vec<String> = if outline.is_empty() {
303+
analysis
304+
.chapters
305+
.iter()
306+
.enumerate()
307+
.map(|(i, _)| format!("第 {} 节", i + 1))
308+
.collect()
309+
} else {
310+
outline.iter().map(|e| e.text.clone()).collect()
311+
};
312+
let glossary: Vec<String> = analysis.glossary.iter().map(|g| g.term.clone()).collect();
313+
// ③ 切片(≤8000 字/片;进度按片上报)
314+
let slices = slice_note(&draft.markdown, SLICE_MAX_CHARS);
315+
let total = slices.len();
316+
set_task(st, task_id, AiTaskState::Running { finished_slices: 0, total_slices: total });
317+
let settings = st
318+
.ai_settings
319+
.lock()
320+
.map_err(|e| AiTaskFailure::Other(e.to_string()))?
321+
.clone();
322+
let client = AiClient::from_settings(&settings, st.ai_credentials.load_key().ok().flatten());
323+
let adapter = AiNoteRefineAdapter::new(client.clone());
324+
let mock_adapter = AiMockAdapter;
325+
let mut refined = String::new();
326+
for (i, slice) in slices.iter().enumerate() {
327+
let req = AiRefineRequest {
328+
content: slice.clone(),
329+
profile: kind.as_str().to_string(),
330+
glossary: glossary.clone(),
331+
chapters: chapters.clone(),
332+
};
333+
let resp = if mock {
334+
mock_adapter.refine(&req)
335+
} else {
336+
adapter.refine(&req).map_err(AiTaskFailure::from)?
337+
};
338+
if !refined.is_empty() {
339+
refined.push_str("\n\n");
340+
}
341+
refined.push_str(&resp.to_markdown());
342+
set_task(
343+
st,
344+
task_id,
345+
AiTaskState::Running { finished_slices: i + 1, total_slices: total },
346+
);
347+
}
348+
// ④ 合并 + 与规则版 diff(基线=本地版,AI 变化点高亮)
349+
let diff = diff_markdown(&draft.markdown, &refined);
350+
let (added, removed, _) = diff_stats(&diff);
351+
Ok(AiRefineResult {
352+
title: draft.title.clone(),
353+
base_markdown: draft.markdown.clone(),
354+
refined_markdown: refined,
355+
diff,
356+
added_lines: added,
357+
removed_lines: removed,
358+
slices: total,
359+
model: client.config.model,
360+
})
361+
}
362+
336363
/// 更新任务状态并推送事件(短锁内完成即释放)。M3 补充任务复用(pub(crate))。
337364
pub(crate) fn set_task(st: &AppState, task_id: u64, new_state: AiTaskState) {
338365
if let Ok(mut tasks) = st.ai_tasks.lock() {

‎app/src/components/AiRefineCard.tsx‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,25 +52,31 @@ export default function AiRefineCard({ sessionId, onApplied }: { sessionId: numb
5252
const [phase, setPhase] = useState<"idle" | "consent" | "confirm" | "running" | "done" | "failed">("idle");
5353
const [estimate, setEstimate] = useState<RefineEstimateView | null>(null);
5454
const [balance, setBalance] = useState<BalanceView | null>(null);
55-
const [taskId, setTaskId] = useState<number | null>(null);
5655
const [progress, setProgress] = useState<{ finished: number; total: number } | null>(null);
5756
const [result, setResult] = useState<AiRefineResult | null>(null);
5857
const [failure, setFailure] = useState<AiTaskFailureLike | null>(null);
5958
const [remember, setRemember] = useState(false);
6059
const [msg, setMsg] = useState("");
6160
const polling = useRef<ReturnType<typeof setInterval> | null>(null);
61+
// 审查修复(2026-08-21 真机 debug):taskId 与 handleState 用 ref 持有——
62+
// 事件/轮询回调在任务期间多次触发,若闭包捕获旧渲染版本(taskId=null),
63+
// 任务成功时 ai_refine_result 以 null 查询会失败 → 永久卡"排队中"
64+
const [, setTaskId] = useState<number | null>(null);
65+
const taskIdRef = useRef<number | null>(null);
66+
const handleStateRef = useRef<(st: AiTaskState) => Promise<void>>(async () => {});
6267

63-
// 事件通道(ai:task-update)——与轮询双通道,事件优先即时
68+
// 事件通道(ai:task-update)——与轮询双通道,事件优先即时;订阅一次,
69+
// 回调经 handleStateRef 取最新实现(无闭包过期)
6470
useEffect(() => {
6571
const un = listen<[number, AiTaskState]>("ai:task-update", (e) => {
6672
const [tid, st] = e.payload;
67-
if (tid !== taskId) return;
68-
handleState(st);
73+
if (tid !== taskIdRef.current) return;
74+
void handleStateRef.current(st);
6975
});
7076
return () => {
7177
un.then((f) => f());
7278
};
73-
}, [taskId]);
79+
}, []);
7480

7581
// 审查修复(2026-08-21):组件卸载时停止轮询——否则 interval 持续 invoke
7682
// 并对已卸载组件 setState(切会话/关面板后泄漏)
@@ -88,9 +94,10 @@ export default function AiRefineCard({ sessionId, onApplied }: { sessionId: numb
8894
}, []);
8995

9096
const handleState = useCallback(async (st: AiTaskState) => {
97+
const tid = taskIdRef.current;
9198
if (st === "Succeeded") {
9299
stopPolling();
93-
const r = await invoke<AiRefineResult>("ai_refine_result", { taskId }).catch(() => null);
100+
const r = await invoke<AiRefineResult>("ai_refine_result", { taskId: tid }).catch(() => null);
94101
if (r) {
95102
setResult(r);
96103
setPhase("done");
@@ -103,7 +110,12 @@ export default function AiRefineCard({ sessionId, onApplied }: { sessionId: numb
103110
setProgress({ finished: st.Running.finished_slices, total: st.Running.total_slices });
104111
setPhase("running");
105112
}
106-
}, [taskId]);
113+
}, []);
114+
115+
// 同步最新 handleState 到 ref(事件/轮询回调总用最新实现)
116+
useEffect(() => {
117+
handleStateRef.current = handleState;
118+
}, [handleState]);
107119

108120
const stopPolling = () => {
109121
if (polling.current) {
@@ -173,6 +185,7 @@ export default function AiRefineCard({ sessionId, onApplied }: { sessionId: numb
173185
return null;
174186
});
175187
if (handle) {
188+
taskIdRef.current = handle.task_id;
176189
setTaskId(handle.task_id);
177190
void handleState(handle.state);
178191
poll(handle.task_id);
@@ -200,6 +213,7 @@ export default function AiRefineCard({ sessionId, onApplied }: { sessionId: numb
200213

201214
const reset = () => {
202215
stopPolling();
216+
taskIdRef.current = null;
203217
setPhase("idle");
204218
setTaskId(null);
205219
setResult(null);

0 commit comments

Comments
 (0)