|
9 | 9 | //! 线程内再开 worker——网络调用阻塞 worker 线程,互不干扰; |
10 | 10 | //! AiClient 为 Clone+Send,跨线程共享安全)。 |
11 | 11 |
|
12 | | -use base64::Engine; |
13 | 12 | use tauri::Emitter; |
14 | 13 |
|
15 | 14 | use crate::ai_chat::AiTurn; |
@@ -38,6 +37,11 @@ mod skeleton; |
38 | 37 | /// 子模块再导出:既有 `crate::ai_refine_task::{…}` 导入路径保持不变。 |
39 | 38 | pub(crate) use self::skeleton::run_refine_task_skeleton; |
40 | 39 |
|
| 40 | +/// 屏卡图装载子模块(视觉增强的目录时序/缩放口径见 vision.rs 模块头) |
| 41 | +#[path = "ai_refine_task/vision.rs"] |
| 42 | +mod vision; |
| 43 | +use self::vision::load_session_vision_images; |
| 44 | + |
41 | 45 | /// 切片并发上限(REQ-145:并发 2-3——配额并发安全由 command 层启动前按 |
42 | 46 | /// 预估片数一次性消耗保证,此处 worker 数不超切片数)。v0.17.0:pub(crate) |
43 | 47 | /// ——笔记级精修任务共用同一上限。 |
@@ -265,68 +269,3 @@ fn run_refine_task_inner( |
265 | 269 | turns, |
266 | 270 | )) |
267 | 271 | } |
268 | | - |
269 | | -/// v0.12.0 M5:装载会话屏卡图(≤1280px 控 token → WebP/PNG base64 data URI)。 |
270 | | -/// |
271 | | -/// @ai-context: 仅 vision_refine_enabled 开启时调用;目录缺失/解码失败 → 跳过 |
272 | | -/// (空列表 → 精修纯文本,现有行为零变化——画面理解是可选增强, |
273 | | -/// 不是硬依赖,绝不阻塞精修主链路)。图片库为 |
274 | | -/// data_dir/session-images/<id> 下平铺 `{timestamp_ms}.webp`(存图 |
275 | | -/// 口径——审查修复:旧过滤器漏 webp 导致取不到图)。 |
276 | | -/// @ai-context: 数量上限 MAX_IMAGES=3(审查修复):会话屏卡可数百张,全量装载 → |
277 | | -/// 每切片请求携带全部图 → 内存/带宽/token 失控;按时间戳取最近 3 张 |
278 | | -/// (画面要点随精修的最新上下文,时序语义正确)。 |
279 | | -/// @ai-context: 隐私红线(ADR-010 扩展):图片仅随精修切片请求上云,不作独立 |
280 | | -/// 提取命令;缩放控 token(长图缩放不裁剪,保画面完整)。 |
281 | | -fn load_session_vision_images(data_dir: &std::path::Path, session_id: i64) -> Vec<String> { |
282 | | - const MAX_EDGE: u32 = 1280; |
283 | | - const MAX_IMAGES: usize = 3; |
284 | | - let dir = data_dir.join("session-images").join(session_id.to_string()); |
285 | | - let Ok(entries) = std::fs::read_dir(&dir) else { return Vec::new() }; |
286 | | - // 文件名 = 时间戳({ts}.webp)→ 按数值排序取最近 N 张(字典序对变长数字不成立, |
287 | | - // 须解析数值——"1000" 字典序 < "999") |
288 | | - let mut names: Vec<String> = entries |
289 | | - .flatten() |
290 | | - .filter_map(|e| { |
291 | | - let p = e.path(); |
292 | | - if !p.is_file() { |
293 | | - return None; |
294 | | - } |
295 | | - let ext = p.extension().and_then(|x| x.to_str()).unwrap_or("").to_lowercase(); |
296 | | - if ext != "webp" && ext != "png" && ext != "jpg" && ext != "jpeg" { |
297 | | - return None; |
298 | | - } |
299 | | - Some(p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()) |
300 | | - }) |
301 | | - .collect(); |
302 | | - names.sort_by_key(|n| { |
303 | | - n.split('.').next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0) |
304 | | - }); |
305 | | - names.reverse(); |
306 | | - names.truncate(MAX_IMAGES); |
307 | | - let mut out = Vec::new(); |
308 | | - for name in names { |
309 | | - let path = dir.join(&name); |
310 | | - let Ok(frame) = image::open(&path) else { continue }; |
311 | | - // 长边 >1280 按比例缩放(保比例;小图原样——控 token 同时不糊画面)。 |
312 | | - // resize 返回 RgbaImage,与 to_rgba8() 同型。 |
313 | | - let (w, h) = (frame.width(), frame.height()); |
314 | | - let max_edge = w.max(h); |
315 | | - let rgb = if max_edge > MAX_EDGE { |
316 | | - let scale = MAX_EDGE as f32 / max_edge as f32; |
317 | | - let nw = (w as f32 * scale).round().max(1.0) as u32; |
318 | | - let nh = (h as f32 * scale).round().max(1.0) as u32; |
319 | | - image::imageops::resize(&frame, nw, nh, image::imageops::FilterType::Triangle) |
320 | | - } else { |
321 | | - frame.to_rgba8() |
322 | | - }; |
323 | | - let mut buf = std::io::Cursor::new(Vec::new()); |
324 | | - let encoder = image::codecs::png::PngEncoder::new(&mut buf); |
325 | | - if rgb.write_with_encoder(encoder).is_err() { |
326 | | - continue; |
327 | | - } |
328 | | - let b64 = base64::engine::general_purpose::STANDARD.encode(buf.into_inner()); |
329 | | - out.push(format!("data:image/png;base64,{}", b64)); |
330 | | - } |
331 | | - out |
332 | | -} |
0 commit comments