Skip to content

Commit d2443ce

Browse files
committed
refactor(rust): 拆 commands_ai_refine 步 1 — 目录模块+dto/registry/gate
1 parent 67427ee commit d2443ce

5 files changed

Lines changed: 261 additions & 195 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//! AI 精修命令域的 IPC 契约类型(批 0-C3 Task 4 拆分,原 commands_ai_refine.rs 51–101 行)。
2+
//!
3+
//! @ai-context: 前端 diff 预览 / 任务面板 / 确认弹窗 / 策略溯源条的**唯一数据源**——
4+
//! 4 个类型逐字搬运:derive 清单、#[serde(rename_all = "camelCase")]、
5+
//! 字段声明顺序(= JSON 键序)、#[serde(default)] /
6+
//! skip_serializing_if 一律未动。字段重排或属性增删会静默改变序列化字节
7+
//! (旧任务结果反序列化、前端 undefined 崩溃的复现面)。
8+
//! @ai-context: 边界:纯数据契约——无副作用、无 IO、无锁。AiTaskEntry **刻意不在此**
9+
//! (它无 serde derive,是进程内注册表条目 → registry.rs);WorkbenchData
10+
//! 也刻意不在此(它带工作台命令的内联契约测试 → workbench.rs)。
11+
12+
use crate::ai_cost::CostEstimate;
13+
use crate::ai_task::AiTaskState;
14+
use crate::note_diff::DiffOp;
15+
16+
/// 精修成功载荷(前端 diff 预览 + 采纳落库数据源)。
17+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
18+
#[serde(rename_all = "camelCase")]
19+
pub struct AiRefineResult {
20+
pub title: String,
21+
/// 规则基线(本地规则版——采纳落库时作为首快照,版本链 [rule, ai-refine])
22+
pub base_markdown: String,
23+
pub refined_markdown: String,
24+
/// 与规则版的段级 diff(本地版为基线,AI 变化点高亮)
25+
pub diff: Vec<DiffOp>,
26+
pub added_lines: usize,
27+
pub removed_lines: usize,
28+
pub slices: usize,
29+
/// F2-B4:失败片数(>0 = 部分成功——重试后仍失败保留已成功片)
30+
pub failed_slices: usize,
31+
pub model: String,
32+
/// v0.17.0:本次策略溯源(档位 + 每维最终值——工作台溯源条数据源;
33+
/// serde default 向前兼容:旧任务结果无此字段)
34+
#[serde(default)]
35+
pub strategy: Option<RefineStrategyInfo>,
36+
}
37+
38+
/// 策略溯源信息(AI 产出按什么规则变的——工作台溯源条展示)。
39+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
40+
#[serde(rename_all = "camelCase")]
41+
pub struct RefineStrategyInfo {
42+
/// 档位 id(standard/faithful/deep/minimal/custom 或 intent:xxx——名称由前端
43+
/// 按 meta 声明解析,未知 id 原样展示——诚实不猜)
44+
pub preset_id: String,
45+
/// 每维最终值(key → value;chips 渲染源)
46+
pub dims: std::collections::HashMap<String, String>,
47+
/// 自定义档自由文本(仅 preset=custom 时有值——溯源条/重生成沿用,REQ-279)
48+
#[serde(default, skip_serializing_if = "Option::is_none")]
49+
pub custom_text: Option<String>,
50+
}
51+
52+
/// 任务句柄(前端轮询/事件对应用)。
53+
#[derive(Debug, Clone, serde::Serialize)]
54+
#[serde(rename_all = "camelCase")]
55+
pub struct AiTaskHandle {
56+
pub task_id: u64,
57+
pub state: AiTaskState,
58+
}
59+
60+
/// 成本预估视图(确认弹窗数据源;余额内联由前端复用 ai_get_balance)。
61+
#[derive(Debug, Clone, serde::Serialize)]
62+
#[serde(rename_all = "camelCase")]
63+
pub struct RefineEstimateView {
64+
pub estimate: CostEstimate,
65+
pub remember_cost_choice: bool,
66+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//! 成本硬拦截门禁(批 0-C3 Task 4 拆分,原 commands_ai_refine.rs 644–688 行)。
2+
//!
3+
//! @ai-context: 精修 / 补充 / 笔记精修 / 学习目标计划 **4 个模块共用**的启动前余额校验:
4+
//! 按字符数预估费用(模型映射单价 + 输出 token)× 安全系数 1.2 < 余额才放行;
5+
//! 免费档(预估 ¥0)与 Ollama 本地 Provider 恒放行;余额查询失败宽容放行
6+
//! (降级口径——费用风险由前端确认弹窗承担,不因余额接口抖动阻断功能)。
7+
//! @ai-context: 副作用与边界:含**同步** HTTP 余额查询(ureq,max_retries = 0,不重试);
8+
//! ai_settings / ai_providers 两条锁各自 .clone() 后立即释放,**不持锁做
9+
//! 网络 IO** —— 搬运时不得改成持锁调用 adapter.fetch()。
10+
//! BALANCE_SAFETY_FACTOR 与错误文案里硬编码的 "×1.2" 重复是既有缺陷
11+
//! (分析 §R7),本批只搬不改。
12+
13+
use crate::ai_cost::estimate_for_content_model;
14+
use crate::commands::AppState;
15+
16+
/// 成本硬拦截安全系数(预估费用 × 系数 < 余额才放行——防预估偏差导致
17+
/// 中途余额耗尽;免费档 ¥0 预估恒放行)。
18+
const BALANCE_SAFETY_FACTOR: f64 = 1.2;
19+
20+
/// 成本硬拦截(F3-D,2026-08-21):启动前校验余额。
21+
///
22+
/// @ai-context: 流程:按字符数预估费用(模型映射单价 + 输出 token)→ 查余额
23+
/// (复用 AiBalanceAdapter)→ 余额 < 预估×1.2 → 拒绝启动 + 三出口
24+
/// 引导(充值/切免费档模型/放弃)。免费档(预估 ¥0)→ 恒放行
25+
/// (余额 0 也可精修——免费模型不扣费);余额查询失败 → 放行
26+
/// (不因余额接口抖动阻断功能——降级宽容,费用风险由确认弹窗
27+
/// 展示承担)。精修/补充共用(补充经 enrich 命令调用本函数)。
28+
pub(crate) fn ensure_balance_for(st: &AppState, chars: usize, model: &str) -> Result<(), String> {
29+
let est = estimate_for_content_model(chars, model);
30+
if est.est_cost_yuan <= 0.0 {
31+
return Ok(()); // 免费档/单价 0——无扣费风险,不拦截
32+
}
33+
let required = est.est_cost_yuan * BALANCE_SAFETY_FACTOR;
34+
// 余额查询(短超时——余额接口抖动不阻断精修;失败放行宽容降级)
35+
// M1 统一门禁:Ollama 本地 Provider 无计费语义——跳过余额检查(m-7.3)
36+
if crate::commands_ai_providers::is_default_provider_local(st) {
37+
return Ok(());
38+
}
39+
if !crate::commands_ai_providers::default_provider_ready(st)? {
40+
return Err("未配置 API 密钥——请在设置页「AI 服务提供商」配置密钥(或使用 Ollama 本地)".to_string());
41+
}
42+
let api_key = crate::commands_ai_providers::resolve_default_provider_key(st)?.unwrap_or_default();
43+
let settings = st.ai_settings.lock().map_err(|e| format!("AI 设置锁中毒: {}", e))?.clone();
44+
let store = st.ai_providers.lock().map_err(|e| format!("AI Provider 存储锁中毒: {}", e))?.clone();
45+
let cfg = crate::ai_client::AiClient::from_settings_with_store(&settings, Some(api_key), &store).config;
46+
let adapter = crate::ai_balance::AiBalanceAdapter {
47+
base_url: cfg.base_url,
48+
api_key: cfg.api_key,
49+
timeout_secs: cfg.timeout_secs,
50+
max_retries: 0, // 拦截是前置守卫——不重试,失败放行
51+
};
52+
match adapter.fetch() {
53+
Ok(balance) if balance.total_balance < required => Err(format!(
54+
"余额不足:当前 ¥{:.2},本次预估 ¥{:.4}(安全系数 ×1.2)——请充值或切换免费档模型后重试",
55+
balance.total_balance, est.est_cost_yuan
56+
)),
57+
Ok(_) => Ok(()),
58+
Err(_) => Ok(()), // 余额查询失败 → 放行(宽容降级,费用由确认弹窗展示)
59+
}
60+
}

app/src-tauri/src/commands_ai_refine.rs renamed to app/src-tauri/src/commands_ai_refine/mod.rs

Lines changed: 24 additions & 193 deletions
Original file line numberDiff line numberDiff line change
@@ -11,94 +11,42 @@
1111
//! 进度经 "ai:task-update" 事件 + ai_refine_status 查询双通道,
1212
//! 网络调用在 spawn_blocking(不阻塞异步运行时)。
1313
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;
2136
use crate::ai_note_refine::NoteRefinePrompt;
2237
use crate::ai_strategy::{RefineStrategyMeta, StrategyOverride};
2338
use crate::ai_task::AiTaskState;
39+
use crate::anchor_strip::strip_anchors;
2440
use crate::commands::AppState;
2541
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};
2843
use crate::note_filter::PurifyEnv;
2944
use crate::note_version::VersionMeta;
3045
use crate::types::{NewNote, Note};
3146
use crate::video_profile::ProfileKind;
3247

3348
/// mock 模式 env 键(本地规则精修,不联网——测试/离线开发,ai_text_filter 先例)。
3449
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-
}
10250

10351
/// 成本预估(REQ-143 + F1 修复:按模型映射单价、预估含输出 token——
10452
/// 切付费模型后费用不再显示 ¥0,消灭成本失真)。
@@ -570,123 +518,6 @@ fn stats_from(secs: &[SectionDiff]) -> DiffStats {
570518
}
571519
}
572520

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-
690521
#[cfg(test)]
691522
mod tests {
692523
use super::*;

0 commit comments

Comments
 (0)