@@ -13,6 +13,8 @@ use std::collections::VecDeque;
1313use std:: sync:: mpsc;
1414use std:: sync:: { Arc , Mutex } ;
1515
16+ use base64:: Engine ;
17+
1618use crate :: ai_client:: AiClient ;
1719use crate :: ai_mock:: AiMockAdapter ;
1820use crate :: ai_note_refine:: AiNoteRefineAdapter ;
@@ -211,6 +213,13 @@ fn run_refine_task_inner(
211213 let client = AiClient :: from_settings_with_store ( & settings, stored_key, & store) ;
212214 let adapter = AiNoteRefineAdapter :: new ( client. clone ( ) ) ;
213215 let mock_adapter = AiMockAdapter ;
216+ // v0.12.0 M5:画面理解——仅精修设置开启时装载会话屏卡图(≤1280px 控 token;
217+ // 空 → 精修纯文本,现有行为零变化;图文会话不触发——调用方只对视频会话接线)
218+ let vision_images = if settings. vision_refine_enabled {
219+ load_session_vision_images ( & st. data_dir , session_id)
220+ } else {
221+ Vec :: new ( )
222+ } ;
214223 // F2-B4:并发精修(worker 池消费切片队列;按片上报进度;失败片重试后
215224 // 仍失败 → 记 failed 下标,不中断其他片——部分成功语义)
216225 let ( markdowns, failed) = refine_slices_concurrent ( RefineCtx {
@@ -224,6 +233,7 @@ fn run_refine_task_inner(
224233 workers : total. min ( CONCURRENCY ) ,
225234 st,
226235 task_id,
236+ vision_images : & vision_images,
227237 } ) ;
228238 // ④ 合并(协议层 merge_refine_slices:各片 join + 章节锚点回挂——7️⃣
229239 // 剥离的章节锚点按标题精确匹配还原,未匹配不挂)
@@ -260,6 +270,9 @@ struct RefineCtx<'a> {
260270 workers : usize ,
261271 st : & ' a AppState ,
262272 task_id : u64 ,
273+ /// v0.12.0 M5:屏卡图 base64 data URI 列表(vision_refine_enabled 开启且
274+ /// 会话有归档图时非空;空 → 精修纯文本,现有行为零变化)
275+ vision_images : & ' a [ String ] ,
263276}
264277
265278/// 片间摘要(F3 v2:前/后片首尾 N 字——提示词衔接上下文,防片间断裂)。
@@ -282,6 +295,52 @@ fn slice_summary(text: &str, head: bool) -> Option<String> {
282295 }
283296}
284297
298+ /// v0.12.0 M5:装载会话屏卡图(≤1280px 控 token → PNG base64 data URI)。
299+ ///
300+ /// @ai-context: 仅 vision_refine_enabled 开启时调用;目录缺失/解码失败 → 跳过
301+ /// (空列表 → 精修纯文本,现有行为零变化——画面理解是可选增强,
302+ /// 不是硬依赖,绝不阻塞精修主链路)。图片库为
303+ /// data_dir/session-images/<id> 下平铺 PNG/JPEG 屏卡。
304+ /// @ai-context: 隐私红线(ADR-010 扩展):图片仅随精修切片请求上云,不作独立
305+ /// 提取命令;缩放控 token(长图缩放不裁剪,保画面完整)。
306+ fn load_session_vision_images ( data_dir : & std:: path:: Path , session_id : i64 ) -> Vec < String > {
307+ const MAX_EDGE : u32 = 1280 ;
308+ let dir = data_dir. join ( "session-images" ) . join ( session_id. to_string ( ) ) ;
309+ let Ok ( entries) = std:: fs:: read_dir ( & dir) else { return Vec :: new ( ) } ;
310+ let mut out = Vec :: new ( ) ;
311+ for entry in entries. flatten ( ) {
312+ let path = entry. path ( ) ;
313+ if !path. is_file ( ) {
314+ continue ;
315+ }
316+ let ext = path. extension ( ) . and_then ( |e| e. to_str ( ) ) . unwrap_or ( "" ) . to_lowercase ( ) ;
317+ if ext != "png" && ext != "jpg" && ext != "jpeg" {
318+ continue ;
319+ }
320+ let Ok ( frame) = image:: open ( & path) else { continue } ;
321+ // 长边 >1280 按比例缩放(保比例;小图原样——控 token 同时不糊画面)。
322+ // resize 返回 RgbaImage,与 to_rgba8() 同型。
323+ let ( w, h) = ( frame. width ( ) , frame. height ( ) ) ;
324+ let max_edge = w. max ( h) ;
325+ let rgb = if max_edge > MAX_EDGE {
326+ let scale = MAX_EDGE as f32 / max_edge as f32 ;
327+ let nw = ( w as f32 * scale) . round ( ) . max ( 1.0 ) as u32 ;
328+ let nh = ( h as f32 * scale) . round ( ) . max ( 1.0 ) as u32 ;
329+ image:: imageops:: resize ( & frame, nw, nh, image:: imageops:: FilterType :: Triangle )
330+ } else {
331+ frame. to_rgba8 ( )
332+ } ;
333+ let mut buf = std:: io:: Cursor :: new ( Vec :: new ( ) ) ;
334+ let encoder = image:: codecs:: png:: PngEncoder :: new ( & mut buf) ;
335+ if rgb. write_with_encoder ( encoder) . is_err ( ) {
336+ continue ;
337+ }
338+ let b64 = base64:: engine:: general_purpose:: STANDARD . encode ( buf. into_inner ( ) ) ;
339+ out. push ( format ! ( "data:image/png;base64,{}" , b64) ) ;
340+ }
341+ out
342+ }
343+
285344/// 并发切片精修(纯编排):worker 池从共享队列取片 → 单片重试 → 收集。
286345///
287346/// @ai-context: 返回 (各片 markdown(保序,失败片跳过), 失败片数)。
@@ -338,8 +397,13 @@ fn refine_slices_concurrent(ctx: RefineCtx<'_>) -> (Vec<String>, usize) {
338397 for attempt in 0 ..=SLICE_RETRY {
339398 let resp = if mock {
340399 Ok ( mock_adapter. refine ( req) )
341- } else {
400+ } else if ctx . vision_images . is_empty ( ) {
342401 adapter. refine ( req) . map_err ( AiTaskFailure :: from)
402+ } else {
403+ // v0.12.0 M5:开启画面理解 → 屏卡图随切片请求送 vision-exp
404+ adapter
405+ . refine_vision ( req, ctx. vision_images )
406+ . map_err ( AiTaskFailure :: from)
343407 } ;
344408 match resp {
345409 Ok ( r) => {
0 commit comments