Skip to content

Commit c0c6eff

Browse files
committed
feat(ai): 画面理解开关入精修弹层——本次覆写+设为默认(REQ-284)
1 parent a21fd6d commit c0c6eff

5 files changed

Lines changed: 214 additions & 5 deletions

File tree

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,14 +168,23 @@ pub(crate) fn run_refine_task_skeleton(
168168
///
169169
/// @ai-context: v0.17.0(REQ-245):dims=策略解析结果(command 层 resolve 后
170170
/// 传入)——每片提示词一致(切片间风格统一),协议零改动。
171-
pub fn run_refine_task(st: AppState, task_id: u64, session_id: i64, mock: bool, dims: ResolvedDims) {
171+
pub fn run_refine_task(
172+
st: AppState,
173+
task_id: u64,
174+
session_id: i64,
175+
mock: bool,
176+
dims: ResolvedDims,
177+
// REQ-284(v0.19.7):任务级画面理解覆写(None=跟随全局设置;
178+
// command 层透传前端「仅本次」勾选)
179+
vision_override: Option<bool>,
180+
) {
172181
// 诊断日志(2026-08-21 真机"排队中"排查):tauri dev 终端可见各阶段进度
173182
eprintln!(
174183
"[refine-task] task={} start session={} mock={} strategy={}",
175184
task_id, session_id, mock, dims.preset_id
176185
);
177186
run_refine_task_skeleton(st.clone(), task_id, format!("session={}", session_id), move || {
178-
run_refine_task_inner(&st, task_id, session_id, mock, &dims)
187+
run_refine_task_inner(&st, task_id, session_id, mock, &dims, vision_override)
179188
});
180189
}
181190

@@ -223,6 +232,7 @@ fn run_refine_task_inner(
223232
session_id: i64,
224233
mock: bool,
225234
dims: &ResolvedDims,
235+
vision_override: Option<bool>,
226236
) -> Result<(AiRefineResult, Vec<AiTurn>), AiTaskFailure> {
227237
let env = PurifyEnv {
228238
config: st.purify.clone(),
@@ -289,7 +299,9 @@ fn run_refine_task_inner(
289299
let mock_adapter = AiMockAdapter;
290300
// v0.12.0 M5:画面理解——仅精修设置开启时装载会话屏卡图(≤1280px 控 token;
291301
// 空 → 精修纯文本,现有行为零变化;图文会话不触发——调用方只对视频会话接线)
292-
let vision_images = if settings.vision_refine_enabled {
302+
// REQ-284(v0.19.7):任务级覆写优先于全局(resolve 纯函数——覆写缺省=全局)
303+
let vision_enabled = crate::ai_settings::resolve_vision_refine(vision_override, settings.vision_refine_enabled);
304+
let vision_images = if vision_enabled {
293305
load_session_vision_images(&st.data_dir, session_id)
294306
} else {
295307
Vec::new()

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,29 @@ impl AiSettings {
156156
}
157157
}
158158

159+
/// 任务级画面理解解析(REQ-284,v0.19.7):本次覆写优先,缺省跟随全局——
160+
/// 覆写不写回全局(单向同步;"设为默认"由命令层显式调 ai_set_vision_refine)。
161+
pub fn resolve_vision_refine(task_override: Option<bool>, global_enabled: bool) -> bool {
162+
task_override.unwrap_or(global_enabled)
163+
}
164+
165+
#[cfg(test)]
166+
mod resolve_tests {
167+
use super::resolve_vision_refine;
168+
169+
#[test]
170+
fn override_wins_when_present() {
171+
assert!(resolve_vision_refine(Some(true), false), "本次开覆盖全局关");
172+
assert!(!resolve_vision_refine(Some(false), true), "本次关覆盖全局开");
173+
}
174+
175+
#[test]
176+
fn missing_override_follows_global() {
177+
assert!(resolve_vision_refine(None, true));
178+
assert!(!resolve_vision_refine(None, false));
179+
}
180+
}
181+
159182
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
160183
#[cfg(test)]
161184
#[path = "ai_settings_tests.rs"]

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ pub async fn ai_refine_start(
145145
session_id: i64,
146146
authorized: bool,
147147
strategy: Option<StrategyOverride>,
148+
// REQ-284(v0.19.7):画面理解任务级覆写(None=跟随全局;前端「仅本次」勾选)
149+
vision_refine: Option<bool>,
148150
) -> Result<AiTaskHandle, String> {
149151
if session_id <= 0 {
150152
return Err("无效的会话 id".to_string());
@@ -240,7 +242,7 @@ pub async fn ai_refine_start(
240242
strategy.as_ref(),
241243
);
242244
tauri::async_runtime::spawn_blocking(move || {
243-
crate::ai_refine_task::run_refine_task(st2, task_id, session_id, mock, dims)
245+
crate::ai_refine_task::run_refine_task(st2, task_id, session_id, mock, dims, vision_refine)
244246
});
245247
Ok(AiTaskHandle { task_id, state: AiTaskState::Pending })
246248
}

‎app/src/components/RefineLaunchDialog.tsx‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ export default function RefineLaunchDialog({
4747
const [remember, setRemember] = useState(false);
4848
const [msg, setMsg] = useState("");
4949
const [starting, setStarting] = useState(false);
50+
// REQ-284(v0.19.7):画面理解任务级覆写——null=跟随全局;勾选变化仅本次
51+
// 生效(不写全局;「设为默认」显式写回)
52+
const [visionOverride, setVisionOverride] = useState<boolean | null>(null);
53+
const [visionSavedNote, setVisionSavedNote] = useState("");
5054
const previewTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
5155

5256
// 装载:设置(授权态/策略默认)+ 策略声明 + 预估 + 余额(并行)
@@ -94,6 +98,21 @@ export default function RefineLaunchDialog({
9498
saveDraft(d);
9599
}, []);
96100

101+
// REQ-284:本次生效值(覆写优先)——供开关初值展示与「设为默认」写回
102+
const effectiveVision = visionOverride ?? settings?.visionRefineEnabled ?? false;
103+
104+
/** 「设为默认」:显式把当前生效值写回全局(单向——覆写本身不写全局) */
105+
const saveVisionAsDefault = async () => {
106+
setVisionSavedNote("");
107+
try {
108+
await invoke("ai_set_vision_refine", { refineEnabled: effectiveVision });
109+
setSettings(settings ? { ...settings, visionRefineEnabled: effectiveVision } : settings);
110+
setVisionSavedNote(effectiveVision ? "已设为默认:下次精修默认启用画面理解" : "已设为默认:下次精修默认关闭画面理解");
111+
} catch (e) {
112+
setVisionSavedNote(`写回全局失败: ${e}`);
113+
}
114+
};
115+
97116
const est = estimate?.estimate;
98117
const selectable = useMemo(() => draft !== null, [draft]);
99118

@@ -116,7 +135,13 @@ export default function RefineLaunchDialog({
116135
isNote ? "ai_note_refine_start" : "ai_refine_start",
117136
isNote
118137
? { noteId, content: noteContent ?? null, profile: noteProfile ?? null, authorized: true, strategy: toOverride(draft) }
119-
: { sessionId, authorized: true, strategy: toOverride(draft) },
138+
: {
139+
sessionId,
140+
authorized: true,
141+
strategy: toOverride(draft),
142+
// REQ-284:仅「本次已覆写」才带参(null=后端跟随全局,避免双写歧义)
143+
...(visionOverride != null ? { visionRefine: visionOverride } : {}),
144+
},
120145
).catch((e) => {
121146
setMsg(`启动失败:${e}`);
122147
setStarting(false);
@@ -185,6 +210,45 @@ export default function RefineLaunchDialog({
185210
/>
186211
</div>
187212

213+
{/* REQ-284(v0.19.7):画面理解开关——仅会话级精修(视频会话关键帧语境)
214+
显示;笔记级精修(纯文本语境)隐藏。checkbox 初值=全局;勾选变化=本次
215+
覆写;「设为默认」显式写回全局(单向同步,不双向自动)。 */}
216+
{!isNote && (
217+
<div style={{ marginTop: 8, border: "1px solid #e5e7eb", borderRadius: 8, padding: "6px 10px", display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
218+
<label style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer", fontSize: 12 }}>
219+
<input
220+
data-testid="vision-refine-toggle"
221+
type="checkbox"
222+
checked={effectiveVision}
223+
disabled={starting}
224+
onChange={(e) => { setVisionOverride(e.target.checked); setVisionSavedNote(""); }}
225+
/>
226+
画面理解(上传关键帧给 AI)
227+
</label>
228+
<span style={{ fontSize: 11, color: "#6b7280" }}>
229+
{visionOverride != null
230+
? `本次:${effectiveVision ? "开" : "关"}(全局默认:${settings?.visionRefineEnabled ? "开" : "关"})`
231+
: `跟随全局默认(${settings?.visionRefineEnabled ? "开" : "关"})——勾选即本次覆写`}
232+
</span>
233+
{effectiveVision && (
234+
<span style={{ fontSize: 10, color: "#b45309" }}>⚠ 开启会显著增加耗时与费用</span>
235+
)}
236+
<button
237+
data-testid="vision-set-default"
238+
onClick={() => void saveVisionAsDefault()}
239+
style={{ marginLeft: "auto", fontSize: 11, border: "none", background: "none", color: "#4f46e5", cursor: "pointer", textDecoration: "underline" }}
240+
title="把当前生效值写回全局设置(AI 服务面板可随时修改)"
241+
>
242+
设为默认
243+
</button>
244+
{visionSavedNote && (
245+
<span data-testid="vision-saved-note" style={{ width: "100%", fontSize: 11, color: visionSavedNote.startsWith("已") ? "#047857" : "#dc2626" }}>
246+
{visionSavedNote}
247+
</span>
248+
)}
249+
</div>
250+
)}
251+
188252
{/* 成本确认行 */}
189253
{est && (
190254
<div style={{ marginTop: 8, fontSize: 12, color: "#374151" }}>
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* RefineLaunchDialog.vision.test.tsx — 画面理解开关入精修弹层(REQ-284,v0.19.7)。
4+
*
5+
* @ai-context: 契约——会话级模式显示开关且初值=全局默认;勾选变化=本次覆写
6+
* (confirm 携带 visionRefine);「设为默认」显式写回全局(单向);
7+
* 未覆写时 payload 不带 visionRefine(null=后端跟随全局);笔记级
8+
* 模式(纯文本语境)整行隐藏。
9+
*/
10+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
12+
import RefineLaunchDialog from "./RefineLaunchDialog";
13+
14+
const invokeMock = vi.fn();
15+
16+
vi.mock("@tauri-apps/api/core", () => ({
17+
invoke: (...args: unknown[]) => invokeMock(...args),
18+
}));
19+
20+
const meta = {
21+
strategyDims: [],
22+
ladderPresets: [
23+
{ id: "standard", name: "标准精修", desc: "", instruction: "", dimValues: {} },
24+
],
25+
intents: [],
26+
};
27+
28+
function settingsOf(visionRefineEnabled: boolean) {
29+
return {
30+
enabled: true, authorized: true, baseUrl: "https://x", model: "m", lowBalanceThreshold: 1,
31+
rememberCostChoice: false, visionRefineEnabled,
32+
refineStrategy: { defaultLadder: "", dimOverrides: {} }, hasKey: true, keySource: "credential",
33+
};
34+
}
35+
36+
beforeEach(() => {
37+
invokeMock.mockReset();
38+
invokeMock.mockImplementation((cmd: string) => {
39+
switch (cmd) {
40+
case "ai_get_settings": return Promise.resolve(settingsOf(false));
41+
case "ai_refine_strategy_meta": return Promise.resolve(meta);
42+
case "ai_refine_estimate": return Promise.resolve({ estimate: { estTokens: 100, estCostYuan: 0, pricePer1m: 0, priceKnown: true }, rememberCostChoice: false });
43+
case "ai_get_balance": return Promise.resolve({ balance: { totalBalance: 10, grantsBalance: 10, toppedUpBalance: 0, currency: "CNY" }, lowBalanceWarning: null });
44+
case "ai_refine_prompt_preview": return Promise.resolve("preview");
45+
case "ai_refine_start": return Promise.resolve({ taskId: 9, state: "Pending" });
46+
case "ai_set_vision_refine": return Promise.resolve(null);
47+
default: return Promise.resolve(null);
48+
}
49+
});
50+
});
51+
52+
afterEach(cleanup);
53+
54+
async function findStartCall(): Promise<Record<string, unknown>> {
55+
return new Promise((resolve, reject) => {
56+
const started = Date.now();
57+
const poll = () => {
58+
const call = invokeMock.mock.calls.find((c) => c[0] === "ai_refine_start");
59+
if (call) { resolve(call[1] as Record<string, unknown>); return; }
60+
if (Date.now() - started > 2000) { reject(new Error("未捕获 ai_refine_start 调用")); return; }
61+
setTimeout(poll, 20);
62+
};
63+
poll();
64+
});
65+
}
66+
67+
describe("画面理解开关(REQ-284)", () => {
68+
it("会话级:初值=全局关;勾选=本次覆写,confirm 携带 visionRefine=true", async () => {
69+
render(<RefineLaunchDialog sessionId={5} onClose={vi.fn()} onStarted={vi.fn()} />);
70+
const toggle = (await screen.findByTestId("vision-refine-toggle")) as HTMLInputElement;
71+
expect(toggle.checked).toBe(false);
72+
expect(screen.getByText(/跟随全局默认(关)——勾选即本次覆写/)).toBeTruthy();
73+
fireEvent.click(toggle);
74+
expect(screen.getByText(/本次:开(全局默认:关)/)?.textContent).toBeTruthy();
75+
// 开启时的耗时提示与「设为默认」写回全局
76+
expect(screen.getByText(/⚠ 开启会显著增加耗时与费用/)).toBeTruthy();
77+
fireEvent.click(screen.getByTestId("vision-set-default"));
78+
await waitFor(() => {
79+
expect(invokeMock).toHaveBeenCalledWith("ai_set_vision_refine", { refineEnabled: true });
80+
});
81+
// 确认启动 → 任务级覆写随请求下发
82+
fireEvent.click(screen.getByText("确认并精修 →"));
83+
const args = await findStartCall();
84+
expect(args).toMatchObject({ sessionId: 5, visionRefine: true });
85+
});
86+
87+
it("会话级:未覆写(跟随全局)时 payload 不带 visionRefine 键", async () => {
88+
render(<RefineLaunchDialog sessionId={5} onClose={vi.fn()} onStarted={vi.fn()} />);
89+
await screen.findByTestId("vision-refine-toggle");
90+
fireEvent.click(screen.getByText("确认并精修 →"));
91+
const args = await findStartCall();
92+
expect((args as { visionRefine?: unknown }).visionRefine).toBeUndefined();
93+
});
94+
95+
it("笔记级(纯文本语境):开关整行不显示", async () => {
96+
render(
97+
<RefineLaunchDialog
98+
noteId={6}
99+
noteContent="手写笔记内容"
100+
onClose={vi.fn()}
101+
onStarted={vi.fn()}
102+
/>,
103+
);
104+
await waitFor(() => {
105+
expect(screen.queryByTestId("vision-refine-toggle")).toBeNull();
106+
});
107+
});
108+
});

0 commit comments

Comments
 (0)