@@ -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 状态,状态流转永不失联。
241246fn 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))。
337364pub ( crate ) fn set_task ( st : & AppState , task_id : u64 , new_state : AiTaskState ) {
338365 if let Ok ( mut tasks) = st. ai_tasks . lock ( ) {
0 commit comments