Skip to content

Commit a44bf00

Browse files
committed
feat(ai): 精修画面理解——vision-exp 图片随精修上云(v0.12.0 M5·精修侧)
M5 质量债·AI 精修图片理解(可选增强,默认关): - ai_settings 加 vision_refine_enabled(默认关——图片上传最敏感独立闸门)+ 持久化/测试 - ai_client 加 chat_vision + build_vision_payload(content 数组 text+image_url data URI); chat_text 重试/错误归一提取 post_completions 复用——9 处既有调用零改动 - ai_note_refine 加 refine_vision(多模态分支,images 空回落 chat_json,现有行为零变化) - ai_refine_task 装载会话屏卡图(≤1280px 控 token → PNG base64),开启时切片请求 附屏卡图送 vision-exp;仅精修触发,不新增独立命令 - commands_ai_settings 加 ai_set_vision_refine 命令 + AiSettingsView 透出字段 - 前端 AiServicePanel 加「精修时启用画面理解」复选框(默认关) - 隐私红线(ADR-010 扩展):图片仅随精修切片请求上云,关闭走纯文本零变化 注:M5 剩余「视频会话全帧 OCR 下线 + 关键帧存图触发解耦」(live_keyframes/ live_frame_process)涉及实时采集管线,需真机验证,留待后续提交。
1 parent be869ef commit a44bf00

9 files changed

Lines changed: 209 additions & 8 deletions

