1616//! · dto.rs —— IPC 契约类型(4 个 serde 结构体;字段序 = JSON 键序)
1717//! · gate.rs —— 成本硬拦截(免费档/本地 Provider 跳过;查询失败宽容放行)
1818//! · registry.rs —— 任务注册表 + id 序列 + 容量守卫 + 状态写入口
19+ //! · session.rs —— 会话生命周期 6 命令(预估/启动/策略元数据/预览/状态/结果)
1920//! · workbench.rs —— 精修工作台(三级数据源 + 章节 diff 统计 + 内联契约测试)
2021//! @ai-context: 本外壳只留「跨域共享的命令 + 重导出门面」;外部 10 文件
2122//! 20 处 crate::commands_ai_refine::X 引用**零改动**(逐项重导出在下方);
2526mod dto;
2627mod gate;
2728mod registry;
29+ pub ( crate ) mod session;
2830pub ( crate ) mod workbench;
2931
3032pub use dto:: { AiRefineResult , AiTaskHandle , RefineEstimateView , RefineStrategyInfo } ;
@@ -34,247 +36,8 @@ pub(crate) use registry::{set_task, trim_tasks};
3436
3537use tauri:: State ;
3638
37- use crate :: ai_cost:: estimate_for_content_model;
38- use crate :: ai_note_refine:: NoteRefinePrompt ;
39- use crate :: ai_strategy:: { RefineStrategyMeta , StrategyOverride } ;
40- use crate :: ai_task:: AiTaskState ;
4139use crate :: commands:: AppState ;
42- use crate :: commands_session_note:: build_rule_draft_with_analysis;
43- use crate :: note_filter:: PurifyEnv ;
4440use crate :: types:: { NewNote , Note } ;
45- use crate :: video_profile:: ProfileKind ;
46-
47- /// mock 模式 env 键(本地规则精修,不联网——测试/离线开发,ai_text_filter 先例)。
48- const MOCK_ENV : & str = "AI_REFINE_MOCK" ;
49-
50- /// 成本预估(REQ-143 + F1 修复:按模型映射单价、预估含输出 token——
51- /// 切付费模型后费用不再显示 ¥0,消灭成本失真)。
52- /// 7️⃣ 修正(2026-08-22,spec 7️⃣):字符数按剥离锚点后的规则草稿计——与
53- /// 精修实际输入同口径(段落锚点不入模省 token),预估不再虚高。
54- #[ tauri:: command]
55- pub fn ai_refine_estimate ( state : State < ' _ , AppState > , session_id : i64 ) -> Result < RefineEstimateView , String > {
56- if session_id <= 0 {
57- return Err ( "无效的会话 id" . to_string ( ) ) ;
58- }
59- // 构建规则草稿(与精修任务同管线:filter + 结构渲染,本地快)后剥离锚点
60- let env = PurifyEnv {
61- config : state. purify . clone ( ) ,
62- symbol : state. symbol_normalize . clone ( ) ,
63- corrections : state. ocr_corrections . clone ( ) ,
64- } ;
65- let ( draft, _) = build_rule_draft_with_analysis (
66- & state. db ,
67- & state. ui_junk ,
68- & env,
69- & state. data_dir ,
70- session_id,
71- None ,
72- )
73- . map_err ( |e| e. to_string ( ) ) ?;
74- let chars = crate :: anchor_strip:: strip_anchors ( & draft. markdown ) . chars ( ) . count ( ) ;
75- let remember = state
76- . ai_settings
77- . lock ( )
78- . map ( |s| s. remember_cost_choice )
79- . unwrap_or ( false ) ;
80- let model = state. ai_settings . lock ( ) . map ( |s| s. model . clone ( ) ) . unwrap_or_default ( ) ;
81- Ok ( RefineEstimateView { estimate : estimate_for_content_model ( chars, & model) , remember_cost_choice : remember } )
82- }
83-
84- /// 启动 AI 精修异步任务(授权红线 + 密钥解析 + 后台切片逐片精修)。
85- ///
86- /// @ai-context: v0.17.0(REQ-245):strategy=任务级策略覆盖(可选——档位 +
87- /// 逐维覆盖;缺省用设置全局默认;非法值 resolve 内部回退默认,
88- /// 永不阻断)。dims 解析后传入任务(每片提示词一致)。
89- #[ tauri:: command]
90- pub async fn ai_refine_start (
91- state : State < ' _ , AppState > ,
92- session_id : i64 ,
93- authorized : bool ,
94- strategy : Option < StrategyOverride > ,
95- // REQ-284(v0.19.7):画面理解任务级覆写(None=跟随全局;前端「仅本次」勾选)
96- vision_refine : Option < bool > ,
97- ) -> Result < AiTaskHandle , String > {
98- if session_id <= 0 {
99- return Err ( "无效的会话 id" . to_string ( ) ) ;
100- }
101- let st: AppState = ( * state) . clone ( ) ;
102- // ① 授权红线:内容上传类调用 gate(enabled + authorized 双条件)
103- let settings = st. ai_settings . lock ( ) . map_err ( |e| format ! ( "AI 设置锁中毒: {}" , e) ) ?. clone ( ) ;
104- settings. content_gate ( ) ?;
105- if !authorized {
106- return Err ( "本次上传未确认——请先阅读并同意授权说明" . to_string ( ) ) ;
107- }
108- let mock = std:: env:: var ( MOCK_ENV ) . map ( |v| v == "1" ) . unwrap_or ( false ) ;
109- // ② 密钥解析(env > 凭据库);非 mock 且无密钥 → 明确错误(不创建任务)
110- if !mock {
111- let ready = crate :: commands_ai_providers:: default_provider_ready ( & st) ?;
112- if !ready {
113- return Err ( "未配置 API 密钥(请在设置页 AI 服务提供商中配置)" . to_string ( ) ) ;
114- }
115- }
116- // ②b F1 修复(2026-08-21)+ 审查修复(2026-08-21):任务去重——按
117- // **目标会话**粒度检查进行中任务(防双击/重进/多窗口对同一会话重复
118- // 扣费;不同会话的任务互不阻塞——原实现全表 any 会误伤其他会话)
119- {
120- let tasks = st. ai_tasks . lock ( ) . map_err ( |e| format ! ( "任务注册表锁中毒: {}" , e) ) ?;
121- let active = tasks. values ( ) . any ( |t| {
122- t. target_id == session_id
123- && matches ! ( t. state, AiTaskState :: Pending | AiTaskState :: Running { .. } )
124- } ) ;
125- if active {
126- return Err ( "该会话已有进行中的 AI 任务——请等待完成或到任务中心查看进度(防重复扣费)" . to_string ( ) ) ;
127- }
128- }
129- // ②c F1/F3-D 修复(2026-08-21):成本硬拦截 + 每日配额接入。
130- // 顺序铁律(审查修复):先余额拦截(失败不消耗配额),后消耗配额——
131- // 否则余额不足被拒时配额已扣(浪费每日额度)。
132- if !mock {
133- let segments = st. db . list_segments ( session_id) . map_err ( |e| e. to_string ( ) ) ?;
134- let ocr = st. db . list_ocr_blocks ( session_id) . map_err ( |e| e. to_string ( ) ) ?;
135- let chars = segments. iter ( ) . map ( |s| s. text . chars ( ) . count ( ) ) . sum :: < usize > ( )
136- + ocr. iter ( ) . map ( |b| b. text . chars ( ) . count ( ) ) . sum :: < usize > ( ) ;
137- // 成本硬拦截(免费档 ¥0 预估 → 余额 0 也放行;查询失败宽容放行)
138- ensure_balance_for ( & st, chars, & settings. model ) ?;
139- // 片数估算(与 ai_task::slice_note 同口径的保守上界:向上取整,
140- // 空内容 0 片不消耗配额——审查修复:原公式 +1 导致空会话也扣 1)
141- let slices = if chars == 0 {
142- 0
143- } else {
144- chars. saturating_add ( crate :: ai_task:: SLICE_MAX_CHARS - 1 ) / crate :: ai_task:: SLICE_MAX_CHARS
145- } ;
146- let now = crate :: db_sessions_rows:: unix_seconds ( ) ;
147- let mut guards = st. ai_guardrails . lock ( ) . map_err ( |e| format ! ( "护栏状态锁中毒: {}" , e) ) ?;
148- for _ in 0 ..slices {
149- if !guards. quota . try_consume ( now) {
150- return Err ( "今日 AI 精修配额已用完(请明日再试或到设置页调整)" . to_string ( ) ) ;
151- }
152- }
153- drop ( guards) ;
154- }
155- // ③ 注册任务 + 后台执行(spawn_blocking——网络/分析不阻塞异步运行时);
156- // 任务 id 走全任务族统一认领封装(claim_task_id——见其 Why:proofread
157- // 原 +1 偏移与各族口径冲突,相邻认领撞 id 会被 INSERT OR REPLACE 顶替)
158- let task_id = claim_task_id ( & st. ai_task_seq ) ;
159- {
160- let mut tasks = st. ai_tasks . lock ( ) . map_err ( |e| format ! ( "任务注册表锁中毒: {}" , e) ) ?;
161- tasks. insert ( task_id, AiTaskEntry { state : AiTaskState :: Pending , result : None , target_id : session_id } ) ;
162- trim_tasks ( & mut tasks) ;
163- }
164- // F2 任务中心(2026-08-21):任务记录落库(pending 起步;终态在
165- // run_refine_task 回写)。L4 修复:写库失败仍不阻断 AI 调用(H2 设计不变),
166- // 但不再静默——落库失败意味着任务中心重启后无法恢复该任务,必须可观测
167- if let Err ( e) = st. db . insert_ai_task ( & crate :: db_ai_tasks:: AiTaskRecord {
168- task_id,
169- op_type : "refine" . to_string ( ) ,
170- ref_id : session_id,
171- state : "pending" . to_string ( ) ,
172- result_json : None ,
173- cost_yuan : None ,
174- elapsed_ms : None ,
175- model : None ,
176- error : None ,
177- slices : None ,
178- created_at : crate :: db_sessions_rows:: unix_seconds ( ) ,
179- finished_at : None ,
180- adopted : false ,
181- target_kind : Some ( "session" . to_string ( ) ) ,
182- } ) {
183- eprintln ! ( "[AiTasks] refine 任务 {} 落库失败(不阻断 AI 调用;重启后不可恢复): {}" , task_id, e) ;
184- }
185- let st2 = st. clone ( ) ;
186- // v0.17.0:策略解析(任务覆盖 > 设置全局默认 > 内置 standard——非法值
187- // resolve 内回退,永不阻断精修主链路;标准档=现状逐字节一致)
188- let dims = crate :: ai_strategy:: resolve (
189- & NoteRefinePrompt :: bundled ( ) ,
190- & settings. refine_strategy ,
191- strategy. as_ref ( ) ,
192- ) ;
193- tauri:: async_runtime:: spawn_blocking ( move || {
194- crate :: ai_refine_task:: run_refine_task ( st2, task_id, session_id, mock, dims, vision_refine)
195- } ) ;
196- Ok ( AiTaskHandle { task_id, state : AiTaskState :: Pending } )
197- }
198-
199- /// 策略声明元数据(发起对话框/设置页渲染数据源——后端声明即事实源)。
200- #[ tauri:: command]
201- pub fn ai_refine_strategy_meta ( ) -> Result < RefineStrategyMeta , String > {
202- let p = NoteRefinePrompt :: bundled ( ) ;
203- Ok ( RefineStrategyMeta {
204- strategy_dims : p. strategy_dims ,
205- ladder_presets : p. ladder_presets ,
206- intents : p. intents ,
207- } )
208- }
209-
210- /// 提示词组装预览(与实发精修同一 build_system 代码路径——所见即所发)。
211- ///
212- /// @ai-context: 档案来源二选一:session_id(会话级——会话档案驱动风格模板)
213- /// 或 profile(笔记级——handwritten/用户所选档案);全局偏好 +
214- /// 任务级覆盖参与解析;返回完整 system 提示词(只读预览 + 复制)。
215- #[ tauri:: command]
216- pub fn ai_refine_prompt_preview (
217- state : State < ' _ , AppState > ,
218- session_id : Option < i64 > ,
219- profile : Option < String > ,
220- strategy : Option < StrategyOverride > ,
221- ) -> Result < String , String > {
222- let kind = match session_id. filter ( |v| * v > 0 ) {
223- Some ( sid) => {
224- let session = state
225- . db
226- . get_session ( sid)
227- . map_err ( |e| format ! ( "读取会话失败: {}" , e) ) ?
228- . ok_or_else ( || "会话不存在" . to_string ( ) ) ?;
229- session
230- . profile
231- . as_deref ( )
232- . map ( ProfileKind :: parse)
233- . unwrap_or ( ProfileKind :: Lecture )
234- }
235- None => ProfileKind :: parse ( profile. as_deref ( ) . unwrap_or ( "handwritten" ) ) ,
236- } ;
237- let prefs = state
238- . ai_settings
239- . lock ( )
240- . map_err ( |e| format ! ( "AI 设置锁中毒: {}" , e) ) ?
241- . refine_strategy
242- . clone ( ) ;
243- Ok ( crate :: ai_strategy:: preview_system (
244- & NoteRefinePrompt :: bundled ( ) ,
245- kind. as_str ( ) ,
246- & prefs,
247- strategy. as_ref ( ) ,
248- ) )
249- }
250-
251- /// 任务状态查询(前端轮询通道;事件通道见 "ai:task-update")。
252- #[ tauri:: command]
253- pub fn ai_refine_status ( state : State < ' _ , AppState > , task_id : u64 ) -> Result < AiTaskState , String > {
254- state
255- . ai_tasks
256- . lock ( )
257- . map_err ( |e| format ! ( "任务注册表锁中毒: {}" , e) ) ?
258- . get ( & task_id)
259- . map ( |t| t. state . clone ( ) )
260- . ok_or_else ( || format ! ( "任务不存在: {}" , task_id) )
261- }
262-
263- /// 精修结果(仅成功后可取;失败/进行中返回明确错误)。
264- #[ tauri:: command]
265- pub fn ai_refine_result ( state : State < ' _ , AppState > , task_id : u64 ) -> Result < AiRefineResult , String > {
266- let tasks = state. ai_tasks . lock ( ) . map_err ( |e| format ! ( "任务注册表锁中毒: {}" , e) ) ?;
267- let entry = tasks
268- . get ( & task_id)
269- . ok_or_else ( || format ! ( "任务不存在: {}" , task_id) ) ?;
270- match ( & entry. state , & entry. result ) {
271- ( AiTaskState :: Succeeded , Some ( v) ) => serde_json:: from_value ( v. clone ( ) )
272- . map_err ( |e| format ! ( "精修结果反序列化失败: {}" , e) ) ,
273- ( AiTaskState :: Succeeded , None ) => Err ( "任务成功但结果缺失(内部状态异常)" . to_string ( ) ) ,
274- ( AiTaskState :: Failed { reason } , _) => Err ( format ! ( "任务失败({}): {}" , reason. kind( ) , reason. message( ) ) ) ,
275- _ => Err ( "任务仍在进行中" . to_string ( ) ) ,
276- }
277- }
27841
27942/// 任务历史(F2 任务中心:前端面板数据源——按类型列最近任务)。
28043#[ tauri:: command]
0 commit comments