Skip to content

Commit c6a4cb7

Browse files
committed
feat(live): 档位通道四段落地并接线读端
- ① start_live_session 加第 5 参 tier(显式 > 记忆体 > None×既有默认档),经 profile_override 槽交 screen worker 首拍消费并 retune 采样器(seed_initial_tier, 启动失败回滚,不留残留档) - ② ProfileMemory 加 tier 独立通道(TierMemoryEntry + tier_entries,serde 缺省 ⇒ 旧 JSON 零迁移;与 form/domain 通道分离,互不染脏) - ③ 新增命令 remember_video_profile_tier(#[tauri::command] + generate_handler! 成对落地)⇒ registry 311/311/0 - ④ 读端接线:「形态 × 档位」以后端 video_profile_for_spec 为真源(前端 ProfileDetector 的模板/采样行消费其返回值),本地 KIND_TO_FORM / KIND_TO_TIER 映射降级为**离线兜底** —— §C11.4 裁决「并行真源 → 离线降级路径」,兜底不得删除(AGENTS.md §3.4) - 前端:ProfileDetector 改档真写后端记忆(失败不吞)+ 提示文案更正;tier 经 ClassroomRightPane / ClassroomPage / CaptureStartArgs 透传给 start_live_session - 同步豁免表行数列(app_commands.rs 478 · commands_live.rs 471 · commands_video.rs 356 · ProfileDetector.tsx 446)与同行的散文数字 310 → 311 🔴 AGENTS.md §10 点名:本提交改动 app/src-tauri/src/app_commands.rs(Tauri command 注册 / IPC 安全边界)。 测试:Rust +11 用例(tier 跨会话 save/load、通道隔离、旧 JSON 零迁移、启动解析与 覆写槽预置);前端 +4 用例(改档载荷逐字、读端真源、离线兜底、映射留存 + 文案探针)。
1 parent 78bf8ae commit c6a4cb7

12 files changed

Lines changed: 510 additions & 27 deletions

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,8 @@ pub fn handle() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send + Sync
283283
// v0.13.6(REQ-220/222):细目选项表 + 领域记忆(coarse+细目多选)
284284
crate::commands_video::list_domain_fine,
285285
crate::commands_video::remember_video_profile_domain,
286+
// 批 7 T19(规格 §1 L5 行 34):画面档跨会话记忆(与 start_live_session 的 tier 成对)
287+
crate::commands_video::remember_video_profile_tier,
286288
// v0.12.0 M6(采集体验债):采集浮窗关闭
287289
crate::commands_window::close_capture_float,
288290
// v0.12.3(交互/架构升级):浮窗点击穿透/置顶/状态查询 + 回主窗

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

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,20 @@ pub struct LiveSessionStatus {
4242
/// @param sourceWindow - 目标窗口标题(笔记命名与检索)
4343
/// @param windowId - 目标窗口句柄(i64,None=全屏)
4444
/// @param profile - 视频类型档案标识(REQ-043,kebab-case;None=自动检测结果)
45+
/// @param tier - 批 7 T19(规格 §1 L5 行 34):画面档位(kebab-case;**仅在用户显式改档
46+
/// 时传入**——None 时按记忆体(同标题/同系列)兜底,仍无则不覆写 ⇒ 与
47+
/// 今天逐字相同。非法值不阻断开始,按缺失处理,与 `profile` 同口径)
48+
/// @ai-context: 与 `update_live_profile`(**采集态热切换**:改正在跑的会话)严格区分——
49+
/// 本参数只决定**新会话的起点档**(走 profile_override 共享槽,worker
50+
/// 首拍消费并 retune 采样器),不改变任何在跑会话的语义。
4551
#[tauri::command]
4652
pub async fn start_live_session(
4753
state: State<'_, AppState>,
4854
title: String,
4955
source_window: Option<String>,
5056
window_id: Option<i64>,
5157
profile: Option<String>,
58+
tier: Option<String>,
5259
) -> Result<i64, String> {
5360
// v0.11.7(图文会话,ADR-020):互斥——图文采集中不得开始实时捕获
5461
if state.photo_session.lock().map(|g| g.is_some()).unwrap_or(false) {
@@ -79,6 +86,22 @@ pub async fn start_live_session(
7986
}
8087
};
8188

89+
// 批 7 T19(§11-8「档位选完真生效 / 跨会话记住」):档位解析 = 显式入参 > 记忆体
90+
// (键用**窗口标题**——与检测卡写记忆时同一个键;会话标题已过同源去重/平台尾缀
91+
// 净化,不能当记忆键)> None(不覆写 ⇒ 既有默认档行为逐字不变)。
92+
let memory_title = source_window.clone().unwrap_or_else(|| title.clone());
93+
let resolved_tier = {
94+
let memory = state
95+
.profile_memory
96+
.lock()
97+
.map(|m| m.clone())
98+
.unwrap_or_default();
99+
resolve_start_tier(tier.as_deref(), &memory, &memory_title)
100+
};
101+
// 预置覆写槽(与 update_live_profile 同一条 command→worker 通道):start 内部只复位
102+
// tier_override/applied_tier,**不**复位本槽 ⇒ 先写后 start 无竞态(首拍即消费)。
103+
let override_slot = state.live_session.profile_override_slot();
104+
let seeded = seed_initial_tier(&override_slot, resolved_tier);
82105
let params = LiveSessionParams {
83106
title: title.clone(),
84107
source_window: source_window.map(|s| s.chars().take(100).collect()),
@@ -113,11 +136,56 @@ pub async fn start_live_session(
113136
};
114137
// REQ-104/132:剪贴板监听时间戳基准——与实时会话纪元同域(图片文件名不冲突)
115138
let epoch = Instant::now();
116-
let session_id = state.live_session.start(params).map_err(|e| e.to_string())?;
139+
let session_id = match state.live_session.start(params) {
140+
Ok(id) => id,
141+
Err(e) => {
142+
// 启动失败 ⇒ 清掉预置覆写(防残留到下个会话——同 tier_override 按会话复位之因)
143+
if seeded {
144+
if let Ok(mut guard) = override_slot.lock() {
145+
*guard = None;
146+
}
147+
}
148+
return Err(e.to_string());
149+
}
150+
};
117151
start_clipboard_monitor(&state, session_id, epoch);
118152
Ok(session_id)
119153
}
120154

155+
/// 把启动解析出的档位预置进档案覆写槽(批 7 T19 · 落地「档位随 start 生效」)。
156+
///
157+
/// @ai-context: screen worker 首拍 `take()` 消费 ⇒ 新会话从**用户选的档**起(而非默认
158+
/// 档),并经 `applied_tier` 槽回读(live_session_status.tier)。
159+
/// @ai-context: `None` = **不动槽**(既有行为零改动;也避免清掉并发写入的覆写)。
160+
/// @return 是否写入——调用方在 start 失败时据此回滚(防档位残留到下一个会话)。
161+
fn seed_initial_tier(
162+
slot: &std::sync::Arc<std::sync::Mutex<Option<crate::live_session::ProfileOverride>>>,
163+
tier: Option<crate::video_profile_spec::VisualTier>,
164+
) -> bool {
165+
let Some(t) = tier else { return false };
166+
let Ok(mut guard) = slot.lock() else { return false };
167+
*guard = Some(crate::live_session::ProfileOverride {
168+
tier: Some(t),
169+
..Default::default()
170+
});
171+
true
172+
}
173+
174+
/// 会话启动档位解析(批 7 T19 · 规格 §11-8 的单一裁决点;纯函数,可离线单测)。
175+
///
176+
/// @ai-context: 优先级 = 显式入参(前端仅在**用户显式改档**时传)> 记忆体(同标题/
177+
/// 同系列 ⇒ 跨会话记住上次选择)> None(不覆写 ⇒ 消费端按既有默认档起)。
178+
/// @ai-context: 非法显式值**不阻断**会话开始,按缺失处理(与 `profile` 参数同口径)。
179+
fn resolve_start_tier(
180+
explicit: Option<&str>,
181+
memory: &crate::video_profile::ProfileMemory,
182+
title: &str,
183+
) -> Option<crate::video_profile_spec::VisualTier> {
184+
explicit
185+
.and_then(crate::video_profile_spec::VisualTier::parse)
186+
.or_else(|| memory.lookup_tier(title))
187+
}
188+
121189
/// 确认画面档降档(v0.9.0 M2,REQ-189:降档需用户确认——降采样可能丢信息)。
122190
///
123191
/// @param tier - 确认后的档位标识(kebab-case;非法值明确报错)
@@ -397,3 +465,7 @@ pub fn update_live_profile(
397465
.map_err(|e| e.to_string())?;
398466
Ok(true)
399467
}
468+
469+
#[cfg(test)]
470+
#[path = "commands_live_tests.rs"]
471+
mod tests;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
//! 会话启动档位单测(批 7 T19 · 规格 §1 L5 行 34「start_live_session 加 tier」)。
2+
//!
3+
//! @ai-context: 由 commands_live.rs 以 `#[cfg(test)] #[path]` 引入,保持实现文件 ≤300 行
4+
//! 的拆分纪律(AGENTS.md §3;命令层无需 tauri State 即可测纯函数与共享槽)。
5+
//! @ai-context: 主判据 = **档位随 start 生效**(①):解析出的档位被预置进
6+
//! `profile_override` 槽——那是 screen worker 首拍消费并 retune 采样器的
7+
//! 唯一通道(与 update_live_profile 同通道,后者是采集态热切换)。
8+
//! @ai-context: 「跨会话记住」的另一半(③)在 video_profile_memory_tests.rs 的
9+
//! save/load 判据上(新进程 = 重新 load 同一文件);本文件只测解析优先级。
10+
11+
use super::*;
12+
use crate::video_profile::ProfileMemory;
13+
use crate::video_profile_spec::VisualTier;
14+
15+
/// ① 显式入参优先(用户本次改档 ⇒ 覆盖记忆体)。
16+
#[test]
17+
fn start_tier_explicit_wins_over_memory() {
18+
let mut memory = ProfileMemory::default();
19+
memory.remember_tier("零基础化妆教程", VisualTier::Low);
20+
// Act:显式 rich + 记忆 low
21+
let resolved = resolve_start_tier(Some("rich"), &memory, "零基础化妆教程");
22+
// Assert
23+
assert_eq!(resolved, Some(VisualTier::Rich));
24+
}
25+
26+
/// ①③ 无显式入参 ⇒ 记忆体兜底(新会话读到的档 == 上次选的档)。
27+
#[test]
28+
fn start_tier_falls_back_to_memory() {
29+
let mut memory = ProfileMemory::default();
30+
memory.remember_tier("零基础化妆教程 P1", VisualTier::Rich);
31+
// Act:前端未传(= 用户没改档)→ 走记忆(series 键,P5 同系列)
32+
let resolved = resolve_start_tier(None, &memory, "零基础化妆教程 P5");
33+
assert_eq!(resolved, Some(VisualTier::Rich));
34+
// 负控:无记忆 + 无显式 ⇒ None(**不覆写** ⇒ 既有默认档行为逐字不变)
35+
assert_eq!(resolve_start_tier(None, &ProfileMemory::default(), "零基础化妆教程"), None);
36+
}
37+
38+
/// 非法显式值不阻断开始(按缺失处理,回落记忆——与 profile 参数同口径)。
39+
#[test]
40+
fn start_tier_invalid_explicit_falls_back() {
41+
let mut memory = ProfileMemory::default();
42+
memory.remember_tier("高等数学", VisualTier::Medium);
43+
assert_eq!(resolve_start_tier(Some("turbo"), &memory, "高等数学"), Some(VisualTier::Medium));
44+
assert_eq!(resolve_start_tier(Some(""), &ProfileMemory::default(), "高等数学"), None);
45+
}
46+
47+
/// ① 档位随 start 生效:预置覆写槽(worker 首拍消费 ⇒ applied_tier 回读)。
48+
#[test]
49+
fn start_tier_seeds_override_slot() {
50+
// Arrange:与产物同一构造(LiveSessionManager::new 的槽)
51+
let manager = crate::live_session::LiveSessionManager::new();
52+
let slot = manager.profile_override_slot();
53+
// Act
54+
let seeded = seed_initial_tier(&slot, Some(VisualTier::Rich));
55+
// Assert:写入成功 + 槽内恰是「只带 tier」的覆写(form/domain 不受影响)
56+
assert!(seeded, "解析出档位必须写入槽");
57+
let taken = slot.lock().unwrap().take().expect("槽内应有覆写");
58+
assert_eq!(taken.tier, Some(VisualTier::Rich));
59+
assert_eq!(taken.form, None, "只写档位维——不覆写形态");
60+
assert_eq!(taken.domain, None, "只写档位维——不覆写领域");
61+
// None ⇒ 不动槽(既有行为零改动)
62+
assert!(!seed_initial_tier(&slot, None));
63+
assert!(slot.lock().unwrap().is_none(), "None 不得写槽");
64+
}
65+
66+
/// 负控:预置的是**未确认的起点档**,不写 tier_override(降档确认槽——
67+
/// 写它会绕过"降档需用户确认",正是 start 内部按会话复位该槽的同一理由)。
68+
#[test]
69+
fn start_tier_does_not_touch_downgrade_confirm_slot() {
70+
let manager = crate::live_session::LiveSessionManager::new();
71+
let _ = seed_initial_tier(&manager.profile_override_slot(), Some(VisualTier::Low));
72+
assert!(manager.tier_override().lock().unwrap().is_none());
73+
}

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

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ use crate::video_profile_spec::{ContentForm, ProfileSpec};
1818
/// 档案标识最大长度(防御超长输入污染记忆库)。
1919
const KIND_MAX_CHARS: usize = 30;
2020

21+
/// 画面档标识最大长度(批 7 T19;kebab-case 最长 "medium"——防超长输入污染记忆库)。
22+
const TIER_MAX_CHARS: usize = 10;
23+
2124
/// 导出五档案(前端展示/校准;JSON 序列化即档案可校准接口)。
2225
#[tauri::command]
2326
pub fn video_profiles() -> Vec<VideoProfile> {
@@ -255,7 +258,11 @@ pub fn video_profile_memory(state: State<'_, AppState>) -> ProfileMemory {
255258
/// 按四维规格查参数矩阵(形态 × 画面档 → 采样/OCR/存储/模板)。
256259
///
257260
/// @param form - 形态标识(kebab-case;None=识别中——模板走默认讲义式)
258-
/// @param tier - 画面档标识(kebab-case;缺省=中档——开始前默认+诚实声明)
261+
/// @param tier - 画面档标识(kebab-case;缺省=按形态默认档——开始前默认+诚实声明)
262+
/// @ai-context: 读端**真源**(批 7 T19 · `rulings.md` §C11.4 裁决):前端检测卡以本命令
263+
/// 返回值作为「形态 × 档位 → 模板/采样」的真源;本地
264+
/// `KIND_TO_FORM` / `KIND_TO_TIER` 映射**降级为离线兜底**(该调用失败/
265+
/// 纯 web dev 时才用)——兜底必须留(AGENTS.md §3.4 硬性要求)。
259266
#[tauri::command]
260267
pub fn video_profile_for_spec(form: Option<String>, tier: Option<String>) -> VideoProfile {
261268
let form = form.and_then(|f| ContentForm::parse(&f));
@@ -305,7 +312,45 @@ pub fn remember_video_profile_form(
305312
Ok(())
306313
}
307314

315+
/// 批 7 T19(规格 §1 L5 行 34):记录用户确认的**画面档**(跨会话记忆)——
316+
/// 同标题/同系列下次直接生效(由 start_live_session 的 tier 解析读取)。
317+
///
318+
/// @param title - 窗口标题(记忆匹配键;series 键剥离与形态/领域记忆同口径)
319+
/// @param tier - 画面档标识(kebab-case:rich/medium/low/none;非法值明确报错)
320+
/// @ai-context: 与 `update_live_profile`(**采集态热切换**:改正在跑的会话)严格区分——
321+
/// 本条只写记忆偏好(下次会话的起点档),**不触碰任何运行中会话**;
322+
/// 两条命令不可互相替代(C3.1 逐字)。
323+
#[tauri::command]
324+
pub fn remember_video_profile_tier(
325+
state: State<'_, AppState>,
326+
title: String,
327+
tier: String,
328+
) -> Result<(), String> {
329+
let title = title.trim().to_string();
330+
if title.is_empty() {
331+
return Err("窗口标题为空,无法记忆画面档".to_string());
332+
}
333+
let title = title.chars().take(200).collect::<String>();
334+
let tier = crate::video_profile_spec::VisualTier::parse(
335+
&tier.chars().take(TIER_MAX_CHARS).collect::<String>(),
336+
)
337+
.ok_or_else(|| format!("非法画面档标识: {}", tier.chars().take(TIER_MAX_CHARS).collect::<String>()))?;
338+
let path = state.profile_memory_path.clone();
339+
let memory = state.profile_memory.clone();
340+
// 锁内 read-modify-write(与形态/领域记忆同模式,防 TOCTOU 文件竞争)
341+
{
342+
let mut guard = memory
343+
.lock()
344+
.map_err(|e| format!("档案记忆锁中毒: {}", e))?;
345+
guard.remember_tier(&title, tier);
346+
guard
347+
.save(&path)
348+
.map_err(|e| format!("保存档案记忆失败: {}", e))?;
349+
}
350+
Ok(())
351+
}
352+
308353
// ── 单测独立文件(四象限矩阵 + 记忆/检测回归)──
309354
#[cfg(test)]
310355
#[path = "commands_video_tests.rs"]
311-
mod tests;
356+
mod tests;

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

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
//! 档案记忆偏好域(REQ-043 / v0.7.2 REQ-152 系列键 / v0.9.0 REQ-188 四维形态 / v0.13.6 REQ-222 领域记忆)。
22
//!
33
//! @ai-context: 从 video_profile.rs 拆出(AGENTS.md §3 单文件 ≤300 行):记忆偏好 =
4-
//! 「用户确认过一次 → 同窗口标题/同系列下次直接生效」。三条通道互相独立:
5-
//! kind/form 走 entries,domain 走 domain_entries(防 domain-only 条目把
6-
//! legacy kind 结果染成 Unknown);三者的 tie-break 一律「最长关键词优先」
7-
//! (len > best 严格大于 ⇒ 等长时先入条目胜出)。
4+
//! 「用户确认过一次 → 同窗口标题/同系列下次直接生效」。四条通道互相独立:
5+
//! kind/form 走 entries,domain 走 domain_entries,tier 走 tier_entries
6+
//! (批 7 T19)——防某通道的条目把别的通道结果染脏;四者的 tie-break 一律
7+
//! 「最长关键词优先」(len > best 严格大于 ⇒ 等长时先入条目胜出)。
88
//! @ai-context: 副作用 = 文件 IO:load 读 JSON(缺失/损坏回落空库,不阻断启动);
99
//! save 先写 .tmp 再 rename(原子写,防写一半损坏记忆库)。调用方保证
1010
//! 路径与并发(本域无锁、无 tauri state,可 tempfile 单测)。
@@ -46,13 +46,32 @@ pub struct DomainMemoryEntry {
4646
pub domain: crate::video_profile_spec::DomainTag,
4747
}
4848

49+
/// 批 7 T19(规格 §1 L5 行 34「记忆加 tier 字段」):画面档记忆条目。
50+
///
51+
/// @ai-context: 与 MemoryEntry(kind/form 通道)**分离**——防 tier-only 条目把
52+
/// legacy kind/form 结果染脏(与 DomainMemoryEntry 的分离理由同族);
53+
/// 与 `update_live_profile`(**采集态热切换**,改正在跑的会话)严格区分:
54+
/// 本条只写记忆偏好(下次同标题/同系列会话的起点)。
55+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56+
pub struct TierMemoryEntry {
57+
pub keyword: String,
58+
/// series 键标记(与 MemoryEntry.is_series 同口径)
59+
#[serde(default)]
60+
pub is_series: bool,
61+
/// 用户确认的画面档(rich/medium/low/none)
62+
pub tier: crate::video_profile_spec::VisualTier,
63+
}
64+
4965
/// 记忆偏好库(JSON 持久化;同 vocab 模式:路径可注入,测试用 tempfile)。
5066
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
5167
pub struct ProfileMemory {
5268
pub entries: Vec<MemoryEntry>,
5369
/// v0.13.6(REQ-222):领域记忆独立通道(旧 JSON 缺省空——零迁移)
5470
#[serde(default)]
5571
pub domain_entries: Vec<DomainMemoryEntry>,
72+
/// 批 7 T19:画面档记忆独立通道(旧 JSON 缺省空——零迁移,同 domain_entries 先例)
73+
#[serde(default)]
74+
pub tier_entries: Vec<TierMemoryEntry>,
5675
}
5776

5877
impl ProfileMemory {
@@ -191,6 +210,46 @@ impl ProfileMemory {
191210
best.map(|(_, tag)| tag)
192211
}
193212

213+
/// 批 7 T19(规格 §1 L5 行 34):记录用户确认的画面档——同标题/系列下次直接生效。
214+
///
215+
/// @ai-context: 独立通道不污染 kind/form 记忆;空键拒绝(不记悬挂条目)。
216+
pub fn remember_tier(&mut self, keyword: &str, tier: crate::video_profile_spec::VisualTier) {
217+
let (key, is_series) = Self::memory_key(keyword);
218+
if key.is_empty() {
219+
return;
220+
}
221+
if let Some(e) = self.tier_entries.iter_mut().find(|e| e.keyword == key) {
222+
e.is_series = is_series;
223+
e.tier = tier;
224+
} else {
225+
self.tier_entries.push(TierMemoryEntry { keyword: key, is_series, tier });
226+
}
227+
}
228+
229+
/// 按标题查询画面档记忆(批 7 T19):series 键优先,完整标题兜底;最长关键词优先。
230+
pub fn lookup_tier(&self, title: &str) -> Option<crate::video_profile_spec::VisualTier> {
231+
if let Some(info) = crate::series_detect::extract_series(title) {
232+
if let Some(t) = self.lookup_tier_best(&info.series) {
233+
return Some(t);
234+
}
235+
}
236+
self.lookup_tier_best(title)
237+
}
238+
239+
/// 最长关键词优先匹配(纯函数;档位记忆专用通道)。
240+
fn lookup_tier_best(&self, key: &str) -> Option<crate::video_profile_spec::VisualTier> {
241+
let mut best: Option<(usize, crate::video_profile_spec::VisualTier)> = None;
242+
for e in &self.tier_entries {
243+
if key.contains(&e.keyword) && !e.keyword.is_empty() {
244+
let len = e.keyword.chars().count();
245+
if best.as_ref().is_none_or(|(bl, _)| len > *bl) {
246+
best = Some((len, e.tier));
247+
}
248+
}
249+
}
250+
best.map(|(_, tier)| tier)
251+
}
252+
194253
/// 按标题查询四维形态记忆(REQ-188):form 优先,旧条目经 kind 映射兜底。
195254
///
196255
/// @ai-context: 消费端 v2(检测卡/会话落库)用本方法;旧 lookup 保留为
@@ -226,3 +285,7 @@ impl ProfileMemory {
226285
best.map(|(_, form)| form)
227286
}
228287
}
288+
289+
#[cfg(test)]
290+
#[path = "video_profile_memory_tests.rs"]
291+
mod tests;

0 commit comments

Comments
 (0)