File tree

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

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,35 @@ impl AiClient {
169169
/// parse_response 做 TextFilterResponse 反序列化);
170170
/// chat_json = chat_text + parse_json_object 的组合。
171171
pub fn chat_text(&self, system: &str, user: &str) -> Result<String, AiClientError> {
172+
let payload = build_chat_payload(&self.config.model, system, user, self.config.max_tokens);
173+
let body = self.post_completions(payload)?;
174+
extract_content(&body)
175+
}
176+
177+
/// chat/completions 多模态请求(v0.12.0 M5:vision-exp 视觉提取)→ 原始文本。
178+
///
179+
/// @ai-context: content 数组(text + image_url data URI,OpenAI 兼容)。
180+
/// images 为 base64 data URI 列表("data:image/png;base64,.."),
181+
/// 调用方负责缩放 ≤1280px 控 token;空 images 回落纯文本
182+
/// content(与 chat_text 行为一致)。与 chat_text/chat_json
183+
/// 共用 post_completions 的降级链/重试/错误归一——9 处既有调用零改动。
184+
pub fn chat_vision(
185+
&self,
186+
system: &str,
187+
user: &str,
188+
images: &[String],
189+
) -> Result<String, AiClientError> {
190+
let payload = build_vision_payload(&self.config.model, system, user, images, self.config.max_tokens);
191+
let body = self.post_completions(payload)?;
192+
extract_content(&body)
193+
}
194+
195+
/// 发送 chat/completions payload(HTTP + 指数退避重试 + 错误归一;纯网络
196+
/// 路径不单测)。chat_text/chat_vision 共用——降级链/重试单一实现。
197+
fn post_completions(&self, payload: serde_json::Value) -> Result<String, AiClientError> {
172198
if !self.config.is_local && self.config.api_key.trim().is_empty() {
173199
return Err(AiClientError::Auth("未配置 API 密钥(设置页保存或配置环境变量)".to_string()));
174200
}
175-
let payload = build_chat_payload(&self.config.model, system, user, self.config.max_tokens);
176201
let url = chat_completions_url(&self.config.base_url);
177202
let agent = ureq::AgentBuilder::new()
178203
.timeout(std::time::Duration::from_secs(self.config.timeout_secs.max(5)))
@@ -190,17 +215,16 @@ impl AiClient {
190215
.send_json(payload.clone());
191216
match resp {
192217
Ok(resp) => {
193-
let body = resp
218+
return resp
194219
.into_string()
195-
.map_err(|e| AiClientError::Network(format!("读取响应失败: {}", e)))?;
196-
return extract_content(&body);
220+
.map_err(|e| AiClientError::Network(format!("读取响应失败: {}", e)));
197221
}
198222
// 401/403 拆分(2026-08-21 真机 unauthorized 排查):401=密钥
199223
// 无效(换密钥),403=账号无权限/模型未开通(换模型或开权限)——
200224
// 合并时用户无法区分该修密钥还是该换模型
201225
Err(ureq::Error::Status(401, _)) => {
202226
return Err(AiClientError::Auth(
203-
"API 密钥无效(HTTP 401)——请检查设置页密钥或环境变量 SILICONFLOW_API_KEY".to_string(),
227+
"API 密钥无效(HTTP 401)——请检查设置页密钥或环境变量 DEEPSEEK_API_KEY".to_string(),
204228
));
205229
}
206230
Err(ureq::Error::Status(403, _)) => {
@@ -287,6 +311,41 @@ pub fn build_chat_payload(model: &str, system: &str, user: &str, max_tokens: u32
287311
body
288312
}
289313

314+
/// 构建多模态 chat/completions payload(v0.12.0 M5:vision-exp 视觉提取。
315+
/// 纯函数可单测;content 为数组——text + image_url data URI,OpenAI 兼容)。
316+
///
317+
/// @ai-context: images 为空 → content 回归纯文本(与 build_chat_payload 一致,
318+
/// 但多加 json_object 约束——视觉精修输出结构同 note_refine v2)。
319+
/// @ai-context: image_url 走 data URI(base64 内联,无本地上传路径——隐私红线:
320+
/// 图片仅随精修切片请求上云,不作独立提取命令)。
321+
pub fn build_vision_payload(
322+
model: &str,
323+
system: &str,
324+
user: &str,
325+
images: &[String],
326+
max_tokens: u32,
327+
) -> serde_json::Value {
328+
let mut content: Vec<serde_json::Value> = Vec::new();
329+
content.push(serde_json::json!({"type": "text", "text": user}));
330+
for img in images {
331+
content.push(serde_json::json!({"type": "image_url", "image_url": {"url": img}}));
332+
}
333+
let mut body = serde_json::json!({
334+
"model": model,
335+
"messages": [
336+
{"role": "system", "content": system},
337+
{"role": "user", "content": content}
338+
],
339+
"temperature": 0,
340+
"max_tokens": max_tokens,
341+
"response_format": {"type": "json_object"}
342+
});
343+
if model.to_lowercase().contains("r1") {
344+
body["no_think"] = serde_json::json!(true);
345+
}
346+
body
347+
}
348+
290349
/// 从 chat/completions 响应体提取 assistant 文本(纯函数)。
291350
pub fn extract_content(body: &str) -> Result<String, AiClientError> {
292351
let v: serde_json::Value = serde_json::from_str(body)

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
1212
use serde::{Deserialize, Serialize};
1313

14-
use crate::ai_client::{AiClient, AiClientError};
14+
use crate::ai_client::{AiClient, AiClientError, parse_json_object};
1515
use crate::ai_refine_protocol::{AiRefineRequest, AiRefineResponse};
1616

1717
/// 提示词模板(prompts/note_refine.json 结构)。
@@ -127,10 +127,30 @@ impl AiNoteRefineAdapter {
127127
/// summary)——提示词据此约束"只整理本片、沿用全局章节";
128128
/// 响应 schema_version=2(向后兼容:旧响应缺省 1 仍可解析)。
129129
pub fn refine(&self, request: &AiRefineRequest) -> Result<AiRefineResponse, AiClientError> {
130+
// v0.12.0 M5:单一实现——refine = refine_vision(request, &[])(纯文本,
131+
// 与既有行为逐字节一致;画面理解是可选增强,不改变默认链)
132+
self.refine_vision(request, &[])
133+
}
134+
135+
/// 单次精修(多模态分支,v0.12.0 M5:vision-exp 视觉提取)。
136+
///
137+
/// @ai-context: images 非空 → 屏卡图随切片请求送 vision-exp(content 数组
138+
/// 多模态,chat_vision);空 → 纯文本(chat_json,零变化)。
139+
/// 精修输出直接含画面要点;校验/回退逻辑与 refine 共用。
140+
pub fn refine_vision(
141+
&self,
142+
request: &AiRefineRequest,
143+
images: &[String],
144+
) -> Result<AiRefineResponse, AiClientError> {
130145
let system = self.prompt.build_system(&request.profile);
131146
let user = serde_json::to_string(request)
132147
.map_err(|e| AiClientError::Parse(format!("精修请求序列化失败: {}", e)))?;
133-
let v = self.client.chat_json(&system, &user)?;
148+
let v = if images.is_empty() {
149+
self.client.chat_json(&system, &user)?
150+
} else {
151+
let raw = self.client.chat_vision(&system, &user, images)?;
152+
parse_json_object(&raw)?
153+
};
134154
let mut resp: AiRefineResponse = serde_json::from_value(v)
135155
.map_err(|e| AiClientError::Parse(format!("精修响应结构非法: {}", e)))?;
136156
resp.validate()

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

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ use std::collections::VecDeque;
1313
use std::sync::mpsc;
1414
use std::sync::{Arc, Mutex};
1515

16+
use base64::Engine;
17+
1618
use crate::ai_client::AiClient;
1719
use crate::ai_mock::AiMockAdapter;
1820
use 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) => {

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ pub struct AiSettings {
3434
pub low_balance_threshold: f64,
3535
/// 成本确认"记住此选择"偏好(REQ-143 基础版;M2 精修消费)
3636
pub remember_cost_choice: bool,
37+
/// 精修时启用画面理解(v0.12.0 M5):开启后 AI 精修请求附带屏卡图
38+
/// (content 数组多模态,deepseek-v4-flash-vision-exp 视觉提取画面要点)。
39+
/// 图片上传最敏感——独立闸门;默认关(关闭则精修纯文本,现有行为零变化)。
40+
/// 仅视频会话生效;图文会话 OCR 已足够,不触发 vision。
41+
pub vision_refine_enabled: bool,
3742
}
3843

3944
impl Default for AiSettings {
@@ -45,6 +50,7 @@ impl Default for AiSettings {
4550
model: DEFAULT_AI_MODEL.to_string(),
4651
low_balance_threshold: DEFAULT_LOW_BALANCE_THRESHOLD,
4752
remember_cost_choice: false,
53+
vision_refine_enabled: false,
4854
}
4955
}
5056
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ fn default_is_disabled_and_unauthorized() {
2020
let s = AiSettings::default();
2121
assert!(!s.enabled, "全局开关默认必须关闭");
2222
assert!(!s.authorized, "授权默认必须未同意");
23+
// v0.12.0 M5:图片上传最敏感——vision 画面理解默认关(隐私红线)
24+
assert!(!s.vision_refine_enabled, "图片理解默认必须关闭");
2325
}
2426

2527
#[test]
@@ -51,6 +53,8 @@ fn save_load_roundtrip() {
5153
s.model = "acme/model-1".to_string();
5254
s.low_balance_threshold = 5.0;
5355
s.remember_cost_choice = true;
56+
// v0.12.0 M5:vision 画面理解默认关——持久化 roundtrip 保真
57+
s.vision_refine_enabled = true;
5458
s.save(&path).expect("保存成功");
5559
let loaded = AiSettings::load(&path);
5660
assert_eq!(loaded, s);

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ pub struct AiSettingsView {
2929
pub model: String,
3030
pub low_balance_threshold: f64,
3131
pub remember_cost_choice: bool,
32+
/// 精修时启用画面理解(v0.12.0 M5——默认关,图片上传最敏感独立闸门)
33+
pub vision_refine_enabled: bool,
3234
/// 是否已配置密钥(env 或凭据库)
3335
pub has_key: bool,
3436
/// 密钥来源:credential | env | none
@@ -64,6 +66,7 @@ pub fn ai_get_settings(state: State<'_, AppState>) -> Result<AiSettingsView, Str
6466
model: s.model,
6567
low_balance_threshold: s.low_balance_threshold,
6668
remember_cost_choice: s.remember_cost_choice,
69+
vision_refine_enabled: s.vision_refine_enabled,
6770
has_key,
6871
key_source,
6972
})
@@ -138,6 +141,20 @@ pub fn ai_set_enabled(state: State<'_, AppState>, enabled: bool) -> Result<(), S
138141
lock.save(&state.ai_settings_path)
139142
}
140143

144+
/// 开启/关闭"精修时画面理解"(v0.12.0 M5——图片上传最敏感,独立闸门默认关)。
145+
///
146+
/// @ai-context: 仅影响 AI 音视频会话精修是否随请求上传屏卡图;开启不等于自动
147+
/// 上传——仍需全局 enabled + authorized(content_gate 门控)。
148+
#[tauri::command]
149+
pub fn ai_set_vision_refine(state: State<'_, AppState>, refine_enabled: bool) -> Result<(), String> {
150+
let mut lock = state
151+
.ai_settings
152+
.lock()
153+
.map_err(|e| format!("AI 设置锁中毒: {}", e))?;
154+
lock.vision_refine_enabled = refine_enabled;
155+
lock.save(&state.ai_settings_path)
156+
}
157+
141158
/// 一键测试连接(REQ-138:调余额接口验证密钥有效性——错误密钥明确报错)。
142159
///
143160
/// @ai-context: 配置验证类读操作,不 gate 授权;401/403 → 明确"密钥无效"

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,7 @@ pub fn run() {
552552
commands_ai_settings::ai_update_settings,
553553
commands_ai_settings::ai_set_authorized,
554554
commands_ai_settings::ai_set_enabled,
555+
commands_ai_settings::ai_set_vision_refine,
555556
commands_ai_settings::ai_test_connection,
556557
commands_ai_settings::ai_get_balance,
557558
commands_ai_settings::ai_audit_list,

‎app/src/components/AiServicePanel.tsx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,20 @@ export default function AiServicePanel() {
106106
}
107107
};
108108

109+
/** v0.12.0 M5:精修画面理解开关(图片上传最敏感——独立闸门默认关) */
110+
const toggleVisionRefine = async (on: boolean) => {
111+
setBusy(true);
112+
try {
113+
await invoke("ai_set_vision_refine", { refineEnabled: on });
114+
setMsg({ kind: "ok", text: on ? "已开启画面理解(仅视频会话精修生效)" : "已关闭画面理解" });
115+
await load();
116+
} catch (e) {
117+
setMsg({ kind: "err", text: `操作失败:${e}` });
118+
} finally {
119+
setBusy(false);
120+
}
121+
};
122+
109123
return (
110124
<div style={{ fontSize: 12, color: "#1f2937" }}>
111125
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10 }}>
@@ -131,6 +145,20 @@ export default function AiServicePanel() {
131145
<span style={{ color: "#6b7280" }}>(默认关——AI 调用须显式开启)</span>
132146
</div>
133147

148+
{/* v0.12.0 M5:精修画面理解(图片上传最敏感——独立闸门默认关) */}
149+
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10 }}>
150+
<label style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}>
151+
<input
152+
type="checkbox"
153+
checked={!!view?.visionRefineEnabled}
154+
disabled={busy}
155+
onChange={(e) => void toggleVisionRefine(e.target.checked)}
156+
/>
157+
精修时启用画面理解
158+
</label>
159+
<span style={{ color: "#6b7280" }}>(默认关——图片随精修上传,仅视频会话生效)</span>
160+
</div>
161+
134162
{/* 授权确认卡(开启且未授权时出现) */}
135163
{consentVisible && (
136164
<div

‎app/src/types/ai.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export interface AiSettingsView {
1616
model: string;
1717
lowBalanceThreshold: number;
1818
rememberCostChoice: boolean;
19+
/** v0.12.0 M5:精修时启用画面理解(默认关——图片随精修上传,仅视频会话生效) */
20+
visionRefineEnabled: boolean;
1921
hasKey: boolean;
2022
/** credential(凭据库)| env(环境变量)| none */
2123
keySource: string;

0 commit comments

Comments
 (0)