|
11 | 11 | //! 进度经 "ai:task-update" 事件 + ai_refine_status 查询双通道, |
12 | 12 | //! 网络调用在 spawn_blocking(不阻塞异步运行时)。 |
13 | 13 |
|
14 | | -use std::collections::HashMap; |
15 | | -use std::sync::atomic::{AtomicU64, Ordering}; |
16 | | -use std::sync::{Arc, Mutex}; |
17 | | - |
18 | | -use tauri::{Emitter, State}; |
19 | | - |
20 | | -use crate::ai_cost::{estimate_for_content_model, CostEstimate}; |
| 14 | +//! @ai-context: 目录模块(批 0-C3 Task 4 拆分,2026-09-11,原 751 行 → 外壳 ≤300)。 |
| 15 | +//! 子模块职责(D6,按域划分,逐条列出): |
| 16 | +//! · dto.rs —— IPC 契约类型(4 个 serde 结构体;字段序 = JSON 键序) |
| 17 | +//! · gate.rs —— 成本硬拦截(免费档/本地 Provider 跳过;查询失败宽容放行) |
| 18 | +//! · registry.rs —— 任务注册表 + id 序列 + 容量守卫 + 状态写入口 |
| 19 | +//! @ai-context: 本外壳只留「跨域共享的命令 + 重导出门面」;外部 10 文件 |
| 20 | +//! 20 处 crate::commands_ai_refine::X 引用**零改动**(逐项重导出在下方); |
| 21 | +//! #[tauri::command] 定义随域下沉后,注册路径同步改成 |
| 22 | +//! crate::commands_ai_refine::<子模块>::<cmd>(IPC 名 = 路径末段,逐字不变)。 |
| 23 | +
|
| 24 | +mod dto; |
| 25 | +mod gate; |
| 26 | +mod registry; |
| 27 | + |
| 28 | +pub use dto::{AiRefineResult, AiTaskHandle, RefineEstimateView, RefineStrategyInfo}; |
| 29 | +pub use registry::{claim_task_id, task_registry, task_seq, task_seq_lower_bound, AiTaskEntry}; |
| 30 | +pub(crate) use gate::ensure_balance_for; |
| 31 | +pub(crate) use registry::{set_task, trim_tasks}; |
| 32 | + |
| 33 | +use tauri::State; |
| 34 | + |
| 35 | +use crate::ai_cost::estimate_for_content_model; |
21 | 36 | use crate::ai_note_refine::NoteRefinePrompt; |
22 | 37 | use crate::ai_strategy::{RefineStrategyMeta, StrategyOverride}; |
23 | 38 | use crate::ai_task::AiTaskState; |
| 39 | +use crate::anchor_strip::strip_anchors; |
24 | 40 | use crate::commands::AppState; |
25 | 41 | use crate::commands_session_note::build_rule_draft_with_analysis; |
26 | | -use crate::anchor_strip::strip_anchors; |
27 | | -use crate::note_diff::{diff_sections, DiffOp, DiffStats, SectionDiff}; |
| 42 | +use crate::note_diff::{diff_sections, DiffStats, SectionDiff}; |
28 | 43 | use crate::note_filter::PurifyEnv; |
29 | 44 | use crate::note_version::VersionMeta; |
30 | 45 | use crate::types::{NewNote, Note}; |
31 | 46 | use crate::video_profile::ProfileKind; |
32 | 47 |
|
33 | 48 | /// mock 模式 env 键(本地规则精修,不联网——测试/离线开发,ai_text_filter 先例)。 |
34 | 49 | const MOCK_ENV: &str = "AI_REFINE_MOCK"; |
35 | | -/// 任务注册表容量上限(防无界增长:超限丢弃最旧终态任务)。 |
36 | | -const TASKS_CAP: usize = 100; |
37 | | - |
38 | | -/// 任务条目(注册表内:状态 + 成功结果)。 |
39 | | -/// |
40 | | -/// @ai-context: result 为序列化 JSON——精修(AiRefineResult)/补充 |
41 | | -/// (AiEnrichResult,M3)共用同一任务注册表(REQ-145 基建复用), |
42 | | -/// 各命令层自行反序列化。 |
43 | | -pub struct AiTaskEntry { |
44 | | - pub state: AiTaskState, |
45 | | - pub result: Option<serde_json::Value>, |
46 | | - /// 任务目标(去重粒度:精修=session_id、补充=note_id——审查修复 |
47 | | - /// 2026-08-21:原实现按全表 any 检查,会话 A 精修中时会话 B 也被拒) |
48 | | - pub target_id: i64, |
49 | | -} |
50 | | - |
51 | | -/// 精修成功载荷(前端 diff 预览 + 采纳落库数据源)。 |
52 | | -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] |
53 | | -#[serde(rename_all = "camelCase")] |
54 | | -pub struct AiRefineResult { |
55 | | - pub title: String, |
56 | | - /// 规则基线(本地规则版——采纳落库时作为首快照,版本链 [rule, ai-refine]) |
57 | | - pub base_markdown: String, |
58 | | - pub refined_markdown: String, |
59 | | - /// 与规则版的段级 diff(本地版为基线,AI 变化点高亮) |
60 | | - pub diff: Vec<DiffOp>, |
61 | | - pub added_lines: usize, |
62 | | - pub removed_lines: usize, |
63 | | - pub slices: usize, |
64 | | - /// F2-B4:失败片数(>0 = 部分成功——重试后仍失败保留已成功片) |
65 | | - pub failed_slices: usize, |
66 | | - pub model: String, |
67 | | - /// v0.17.0:本次策略溯源(档位 + 每维最终值——工作台溯源条数据源; |
68 | | - /// serde default 向前兼容:旧任务结果无此字段) |
69 | | - #[serde(default)] |
70 | | - pub strategy: Option<RefineStrategyInfo>, |
71 | | -} |
72 | | - |
73 | | -/// 策略溯源信息(AI 产出按什么规则变的——工作台溯源条展示)。 |
74 | | -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] |
75 | | -#[serde(rename_all = "camelCase")] |
76 | | -pub struct RefineStrategyInfo { |
77 | | - /// 档位 id(standard/faithful/deep/minimal/custom 或 intent:xxx——名称由前端 |
78 | | - /// 按 meta 声明解析,未知 id 原样展示——诚实不猜) |
79 | | - pub preset_id: String, |
80 | | - /// 每维最终值(key → value;chips 渲染源) |
81 | | - pub dims: std::collections::HashMap<String, String>, |
82 | | - /// 自定义档自由文本(仅 preset=custom 时有值——溯源条/重生成沿用,REQ-279) |
83 | | - #[serde(default, skip_serializing_if = "Option::is_none")] |
84 | | - pub custom_text: Option<String>, |
85 | | -} |
86 | | - |
87 | | -/// 任务句柄(前端轮询/事件对应用)。 |
88 | | -#[derive(Debug, Clone, serde::Serialize)] |
89 | | -#[serde(rename_all = "camelCase")] |
90 | | -pub struct AiTaskHandle { |
91 | | - pub task_id: u64, |
92 | | - pub state: AiTaskState, |
93 | | -} |
94 | | - |
95 | | -/// 成本预估视图(确认弹窗数据源;余额内联由前端复用 ai_get_balance)。 |
96 | | -#[derive(Debug, Clone, serde::Serialize)] |
97 | | -#[serde(rename_all = "camelCase")] |
98 | | -pub struct RefineEstimateView { |
99 | | - pub estimate: CostEstimate, |
100 | | - pub remember_cost_choice: bool, |
101 | | -} |
102 | 50 |
|
103 | 51 | /// 成本预估(REQ-143 + F1 修复:按模型映射单价、预估含输出 token—— |
104 | 52 | /// 切付费模型后费用不再显示 ¥0,消灭成本失真)。 |
@@ -570,123 +518,6 @@ fn stats_from(secs: &[SectionDiff]) -> DiffStats { |
570 | 518 | } |
571 | 519 | } |
572 | 520 |
|
573 | | -/// 更新任务状态并推送事件(短锁内完成即释放)。M3 补充任务复用(pub(crate))。 |
574 | | -pub(crate) fn set_task(st: &AppState, task_id: u64, new_state: AiTaskState) { |
575 | | - if let Ok(mut tasks) = st.ai_tasks.lock() { |
576 | | - if let Some(entry) = tasks.get_mut(&task_id) { |
577 | | - entry.state = new_state.clone(); |
578 | | - let _ = st.app.emit("ai:task-update", (task_id, &new_state)); |
579 | | - } |
580 | | - } |
581 | | -} |
582 | | - |
583 | | -/// 注册表容量守卫(超限丢弃最旧终态任务——防无界增长)。M3 补充任务复用。 |
584 | | -/// |
585 | | -/// @ai-context: 审查修复(2026-08-21):原实现终态任务数 < excess 时删不完 |
586 | | -/// (并行 Running 占满时 len 持续 > CAP)——改为 while 循环, |
587 | | -/// 无终态可删时停止(Running 任务不可删——任务执行中)。 |
588 | | -pub(crate) fn trim_tasks(tasks: &mut HashMap<u64, AiTaskEntry>) { |
589 | | - while tasks.len() > TASKS_CAP { |
590 | | - let oldest_terminal = tasks |
591 | | - .iter() |
592 | | - .filter(|(_, t)| !matches!(t.state, AiTaskState::Pending | AiTaskState::Running { .. })) |
593 | | - .min_by_key(|(id, _)| **id) |
594 | | - .map(|(id, _)| *id); |
595 | | - match oldest_terminal { |
596 | | - Some(id) => { |
597 | | - tasks.remove(&id); |
598 | | - } |
599 | | - None => break, |
600 | | - } |
601 | | - } |
602 | | -} |
603 | | - |
604 | | -/// 任务序列(AppState 装配)。 |
605 | | -pub fn task_seq() -> Arc<AtomicU64> { |
606 | | - Arc::new(AtomicU64::new(1)) |
607 | | -} |
608 | | - |
609 | | -/// id 序列下限(纯函数):取「当前值」与「DB 最大 task_id + 1」之大。 |
610 | | -/// |
611 | | -/// @ai-context Why(2026-09-09 批 1 修复):启动恢复后序列必须越过 **DB |
612 | | -/// 全表** 最大 id——恢复集只含未采纳成功任务,已采纳/failed/ |
613 | | -/// proofread/goal_plan 行 id 更大时,若只按恢复集推进,新任务 |
614 | | -/// 将复用历史 task_id 并被 insert_ai_task 的 INSERT OR REPLACE |
615 | | -/// 静默顶替(历史行含已采纳——数据丢失)。saturating 防极端 id |
616 | | -/// 溢出;序列只前进不回退(当前值更大时保持)。 |
617 | | -pub fn task_seq_lower_bound(current: u64, db_max_task_id: u64) -> u64 { |
618 | | - current.max(db_max_task_id.saturating_add(1)) |
619 | | -} |
620 | | - |
621 | | -/// 认领下一个 AI 任务 id(全任务族唯一分配点——proofread/refine/enrich/ |
622 | | -/// note_refine/goal_plan 共用的单调序列,返回值即 task_id,推进量=认领量)。 |
623 | | -/// |
624 | | -/// @ai-context Why(2026-09-09 批 2 审查修复):proofread 原写作 |
625 | | -/// fetch_add(1, SeqCst) + 1(+1 存量来源 1904c2c7——按「0 起 |
626 | | -/// 序列」直觉,但 task_seq() 初值 1 且其余族均以 fetch_add |
627 | | -/// 返回值直接作 id)。fetch_add(1)+1 只把计数器推进 1 却领走 |
628 | | -/// 后值:紧邻的下一次认领(任意其他族)恰好拿到同一 id → |
629 | | -/// insert_ai_task 的 INSERT OR REPLACE 运行期顶替先落库行 |
630 | | -/// (running 记账/结果/成本丢行)。统一本函数分配:认领严格 |
631 | | -/// 单调、相邻认领永不相交。Relaxed 即足——唯一性由 fetch_add |
632 | | -/// 原子读-改-写保证,无需跨线程同步排序(与启动序列下限 |
633 | | -/// fetch_update 及 enrich/note_refine/goal_plan 现场同式内联 |
634 | | -/// 的 Relaxed 同档,口径一致)。 |
635 | | -pub fn claim_task_id(seq: &AtomicU64) -> u64 { |
636 | | - seq.fetch_add(1, Ordering::Relaxed) |
637 | | -} |
638 | | - |
639 | | -/// 任务注册表(AppState 装配)。 |
640 | | -pub fn task_registry() -> Arc<Mutex<HashMap<u64, AiTaskEntry>>> { |
641 | | - Arc::new(Mutex::new(HashMap::new())) |
642 | | -} |
643 | | - |
644 | | -/// 成本硬拦截安全系数(预估费用 × 系数 < 余额才放行——防预估偏差导致 |
645 | | -/// 中途余额耗尽;免费档 ¥0 预估恒放行)。 |
646 | | -const BALANCE_SAFETY_FACTOR: f64 = 1.2; |
647 | | - |
648 | | -/// 成本硬拦截(F3-D,2026-08-21):启动前校验余额。 |
649 | | -/// |
650 | | -/// @ai-context: 流程:按字符数预估费用(模型映射单价 + 输出 token)→ 查余额 |
651 | | -/// (复用 AiBalanceAdapter)→ 余额 < 预估×1.2 → 拒绝启动 + 三出口 |
652 | | -/// 引导(充值/切免费档模型/放弃)。免费档(预估 ¥0)→ 恒放行 |
653 | | -/// (余额 0 也可精修——免费模型不扣费);余额查询失败 → 放行 |
654 | | -/// (不因余额接口抖动阻断功能——降级宽容,费用风险由确认弹窗 |
655 | | -/// 展示承担)。精修/补充共用(补充经 enrich 命令调用本函数)。 |
656 | | -pub(crate) fn ensure_balance_for(st: &AppState, chars: usize, model: &str) -> Result<(), String> { |
657 | | - let est = estimate_for_content_model(chars, model); |
658 | | - if est.est_cost_yuan <= 0.0 { |
659 | | - return Ok(()); // 免费档/单价 0——无扣费风险,不拦截 |
660 | | - } |
661 | | - let required = est.est_cost_yuan * BALANCE_SAFETY_FACTOR; |
662 | | - // 余额查询(短超时——余额接口抖动不阻断精修;失败放行宽容降级) |
663 | | - // M1 统一门禁:Ollama 本地 Provider 无计费语义——跳过余额检查(m-7.3) |
664 | | - if crate::commands_ai_providers::is_default_provider_local(st) { |
665 | | - return Ok(()); |
666 | | - } |
667 | | - if !crate::commands_ai_providers::default_provider_ready(st)? { |
668 | | - return Err("未配置 API 密钥——请在设置页「AI 服务提供商」配置密钥(或使用 Ollama 本地)".to_string()); |
669 | | - } |
670 | | - let api_key = crate::commands_ai_providers::resolve_default_provider_key(st)?.unwrap_or_default(); |
671 | | - let settings = st.ai_settings.lock().map_err(|e| format!("AI 设置锁中毒: {}", e))?.clone(); |
672 | | - let store = st.ai_providers.lock().map_err(|e| format!("AI Provider 存储锁中毒: {}", e))?.clone(); |
673 | | - let cfg = crate::ai_client::AiClient::from_settings_with_store(&settings, Some(api_key), &store).config; |
674 | | - let adapter = crate::ai_balance::AiBalanceAdapter { |
675 | | - base_url: cfg.base_url, |
676 | | - api_key: cfg.api_key, |
677 | | - timeout_secs: cfg.timeout_secs, |
678 | | - max_retries: 0, // 拦截是前置守卫——不重试,失败放行 |
679 | | - }; |
680 | | - match adapter.fetch() { |
681 | | - Ok(balance) if balance.total_balance < required => Err(format!( |
682 | | - "余额不足:当前 ¥{:.2},本次预估 ¥{:.4}(安全系数 ×1.2)——请充值或切换免费档模型后重试", |
683 | | - balance.total_balance, est.est_cost_yuan |
684 | | - )), |
685 | | - Ok(_) => Ok(()), |
686 | | - Err(_) => Ok(()), // 余额查询失败 → 放行(宽容降级,费用由确认弹窗展示) |
687 | | - } |
688 | | -} |
689 | | - |
690 | 521 | #[cfg(test)] |
691 | 522 | mod tests { |
692 | 523 | use super::*; |
|
0 commit comments