1616//! · dto.rs —— IPC 契约类型(4 个 serde 结构体;字段序 = JSON 键序)
1717//! · gate.rs —— 成本硬拦截(免费档/本地 Provider 跳过;查询失败宽容放行)
1818//! · registry.rs —— 任务注册表 + id 序列 + 容量守卫 + 状态写入口
19+ //! · workbench.rs —— 精修工作台(三级数据源 + 章节 diff 统计 + 内联契约测试)
1920//! @ai-context: 本外壳只留「跨域共享的命令 + 重导出门面」;外部 10 文件
2021//! 20 处 crate::commands_ai_refine::X 引用**零改动**(逐项重导出在下方);
2122//! #[tauri::command] 定义随域下沉后,注册路径同步改成
2425mod dto;
2526mod gate;
2627mod registry;
28+ pub ( crate ) mod workbench;
2729
2830pub use dto:: { AiRefineResult , AiTaskHandle , RefineEstimateView , RefineStrategyInfo } ;
2931pub use registry:: { claim_task_id, task_registry, task_seq, task_seq_lower_bound, AiTaskEntry } ;
@@ -36,12 +38,9 @@ use crate::ai_cost::estimate_for_content_model;
3638use crate :: ai_note_refine:: NoteRefinePrompt ;
3739use crate :: ai_strategy:: { RefineStrategyMeta , StrategyOverride } ;
3840use crate :: ai_task:: AiTaskState ;
39- use crate :: anchor_strip:: strip_anchors;
4041use crate :: commands:: AppState ;
4142use crate :: commands_session_note:: build_rule_draft_with_analysis;
42- use crate :: note_diff:: { diff_sections, DiffStats , SectionDiff } ;
4343use crate :: note_filter:: PurifyEnv ;
44- use crate :: note_version:: VersionMeta ;
4544use crate :: types:: { NewNote , Note } ;
4645use crate :: video_profile:: ProfileKind ;
4746
@@ -391,192 +390,3 @@ pub fn ai_refine_apply(
391390 crate :: notify:: emit_changed ( & state. app , crate :: notify:: DataDomain :: Notes ) ;
392391 Ok ( note_row)
393392}
394-
395- // ────────────────────────────────────────────────────────────
396- // 精修工作台(Task 11 / spec 6️⃣——规则草稿+精修结果+章节分组 diff 一次取全)
397- // ────────────────────────────────────────────────────────────
398-
399- /// 工作台数据(前端 RefineWorkbench 模态数据源)。
400- ///
401- /// @ai-context: 必须 camelCase(与 AiRefineResult 等兄弟结构体一致)——本模块
402- /// 其余结构体均已 2026-08 统一契约;此处缺失曾导致前端按
403- /// ruleMarkdown 读取得到 undefined → renderMd(undefined).split
404- /// ("Cannot read properties of undefined (reading 'split')",
405- /// v0.11.5 引入、真机验收漏测,v0.12.3 修复补测)。
406- /// @ai-context: 嵌套 section(SectionDiff)保持 snake_case 是刻意契约
407- /// (首次出现即定,前端类型注释"勿改"),rename 只作用于顶层键。
408- #[ derive( Debug , Clone , serde:: Serialize ) ]
409- #[ serde( rename_all = "camelCase" ) ]
410- pub struct WorkbenchData {
411- pub rule_markdown : String ,
412- pub refined_markdown : Option < String > ,
413- pub sections : Vec < SectionDiff > ,
414- pub stats : DiffStats ,
415- pub meta : Option < VersionMeta > ,
416- }
417-
418- /// 工作台数据接口:规则草稿 + 精修结果 + 章节分组 diff 一次取全。
419- ///
420- /// refined_markdown 为 None → 尚未精修;否则包含最新精修版内容与章节 diff。
421- ///
422- /// @ai-context: 精修版三级数据源(修复:原实现只查已落库笔记——采纳前打开
423- /// 工作台右侧恒空;且任务成功事件先于 DB 落库,存在竞态):
424- /// ① refine_result 参数(调用方内存结果——采纳前刚完成的任务,
425- /// 消除竞态)② 最新未采纳成功任务(DB 持久化——重启后恢复)
426- /// ③ 已落库笔记最新版本(采纳后)。
427- #[ tauri:: command]
428- pub async fn refine_workbench (
429- state : State < ' _ , AppState > ,
430- session_id : i64 ,
431- refine_result : Option < AiRefineResult > ,
432- ) -> Result < WorkbenchData , String > {
433- if session_id <= 0 {
434- return Err ( "无效的会话 id" . to_string ( ) ) ;
435- }
436- // ① 构建规则草稿(与精修任务同管线)
437- let env = PurifyEnv {
438- config : state. purify . clone ( ) ,
439- symbol : state. symbol_normalize . clone ( ) ,
440- corrections : state. ocr_corrections . clone ( ) ,
441- } ;
442- let ( draft, _) = build_rule_draft_with_analysis (
443- & state. db ,
444- & state. ui_junk ,
445- & env,
446- & state. data_dir ,
447- session_id,
448- None ,
449- )
450- . map_err ( |e| e. to_string ( ) ) ?;
451- let rule_md = strip_anchors ( & draft. markdown ) ;
452-
453- // ② 精修版数据源:内存结果(优先)> 未采纳成功任务(DB 兜底)> 已落库笔记
454- let unadopted = state
455- . db
456- . find_latest_unadopted_refine ( session_id)
457- . map_err ( |e| e. to_string ( ) ) ?;
458- let pending_result: Option < AiRefineResult > = match ( refine_result, unadopted) {
459- ( Some ( r) , _) => Some ( r) ,
460- // 解析失败降级(日志可观测——不影响工作台打开,可经任务中心重取)
461- ( None , Some ( task) ) => task. result_json . as_deref ( ) . and_then ( |j| {
462- serde_json:: from_str :: < AiRefineResult > ( j)
463- . map_err ( |e| eprintln ! ( "[refine-workbench] 未采纳任务 {} 结果解析失败: {}" , task. task_id, e) )
464- . ok ( )
465- } ) ,
466- ( None , None ) => None ,
467- } ;
468-
469- let note = state. db . find_note_by_session ( session_id) . map_err ( |e| e. to_string ( ) ) ?;
470- let ( refined_md, sections, stats, meta) = if let Some ( result) = pending_result {
471- let secs = diff_sections ( & rule_md, & result. refined_markdown ) ;
472- let st = stats_from ( & secs) ;
473- // 未落库:成本按模型单价在 apply 时核算——此处不做虚假回填
474- let m = VersionMeta {
475- cost_yuan : None ,
476- model : Some ( result. model . clone ( ) ) ,
477- slices : Some ( result. slices ) ,
478- merged_from : None ,
479- } ;
480- ( Some ( result. refined_markdown ) , secs, st, Some ( m) )
481- } else if let Some ( ref note) = note {
482- // 取最新版本内容作为精修版
483- let versions = state. db . list_versions ( note. id ) . map_err ( |e| e. to_string ( ) ) ?;
484- let latest = versions. last ( )
485- . map ( |v| v. content . clone ( ) )
486- . unwrap_or_else ( || note. content . clone ( ) ) ;
487- let secs = diff_sections ( & rule_md, & latest) ;
488- let st = stats_from ( & secs) ;
489- // 取最新版本 meta
490- let m = versions. last ( ) . map ( |v| VersionMeta {
491- cost_yuan : v. meta . cost_yuan ,
492- model : v. meta . model . clone ( ) ,
493- slices : v. meta . slices ,
494- merged_from : v. meta . merged_from . clone ( ) ,
495- } ) ;
496- ( Some ( latest) , secs, st, m)
497- } else {
498- ( None , vec ! [ ] , DiffStats { added : 0 , removed : 0 , unchanged : 0 } , None )
499- } ;
500-
501- Ok ( WorkbenchData {
502- rule_markdown : rule_md,
503- refined_markdown : refined_md,
504- sections,
505- stats,
506- meta,
507- } )
508- }
509-
510- /// 章节分组 diff 统计(新增/删除/未变行数——与 diff_sections 同口径)。
511- fn stats_from ( secs : & [ SectionDiff ] ) -> DiffStats {
512- DiffStats {
513- added : secs. iter ( ) . map ( |s| s. added_lines . len ( ) ) . sum ( ) ,
514- removed : secs. iter ( ) . map ( |s| s. removed_lines . len ( ) ) . sum ( ) ,
515- unchanged : secs. iter ( )
516- . filter ( |s| s. status == crate :: note_diff:: DiffStatus :: Unchanged )
517- . count ( ) ,
518- }
519- }
520-
521- #[ cfg( test) ]
522- mod tests {
523- use super :: * ;
524-
525- /// v0.12.3 回归(Bug#2):WorkbenchData 顶层键必须 camelCase——
526- /// 前端按 ruleMarkdown/refinedMarkdown 读取;snake_case 键使字段为
527- /// undefined → renderMd(undefined).split 抛
528- /// "Cannot read properties of undefined (reading 'split')"
529- /// (原实现缺失 rename_all,v0.11.5 引入、真机验收漏测)。
530- #[ test]
531- fn workbench_data_serializes_camel_case_top_level ( ) {
532- let data = WorkbenchData {
533- rule_markdown : "# 标题\n 正文" . to_string ( ) ,
534- refined_markdown : Some ( "# 标题\n 精修正文" . to_string ( ) ) ,
535- sections : vec ! [ crate :: note_diff:: SectionDiff {
536- heading: "标题" . to_string( ) ,
537- status: crate :: note_diff:: DiffStatus :: Modified ,
538- removed_lines: vec![ "正文" . to_string( ) ] ,
539- added_lines: vec![ "精修正文" . to_string( ) ] ,
540- } ] ,
541- stats : crate :: note_diff:: DiffStats {
542- added : 1 ,
543- removed : 1 ,
544- unchanged : 0 ,
545- } ,
546- meta : Some ( crate :: note_version:: VersionMeta {
547- cost_yuan : Some ( 0.01 ) ,
548- model : Some ( "test-model" . to_string ( ) ) ,
549- slices : Some ( 1 ) ,
550- merged_from : None ,
551- } ) ,
552- } ;
553- let v = serde_json:: to_value ( & data) . expect ( "序列化失败" ) ;
554- let obj = v. as_object ( ) . expect ( "应为 JSON 对象" ) ;
555- assert ! ( obj. contains_key( "ruleMarkdown" ) , "顶层键必须为 ruleMarkdown(camelCase)" ) ;
556- assert ! ( obj. contains_key( "refinedMarkdown" ) , "顶层键必须为 refinedMarkdown(camelCase)" ) ;
557- assert ! ( obj. contains_key( "sections" ) ) ;
558- assert ! ( obj. contains_key( "stats" ) ) ;
559- assert ! ( obj. contains_key( "meta" ) ) ;
560- // 嵌套 SectionDiff 保持 snake_case(首次出现即定的契约,勿随顶层 rename)
561- let sec = & obj[ "sections" ] [ 0 ] ;
562- assert ! ( sec. get( "removed_lines" ) . is_some( ) , "SectionDiff 嵌套键保持 snake_case" ) ;
563- assert ! ( sec. get( "added_lines" ) . is_some( ) ) ;
564- assert_eq ! ( sec[ "status" ] , "modified" ) ;
565- }
566-
567- /// stats_from:按 diff_sections 分组行数统计(工作台头部 新增/删除/未变 徽标)。
568- #[ test]
569- fn stats_from_aggregates_section_lines ( ) {
570- use crate :: note_diff:: { DiffStatus , SectionDiff } ;
571- let secs = vec ! [
572- SectionDiff { heading: "A" . into( ) , status: DiffStatus :: Modified , removed_lines: vec![ "a" . into( ) ] , added_lines: vec![ "A" . into( ) , "B" . into( ) ] } ,
573- SectionDiff { heading: "B" . into( ) , status: DiffStatus :: Unchanged , removed_lines: vec![ ] , added_lines: vec![ ] } ,
574- SectionDiff { heading: "C" . into( ) , status: DiffStatus :: Added , removed_lines: vec![ ] , added_lines: vec![ "c" . into( ) ] } ,
575- SectionDiff { heading: "D" . into( ) , status: DiffStatus :: Removed , removed_lines: vec![ "d" . into( ) ] , added_lines: vec![ ] } ,
576- ] ;
577- let s = stats_from ( & secs) ;
578- assert_eq ! ( s. added, 3 ) ;
579- assert_eq ! ( s. removed, 2 ) ;
580- assert_eq ! ( s. unchanged, 1 ) ;
581- }
582- }
0 commit comments