Skip to content

Commit 4c44297

Browse files
committed
feat(ai): v0.8.0 M4 笔记版本管理+成本完整——快照链/回滚/diff/时间线/落库 (REQ-144+143)
1 parent b4e7e79 commit 4c44297

20 files changed

Lines changed: 1112 additions & 35 deletions

‎CHANGELOG.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,20 @@
33
本项目遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 与 [SemVer](https://semver.org/lang/zh-CN/)。
44
各版本的深度版本文档见 [docs/versions/](docs/versions/)。
55

6-
## [0.8.0] - 2026-08(AI 接入,M1/M2/M3 开发中)
6+
## [0.8.0] - 2026-08(AI 接入,M1/M2/M3/M4 开发中)
7+
8+
### 新增(M4 版本管理与成本完整,REQ-144 + REQ-143 完整)
9+
10+
- **notes_versions 快照链(REQ-144)**:`(id, note_id, content, source: rule|ai-refine|ai-enrich|user-edit, parent_id, created_at, meta{成本/模型/切片/合并摘要})`;旧数据迁移兼容(旧笔记首快照=当前内容,惰性创建);**统一 versioned 写路径**——转笔记(首快照)/精修采纳/补充采纳/手动保存(update_note)/回滚全部走 versioned_save(事务:更新 notes + 插入快照 + 上限合并)
11+
- **50 版上限**:超限合并最旧两版(删最旧 + 次旧 meta 追加 merged_from 摘要——meta 不丢)
12+
- **回滚不破坏历史链**:回滚=新版本(content=目标版本,source=user-edit,parent=最新版本——线性链语义,规划"parent=目标版本"会制造分叉故取线性,见 v0.8.0.md M4 注记)
13+
- **任意两版段级 diff**:`note_versions_diff` 复用 note_diff 内核(版本对比视图高亮)
14+
- **成本落库(REQ-143 完整)**:`note_ai_usage` 表(操作类型/token 输入输出/费用/模型/切片数)——精修/补充采纳时自动落库(token 估算与预估同口径,校准单价表数据源)
15+
- **版本时间线 UI**:笔记详情页「🕘 版本时间线」面板——版本列表(时间/source 徽标/费用/合并摘要)+ 回滚到此处 + 两版 diff 对比(下拉选择)+ AI 成本记录
16+
17+
### 测试
18+
19+
- 单测 +11(全量 1264 通过):来源标记 serde/label · meta 合并摘要(含/不含 AI 元信息)· 惰性首快照(旧数据兼容)· versioned 写链(rule 基快照 + ai-refine 新版本 + parent 链)· 回滚不破坏历史链(3 条)· 50 版上限合并(merged_from 摘要)· 成本记录 roundtrip/互不干扰 · 缺失笔记拒绝
720

821
### 新增(M3 知识补充,REQ-142)
922

‎app/src-tauri/src/ai_cost.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ pub fn estimate_for_content(chars: usize) -> CostEstimate {
5757
}
5858
}
5959

60+
/// 成本记录费用(纯函数:输入+输出 token × 当前单价——与预估同口径,
61+
/// M4 落库 note_ai_usage 用)。
62+
pub fn usage_cost(tokens_in: usize, tokens_out: usize) -> f64 {
63+
estimate_cost(tokens_in.saturating_add(tokens_out), price_per_1m())
64+
}
65+
6066
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
6167
#[cfg(test)]
6268
#[path = "ai_cost_tests.rs"]

‎app/src-tauri/src/commands.rs‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,8 @@ pub async fn get_note(state: State<'_, AppState>, id: i64) -> Result<Option<Note
300300
}
301301

302302
/// 更新笔记(REQ-004;标题/正文截断防超大 payload——TD-005)。
303+
/// v0.8.0 M4(REQ-144):手动保存 = 新版本(versioned_save 统一写路径——
304+
/// 正文版本化,标题只更新不建版本;"重新生成"从覆盖变为新版本)。
303305
#[tauri::command]
304306
pub async fn update_note(
305307
state: State<'_, AppState>,
@@ -310,14 +312,22 @@ pub async fn update_note(
310312
if id <= 0 {
311313
return Err("无效的笔记 id".to_string());
312314
}
315+
let title = truncate_chars(title, TITLE_MAX_CHARS);
316+
let content = truncate_chars(content, CONTENT_MAX_CHARS);
313317
state
314318
.db
315-
.update_note(
319+
.versioned_save(
316320
id,
317-
&truncate_chars(title, TITLE_MAX_CHARS),
318-
&truncate_chars(content, CONTENT_MAX_CHARS),
321+
&content,
322+
crate::note_version::NoteVersionSource::UserEdit,
323+
&crate::note_version::VersionMeta::default(),
319324
)
320-
.map_err(|e| e.to_string())
325+
.map_err(|e| e.to_string())?;
326+
state
327+
.db
328+
.update_note(id, &title, &content)
329+
.map_err(|e| e.to_string())?;
330+
Ok(true)
321331
}
322332

323333
/// 删除笔记(REQ-004)。

‎app/src-tauri/src/commands_ai_enrich.rs‎

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ pub struct AiEnrichResult {
4343
pub blocks: usize,
4444
pub depth_blocks: usize,
4545
pub breadth_blocks: usize,
46+
/// 切片数(长笔记任务执行时的切片数——成本记录用)
47+
pub slices: usize,
4648
/// 实际返回的子项(kebab-case)
4749
pub kinds: Vec<String>,
4850
pub model: String,
@@ -110,32 +112,68 @@ pub fn ai_enrich_result(state: State<'_, AppState>, task_id: u64) -> Result<AiEn
110112
}
111113
}
112114

113-
/// 采纳落库(update_note 覆盖内容——M4 版本管理统一 versioned 写路径)。
115+
/// 采纳落库(v0.8.0 M4 版本化写路径:新版本 ai-enrich + 成本 meta +
116+
/// note_ai_usage 落库——"重新生成"从覆盖变为新版本)。
114117
#[tauri::command]
115118
pub fn ai_enrich_apply(
116119
state: State<'_, AppState>,
117120
note_id: i64,
118-
enriched_markdown: String,
121+
result: AiEnrichResult,
119122
) -> Result<Note, String> {
120-
let note = get_note(state.inner(), note_id)?;
123+
// 存在性校验(versioned_save 内部也会校验——提前失败给明确错误)
124+
get_note(state.inner(), note_id)?;
125+
let cost = crate::ai_cost::usage_cost(
126+
result.base_markdown.chars().count(),
127+
result.enriched_markdown.chars().count(),
128+
);
129+
let meta = crate::note_version::VersionMeta {
130+
cost_yuan: Some(cost),
131+
model: Some(result.model.clone()),
132+
slices: Some(result.slices),
133+
merged_from: None,
134+
};
135+
state
136+
.db
137+
.versioned_save(
138+
note_id,
139+
&result.enriched_markdown,
140+
crate::note_version::NoteVersionSource::AiEnrich,
141+
&meta,
142+
)
143+
.map_err(|e| e.to_string())?;
121144
state
122145
.db
123-
.update_note(note_id, &note.title, &enriched_markdown)
146+
.record_ai_usage(
147+
note_id,
148+
&crate::db_ai_usage::AiUsageInput {
149+
op_type: "enrich",
150+
tokens_in: result.base_markdown.chars().count(),
151+
tokens_out: result.enriched_markdown.chars().count(),
152+
cost_yuan: cost,
153+
model: result.model.clone(),
154+
slices: result.slices,
155+
},
156+
)
124157
.map_err(|e| e.to_string())?;
125158
get_note(state.inner(), note_id)
126159
}
127160

128-
/// 撤销补充(删除无残留——内容还原补充前 base)。
161+
/// 撤销补充(删除无残留——内容还原补充前 base;v0.8.0 M4:= 新版本 user_edit)。
129162
#[tauri::command]
130163
pub fn ai_enrich_revert(
131164
state: State<'_, AppState>,
132165
note_id: i64,
133166
base_markdown: String,
134167
) -> Result<Note, String> {
135-
let note = get_note(state.inner(), note_id)?;
168+
get_note(state.inner(), note_id)?;
136169
state
137170
.db
138-
.update_note(note_id, &note.title, &base_markdown)
171+
.versioned_save(
172+
note_id,
173+
&base_markdown,
174+
crate::note_version::NoteVersionSource::UserEdit,
175+
&crate::note_version::VersionMeta::default(),
176+
)
139177
.map_err(|e| e.to_string())?;
140178
get_note(state.inner(), note_id)
141179
}
@@ -204,6 +242,7 @@ fn run_enrich_task(st: AppState, task_id: u64, note_id: i64, selected: Vec<AiEnr
204242
blocks: resp.blocks.len(),
205243
depth_blocks: depth,
206244
breadth_blocks: breadth,
245+
slices: total,
207246
kinds,
208247
model: client.config.model,
209248
})

‎app/src-tauri/src/commands_ai_refine.rs‎

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ pub struct AiTaskEntry {
5252
#[serde(rename_all = "camelCase")]
5353
pub struct AiRefineResult {
5454
pub title: String,
55+
/// 规则基线(本地规则版——采纳落库时作为首快照,版本链 [rule, ai-refine])
56+
pub base_markdown: String,
5557
pub refined_markdown: String,
5658
/// 与规则版的段级 diff(本地版为基线,AI 变化点高亮)
5759
pub diff: Vec<DiffOp>,
@@ -161,14 +163,14 @@ pub fn ai_refine_result(state: State<'_, AppState>, task_id: u64) -> Result<AiRe
161163
}
162164
}
163165

164-
/// 采纳落库(REQ-141:diff 预览后用户采纳 → 新笔记,source=classroom;
165-
/// M4 版本管理上线后统一 versioned 写路径)。
166+
/// 采纳落库(REQ-141:diff 预览后用户采纳;v0.8.0 M4 版本化写路径——
167+
/// ① 以规则基线建笔记(首快照)→ ② 精修版 = 新版本(ai-refine,含成本
168+
/// meta)→ ③ 成本落库 note_ai_usage)。
166169
#[tauri::command]
167170
pub fn ai_refine_apply(
168171
state: State<'_, AppState>,
169172
session_id: i64,
170-
refined_markdown: String,
171-
title: String,
173+
result: AiRefineResult,
172174
) -> Result<Note, String> {
173175
if session_id <= 0 {
174176
return Err("无效的会话 id".to_string());
@@ -179,16 +181,57 @@ pub fn ai_refine_apply(
179181
.map_err(|e| e.to_string())?
180182
.ok_or_else(|| format!("会话不存在: {}", session_id))?;
181183
let fallback = format!("{}(AI 精修)", session.title);
182-
let title = crate::commands::normalize_title(title, &fallback);
184+
let title = crate::commands::normalize_title(result.title.clone(), &fallback);
185+
// ① 规则基线建笔记(版本链首快照原料——可回溯精修前内容)
183186
let new = NewNote {
184-
title,
185-
content: refined_markdown,
187+
title: title.clone(),
188+
content: result.base_markdown.clone(),
186189
source: "classroom".to_string(),
187190
session_id: Some(session_id),
188-
rule_version: Some("ai-refine".to_string()),
191+
rule_version: Some("rule".to_string()),
189192
purify_stats: None,
190193
};
191-
state.db.create_note(&new).map_err(|e| e.to_string())
194+
let note = state.db.create_note(&new).map_err(|e| e.to_string())?;
195+
// ② 精修版落库(新版本 ai-refine + 成本 meta)
196+
let cost = crate::ai_cost::usage_cost(
197+
result.base_markdown.chars().count(),
198+
result.refined_markdown.chars().count(),
199+
);
200+
let meta = crate::note_version::VersionMeta {
201+
cost_yuan: Some(cost),
202+
model: Some(result.model.clone()),
203+
slices: Some(result.slices),
204+
merged_from: None,
205+
};
206+
state
207+
.db
208+
.versioned_save(
209+
note.id,
210+
&result.refined_markdown,
211+
crate::note_version::NoteVersionSource::AiRefine,
212+
&meta,
213+
)
214+
.map_err(|e| e.to_string())?;
215+
// ③ 成本落库(token 估算与预估同口径——校准单价表数据源)
216+
state
217+
.db
218+
.record_ai_usage(
219+
note.id,
220+
&crate::db_ai_usage::AiUsageInput {
221+
op_type: "refine",
222+
tokens_in: result.base_markdown.chars().count(),
223+
tokens_out: result.refined_markdown.chars().count(),
224+
cost_yuan: cost,
225+
model: result.model.clone(),
226+
slices: result.slices,
227+
},
228+
)
229+
.map_err(|e| e.to_string())?;
230+
state
231+
.db
232+
.get_note(note.id)
233+
.map_err(|e| e.to_string())?
234+
.ok_or_else(|| "笔记不存在".to_string())
192235
}
193236

194237
// ────────────────────────────────────────────────────────────
@@ -275,6 +318,7 @@ fn run_refine_task(st: AppState, task_id: u64, session_id: i64, mock: bool) {
275318
let (added, removed, _) = diff_stats(&diff);
276319
Ok(AiRefineResult {
277320
title: draft.title.clone(),
321+
base_markdown: draft.markdown.clone(),
278322
refined_markdown: refined,
279323
diff,
280324
added_lines: added,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
//! 笔记版本管理 commands(REQ-144 + REQ-143 成本展示,v0.8.0 M4)。
2+
//!
3+
//! @ai-context: 版本时间线数据源(list/diff/rollback)+ 成本记录展示;
4+
//! 写路径统一走 versioned_save(转笔记首快照惰性/精修采纳/
5+
//! 补充采纳/手动保存/回滚=新版本——见 db_notes_versions.rs)。
6+
//! @ai-context: 段级 diff 复用 note_diff.rs(M2 预览内核——任意两版对比);
7+
//! 回滚不破坏历史链(新版本 parent=目标版本)。
8+
9+
use tauri::State;
10+
11+
use crate::commands::AppState;
12+
use crate::db_ai_usage::AiUsageRecord;
13+
use crate::db_notes_versions::NoteVersion;
14+
use crate::note_diff::{diff_markdown, DiffOp};
15+
use crate::types::Note;
16+
17+
/// 版本列表(旧→新;含惰性首快照——旧数据迁移兼容)。
18+
#[tauri::command]
19+
pub fn note_versions_list(state: State<'_, AppState>, note_id: i64) -> Result<Vec<NoteVersion>, String> {
20+
if note_id <= 0 {
21+
return Err("无效的笔记 id".to_string());
22+
}
23+
state.db.list_versions(note_id).map_err(|e| e.to_string())
24+
}
25+
26+
/// 任意两版段级 diff(版本对比视图数据源——note_diff 内核)。
27+
#[tauri::command]
28+
pub fn note_versions_diff(
29+
state: State<'_, AppState>,
30+
note_id: i64,
31+
v1_id: i64,
32+
v2_id: i64,
33+
) -> Result<Vec<DiffOp>, String> {
34+
let v1 = get_version_for_note(&state, note_id, v1_id)?;
35+
let v2 = get_version_for_note(&state, note_id, v2_id)?;
36+
Ok(diff_markdown(&v1.content, &v2.content))
37+
}
38+
39+
/// 回滚到目标版本(新版本 user_edit,parent=目标版本——历史链不破坏)。
40+
#[tauri::command]
41+
pub fn note_versions_rollback(
42+
state: State<'_, AppState>,
43+
note_id: i64,
44+
target_version_id: i64,
45+
) -> Result<Note, String> {
46+
if note_id <= 0 || target_version_id <= 0 {
47+
return Err("无效的参数".to_string());
48+
}
49+
state
50+
.db
51+
.rollback_to(note_id, target_version_id)
52+
.map_err(|e| e.to_string())?;
53+
state
54+
.db
55+
.get_note(note_id)
56+
.map_err(|e| e.to_string())?
57+
.ok_or_else(|| "笔记不存在".to_string())
58+
}
59+
60+
/// 笔记成本记录(版本时间线"费用"列数据源)。
61+
#[tauri::command]
62+
pub fn note_versions_usage(state: State<'_, AppState>, note_id: i64) -> Result<Vec<AiUsageRecord>, String> {
63+
if note_id <= 0 {
64+
return Err("无效的笔记 id".to_string());
65+
}
66+
state.db.list_ai_usage(note_id).map_err(|e| e.to_string())
67+
}
68+
69+
/// 读版本并校验归属(diff 输入防御)。
70+
fn get_version_for_note(state: &AppState, note_id: i64, version_id: i64) -> Result<NoteVersion, String> {
71+
let v = state
72+
.db
73+
.get_version(version_id)
74+
.map_err(|e| e.to_string())?
75+
.ok_or_else(|| format!("版本不存在: {}", version_id))?;
76+
if v.note_id != note_id {
77+
return Err("版本与笔记不匹配".to_string());
78+
}
79+
Ok(v)
80+
}

‎app/src-tauri/src/db.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ impl Db {
175175
conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_notes_session ON notes(session_id)")?;
176176
// v0.7.7(REQ-183):结构图记录表(建表幂等——新库建表/旧库补表)
177177
crate::db_structures::init(&conn)?;
178+
// v0.8.0 M4(REQ-144):笔记版本快照链 + AI 成本记录(幂等建表)
179+
crate::db_notes_versions::init(&conn)?;
180+
crate::db_ai_usage::init(&conn)?;
178181
Ok(Self { conn: Arc::new(Mutex::new(conn)) })
179182
}
180183

0 commit comments

Comments
 (0)