diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 33e13265c..a6cb85a61 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -5894,6 +5894,95 @@ fn shortcut_state(shortcut: install::ShortcutState) -> PathState { } } +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TestVlmRequest { + pub api_key: String, + pub model: String, + pub base_url: String, + pub image_data_url: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TestVlmResult { + pub vlm_status: String, + pub http_code: Option, + pub duration_ms: u64, + pub error: Option, + pub description: Option, + pub model: String, + pub raw_request: Option, + pub raw_response: Option, +} + +/// 用表单当前 VLM 配置 + 用户上传图片(data URL)测试 VLM 可用性。 +/// 用表单当前值(未保存亦可);失败也返回结构化 payload 供前端渲染诊断。 +#[tauri::command] +pub async fn test_vlm(request: TestVlmRequest) -> CommandResult { + // 加固 spec §4.1 第二道门:前端校验可被绕过(IPC 直调),类型与大小在 + // 信任边界重新校验;非法输入不发起网络请求。 + if let Err(reason) = codex_plus_core::vision::validate_image_data_url(&request.image_data_url) { + return failed( + "VLM 测试失败:invalid_image", + TestVlmResult { + vlm_status: "invalid_image".to_string(), + http_code: None, + duration_ms: 0, + error: Some(reason), + description: None, + model: request.model, + raw_request: None, + raw_response: None, + }, + ); + } + let config = codex_plus_core::vision::VlmConfig { + api_key: request.api_key, + model: request.model.clone(), + base_url: request.base_url, + }; + let client = match codex_plus_core::http_client::vlm_http_client() { + Ok(c) => c, + Err(e) => { + return failed( + &format!("VLM HTTP 客户端构建失败:{e}"), + TestVlmResult { + vlm_status: "client_error".to_string(), + http_code: None, + duration_ms: 0, + // 加固 spec §4.2:client_error 路径同样过脱敏,全仓库一条规则 + error: Some(codex_plus_core::vision::redact_secrets( + &e.to_string(), + &config.api_key, + )), + description: None, + model: request.model, + raw_request: None, + raw_response: None, + }, + ); + } + }; + let outcome = + codex_plus_core::vision::test_vlm_once(&config, &request.image_data_url, &client).await; + let result = TestVlmResult { + vlm_status: outcome.status.clone(), + http_code: outcome.http_code, + duration_ms: outcome.duration_ms, + error: outcome.error, + description: outcome.text, + model: request.model, + raw_request: outcome.raw_request, + raw_response: outcome.raw_response, + }; + if outcome.status == "ok" { + ok("VLM 测试成功。", result) + } else { + failed(&format!("VLM 测试失败:{}", outcome.status), result) + } +} + fn ok(message: &str, payload: T) -> CommandResult { CommandResult { status: "ok".to_string(), diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs index 245ad0650..e9074b5be 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -70,6 +70,7 @@ pub fn run() { commands::restart_codex_plus, commands::load_settings, commands::save_settings, + commands::test_vlm, commands::load_grok_config, commands::save_grok_config, commands::weixin_connect_qr_start, diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index c6ca522fe..ed67c4875 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -128,6 +128,7 @@ import { type DreamSkinVerificationResult, } from "./dream-skin"; import { getLanguage, t, tf, toggleLanguage } from "@/i18n"; +import { vlmTestTranslation } from "./vlm-test-translation"; const isWindowsPlatform = /\bWindows\b/i.test(navigator.userAgent); const dreamSkinWindowsPreviewUrl = new URL("../../../assets/inject/upstream/dream-skin/windows/dream-reference.jpg", import.meta.url).href; @@ -8228,6 +8229,7 @@ function RelayProfileEditor({ setModelWindowRows: (value: ModelWindowRow[]) => void; }) { const [showAdvanced, setShowAdvanced] = useState(false); + const [vlmTestOpen, setVlmTestOpen] = useState(false); const useCommonConfig = profile.useCommonConfig !== false; // VLM/Strip 对 Chat Completions 与 Responses 协议均可用(注入块类型已按协议适配)。 const vlmUnsupportedProtocol = false; @@ -8692,6 +8694,17 @@ function RelayProfileEditor({ {modelWindowRows.some((row) => row.imageHandling === "vlm") && (!profile.vlmApiKey || !profile.vlmModel || !profile.vlmBaseUrl) ? (

{t("VLM 配置不完整:API Key、Model 和 Base URL 为必填项,否则 VLM 不会生效。")}

) : null} +
+ +
+ {vlmTestOpen ? setVlmTestOpen(false)} /> : null} ) : null} {showApiFields ? ( @@ -8794,6 +8807,207 @@ function RelayProfileEditor({ ); } +type VlmTestState = + | { kind: "idle" } + | { kind: "running" } + | { kind: "done"; result: TestVlmResult }; + +type TestVlmResult = { + status: string; + message: string; + vlmStatus: string; + httpCode: number | null; + durationMs: number; + error: string | null; + description: string | null; + model: string; + rawRequest: string | null; + rawResponse: string | null; +}; + +const VLM_TEST_MAX_IMAGE_BYTES = 10 * 1024 * 1024; + +/// 方向 C:选图即测 + 排障增强(spec §4)。 +/// 选完文件自动开跑;失败时给通俗诊断 + 复制错误 + 原始请求/响应折叠。 +function VlmTestPanel({ + profile, + onClose, +}: { + profile: Pick; + onClose: () => void; +}) { + const [dataUrl, setDataUrl] = useState(null); + const [state, setState] = useState({ kind: "idle" }); + const [showRaw, setShowRaw] = useState(false); + const [localError, setLocalError] = useState(null); + const fileInputRef = useRef(null); + const tr = (zh: string, params?: string[]) => (params ? tf(zh, params) : t(zh)); + + const runTest = async (url: string) => { + setState({ kind: "running" }); + setLocalError(null); + try { + const res = await invoke("test_vlm", { + request: { + apiKey: profile.vlmApiKey, + model: profile.vlmModel, + baseUrl: profile.vlmBaseUrl, + imageDataUrl: url, + }, + }); + setState({ kind: "done", result: res }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + setState({ + kind: "done", + result: { + status: "failed", + message: msg, + vlmStatus: "client_error", + httpCode: null, + durationMs: 0, + error: msg, + description: null, + model: profile.vlmModel, + rawRequest: null, + rawResponse: null, + }, + }); + } + }; + + // 选图即测:选完文件自动发起,无需二次点击(spec §4)。 + const onFile = (file: File | undefined) => { + if (!file) return; + if (!file.type.startsWith("image/")) { + setLocalError(t("请选择图片文件。")); + return; + } + if (file.size > VLM_TEST_MAX_IMAGE_BYTES) { + setLocalError(t("图片超过 10MB,请换一张较小的图片。")); + return; + } + const reader = new FileReader(); + reader.onerror = () => setLocalError(t("读取图片失败,请重新选择")); + reader.onload = () => { + const url = typeof reader.result === "string" ? reader.result : null; + if (url) { + setDataUrl(url); + void runTest(url); + } + }; + reader.readAsDataURL(file); + }; + + // 复制完整排障信息(诊断 + 原始报文,无 API Key),可直接贴 issue(spec §3)。 + const copyError = async () => { + if (state.kind !== "done") return; + const r = state.result; + const text = [ + vlmTestTranslation(r.vlmStatus, r.httpCode ?? undefined, r.durationMs, tr), + `model: ${r.model}`, + r.httpCode != null ? `HTTP ${r.httpCode}` : null, + r.error ? `error: ${r.error}` : null, + r.rawRequest ? `--- request ---\n${r.rawRequest}` : null, + r.rawResponse ? `--- response ---\n${r.rawResponse}` : null, + ] + .filter((x) => x !== null) + .join("\n"); + try { + await navigator.clipboard.writeText(text); + } catch { + setLocalError(t("复制失败,请从报文中手动复制。")); + } + }; + + const done = state.kind === "done" ? state.result : null; + const running = state.kind === "running"; + const formReady = !!profile.vlmApiKey && !!profile.vlmModel && !!profile.vlmBaseUrl; + const canRun = !!dataUrl && formReady; + + return ( +
+
+
+

{t("测试 VLM")}

+

+ {t("选一张图片立即验证当前 VLM 配置(使用表单当前值,无需保存)。")} +

+
+ +
+ +
+ { + onFile(e.currentTarget.files?.[0]); + e.currentTarget.value = ""; + }} + type="file" + style={{ display: "none" }} + /> + + {dataUrl ? {t("图片预览")} : null} + {running ? ( +

+

+ ) : null} +
+ + {localError ?

{localError}

: null} + + {done ? ( +
+

+ {vlmTestTranslation(done.vlmStatus, done.httpCode ?? undefined, done.durationMs, tr)} +

+ {done.description ? ( +
{done.description}
+ ) : null} + {done.vlmStatus !== "ok" ? ( + + ) : null} + + {showRaw ? ( +
+
{t("原始请求")}
+
{done.rawRequest ?? "-"}
+
{t("原始响应")}
+
{done.rawResponse ?? "-"}
+
+ ) : null} +
+ ) : null} + + + {dataUrl ? ( + + ) : null} + + +
+ ); +} + function AggregateRelayProfileEditor({ profile, form, diff --git a/apps/codex-plus-manager/src/i18n-en.ts b/apps/codex-plus-manager/src/i18n-en.ts index f6273336f..3da7d6c37 100644 --- a/apps/codex-plus-manager/src/i18n-en.ts +++ b/apps/codex-plus-manager/src/i18n-en.ts @@ -800,6 +800,11 @@ export const EN_PLAIN: Record = { "覆盖图片": "Overlay image", "背景适配方式": "Background fit mode", "原样发送图片": "Send images as-is", + "移除图片": "Remove images", + "视觉辅助分析": "Vision-assisted analysis", + "多模态模型直接接收图片,不经过任何处理": "Multimodal models receive images directly, without any processing.", + "删掉图片只发文字,避免纯文本模型报错(模型看不到图)": "Strip images and send text only, so text-only models don't error out (the model can't see the image).", + "图片先由视觉辅助模型(Qwen)转成文字描述,纯文本模型也能\"看图\"": "Images are first turned into text descriptions by a vision model (Qwen), so text-only models can \"see\" images too.", "为纯文本模型移除消息中的图片": "Remove images from messages for text-only models", "为纯文本模型配置图片分析路由": "Configure image analysis routing for text-only models", "若开启 VLM analysis,请确认 VLM 配置项完整且服务可用。": "If VLM analysis is enabled, make sure the VLM settings are complete and the service is available.", @@ -1046,6 +1051,40 @@ export const EN_PLAIN: Record = { "高级:直接编辑 TOML": "Advanced: edit TOML directly", "此条目还有表单未覆盖的高级配置,保存时会原样保留。展开下方高级区可查看。": "This entry has advanced settings the form does not cover. They are preserved on save — expand the advanced section below to view them.", + "测试 VLM": "Test VLM", + "收起测试面板": "Collapse test panel", + "选一张图片立即验证当前 VLM 配置(使用表单当前值,无需保存)。": + "Pick an image to instantly verify the current VLM configuration (uses current form values; no need to save).", + "换图并测试": "Swap image & test", + "选择图片并测试": "Choose image & test", + "正在调用 VLM…": "Calling VLM...", + "图片超过 10MB,请换一张较小的图片。": "Image exceeds 10MB. Please choose a smaller one.", + "❌ 测试图片无效:仅支持图片文件,且不超过 10MB": + "❌ Invalid test image: only image files up to 10MB are supported.", + "复制错误": "Copy error", + "显示原始报文": "Show raw messages", + "隐藏原始报文": "Hide raw messages", + "原始请求": "Raw request", + "原始响应": "Raw response", + "重测": "Retest", + "收起": "Collapse", + "复制失败,请从报文中手动复制。": "Copy failed. Please copy from the raw messages instead.", + "请选择图片文件。": "Please choose an image file.", + "图片预览": "Image preview", + "❌ 接口不存在:Base URL 或模型名有误(HTTP 404)": + "❌ Endpoint not found: check the Base URL or model name (HTTP 404)", + "❌ 被限流,稍后再试(HTTP 429)": "❌ Rate limited. Try again later (HTTP 429)", + "❌ 请求超时:VLM 响应过慢或网络不通": "❌ Request timed out: the VLM is too slow or unreachable", + "❌ 发送失败:网络错误,检查 Base URL 是否可达": + "❌ Send failed: network error. Check that the Base URL is reachable", + "❌ 返回内容解析失败:上游返回的不是有效 JSON": + "❌ Failed to parse the response: upstream did not return valid JSON", + "❌ 返回中未找到描述文本:模型可能不支持视觉或返回格式异常": + "❌ No description text found in the response: the model may not support vision or returned an unexpected format", + "❌ 批量描述解析失败(单图测试不应触发)": + "❌ Batch description parse failed (should not trigger for single-image tests)", + "❌ HTTP 客户端构建失败": "❌ Failed to build the HTTP client", + "❌ 未知错误": "❌ Unknown error", }; // Interpolated strings: tf("前缀 {0}", [x]) -> EN_TEMPLATE["前缀 {0}"] with {0} filled. @@ -1142,6 +1181,9 @@ export const EN_TEMPLATE: Record = { "删除备份「{0}」?此操作不可撤销。": "Delete the backup \"{0}\"? This cannot be undone.", "删除仓库源「{0}」?已装的 Skill 不受影响,只是不再更新。": "Delete the repository \"{0}\"? Installed skills are unaffected — they just stop receiving updates.", + "✅ 识别成功(耗时 {0}s)": "✅ Recognition succeeded ({0}s)", + "❌ 服务返回错误(HTTP {0})": "❌ Server returned an error (HTTP {0})", + "❌ 认证失败(HTTP {0}):API Key 或模型名可能不正确": "❌ Auth failed (HTTP {0}): the API key or model name may be incorrect", }; // Backend (Rust) messages returned via result.message. These are translated diff --git a/apps/codex-plus-manager/src/styles.css b/apps/codex-plus-manager/src/styles.css index 4466a7067..aac4c3555 100644 --- a/apps/codex-plus-manager/src/styles.css +++ b/apps/codex-plus-manager/src/styles.css @@ -6009,6 +6009,95 @@ select { } } +/* ── VLM 测试面板(测试 VLM)────────────────────────────── */ +.vlm-test-entry { + grid-column: 1 / -1; + margin-top: 10px; +} +.vlm-test-panel { + grid-column: 1 / -1; + max-width: 560px; + margin-top: 10px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + padding: 12px; +} +.vlm-test-upload { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 12px; + align-items: flex-start; +} +.vlm-test-preview { + max-width: 100%; + max-height: 240px; + object-fit: contain; + border: 1px solid hsl(var(--border)); + border-radius: 6px; +} +.vlm-test-running { + display: flex; + align-items: center; + gap: 6px; + color: hsl(var(--muted-foreground)); + margin: 0; +} +.vlm-test-result { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; +} +.vlm-test-summary { + font-weight: 600; + margin: 0; +} +.vlm-test-description { + max-height: 200px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + background: hsl(var(--muted) / 0.4); + border-radius: 6px; + padding: 8px; + margin: 0; + font-size: 12px; +} +.vlm-test-detail-toggle { + align-self: flex-start; + background: none; + border: none; + color: hsl(var(--primary)); + cursor: pointer; + padding: 0; + font-size: 12px; +} +.vlm-test-raw { + display: flex; + flex-direction: column; + gap: 6px; +} +.vlm-test-raw .label { + font-size: 12px; + color: hsl(var(--muted-foreground)); +} +.vlm-test-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid hsl(var(--muted)); + border-top-color: hsl(var(--primary)); + border-radius: 50%; + animation: vlm-test-spin 0.7s linear infinite; + vertical-align: middle; +} +@keyframes vlm-test-spin { + to { + transform: rotate(360deg); + } +} + .dream-skin-panel .toolbar { flex-wrap: wrap; } diff --git a/apps/codex-plus-manager/src/vlm-test-translation.test.ts b/apps/codex-plus-manager/src/vlm-test-translation.test.ts new file mode 100644 index 000000000..fd0588cfd --- /dev/null +++ b/apps/codex-plus-manager/src/vlm-test-translation.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { vlmTestTranslation } from "./vlm-test-translation.ts"; + +const tr = (zh: string, params?: string[]) => + params ? zh.replace("{0}", params[0]) : zh; + +describe("vlmTestTranslation", () => { + it("ok 含识别成功", () => { + assert.ok(vlmTestTranslation("ok", 200, 2300, tr).includes("识别成功")); + }); + it("http_error 401 含认证失败", () => { + assert.ok(vlmTestTranslation("http_error", 401, 0, tr).includes("认证失败")); + }); + it("http_error 403 也走认证失败并渲染状态码", () => { + const msg = vlmTestTranslation("http_error", 403, 0, tr); + assert.ok(msg.includes("认证失败")); + assert.ok(msg.includes("HTTP 403")); + }); + it("http_error 404 含接口不存在", () => { + assert.ok(vlmTestTranslation("http_error", 404, 0, tr).includes("接口不存在")); + }); + it("send_error 含网络", () => { + assert.ok(vlmTestTranslation("send_error", undefined, 0, tr).includes("网络")); + }); + it("no_text 含未找到描述文本", () => { + assert.ok(vlmTestTranslation("no_text", 200, 0, tr).includes("未找到描述文本")); + }); + it("json_error 含解析失败", () => { + assert.ok(vlmTestTranslation("json_error", 200, 0, tr).includes("解析失败")); + }); + it("未知 status 兜底", () => { + assert.ok(vlmTestTranslation("weird", 500, 0, tr).includes("未知错误")); + }); + it("ok 渲染耗时占位", () => { + assert.ok(vlmTestTranslation("ok", 200, 2300, tr).includes("2.3s")); + }); + it("http_error 500 走通用分支含 HTTP 码", () => { + assert.ok(vlmTestTranslation("http_error", 500, 0, tr).includes("HTTP 500")); + }); + it("parse_error 含批量描述解析失败", () => { + assert.ok(vlmTestTranslation("parse_error", undefined, 0, tr).includes("批量描述解析失败")); + }); + it("client_error 含客户端构建失败", () => { + assert.ok(vlmTestTranslation("client_error", undefined, 0, tr).includes("客户端构建失败")); + }); + it("http_error 无状态码走 ? 兜底", () => { + assert.ok(vlmTestTranslation("http_error", undefined, 0, tr).includes("HTTP ?")); + }); + it("429 文案逐字锁定", () => { + assert.equal(vlmTestTranslation("http_error", 429, 0, tr), "❌ 被限流,稍后再试(HTTP 429)"); + }); + it("timeout 文案逐字锁定", () => { + assert.equal(vlmTestTranslation("timeout", undefined, 0, tr), "❌ 请求超时:VLM 响应过慢或网络不通"); + }); + it("401 文案逐字锁定(含模型名归因)", () => { + assert.equal(vlmTestTranslation("http_error", 401, 0, tr), "❌ 认证失败(HTTP 401):API Key 或模型名可能不正确"); + }); + it("invalid_image 提示图片无效与大小上限", () => { + assert.ok(vlmTestTranslation("invalid_image", undefined, 0, tr).includes("测试图片无效")); + assert.ok(vlmTestTranslation("invalid_image", undefined, 0, tr).includes("10MB")); + }); +}); diff --git a/apps/codex-plus-manager/src/vlm-test-translation.ts b/apps/codex-plus-manager/src/vlm-test-translation.ts new file mode 100644 index 000000000..656b18fd2 --- /dev/null +++ b/apps/codex-plus-manager/src/vlm-test-translation.ts @@ -0,0 +1,38 @@ +// VLM 测试结果 -> 通俗文案映射。tr 为翻译回调(zh + 可选插值参数), +// 由调用方注入(App 传 t/tf,测试传 identity),保持本模块纯函数无 @/ 依赖。 +export type TranslateFn = (zh: string, params?: string[]) => string; + +export function vlmTestTranslation( + status: string, + httpCode: number | undefined, + durationMs: number, + tr: TranslateFn, +): string { + const secs = (durationMs / 1000).toFixed(1); + switch (status) { + case "ok": + return tr("✅ 识别成功(耗时 {0}s)", [secs]); + case "http_error": + if (httpCode === 401 || httpCode === 403) + return tr("❌ 认证失败(HTTP {0}):API Key 或模型名可能不正确", [String(httpCode)]); + if (httpCode === 404) return tr("❌ 接口不存在:Base URL 或模型名有误(HTTP 404)"); + if (httpCode === 429) return tr("❌ 被限流,稍后再试(HTTP 429)"); + return tr("❌ 服务返回错误(HTTP {0})", [String(httpCode ?? "?")]); + case "timeout": + return tr("❌ 请求超时:VLM 响应过慢或网络不通"); + case "send_error": + return tr("❌ 发送失败:网络错误,检查 Base URL 是否可达"); + case "json_error": + return tr("❌ 返回内容解析失败:上游返回的不是有效 JSON"); + case "no_text": + return tr("❌ 返回中未找到描述文本:模型可能不支持视觉或返回格式异常"); + case "parse_error": + return tr("❌ 批量描述解析失败(单图测试不应触发)"); + case "client_error": + return tr("❌ HTTP 客户端构建失败"); + case "invalid_image": + return tr("❌ 测试图片无效:仅支持图片文件,且不超过 10MB"); + default: + return tr("❌ 未知错误"); + } +} diff --git a/crates/codex-plus-core/src/vision.rs b/crates/codex-plus-core/src/vision.rs index 0f105cdcd..765a6ce92 100644 --- a/crates/codex-plus-core/src/vision.rs +++ b/crates/codex-plus-core/src/vision.rs @@ -334,27 +334,312 @@ const VLM_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs( #[cfg(test)] const VLM_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); -/// 单 batch VLM 调用(含错误详情截断)。 -async fn call_vlm_batch(urls: &[String], config: &VlmConfig) -> Result { - let client = crate::http_client::vlm_http_client_with_timeout( - std::time::Duration::from_secs(5), - VLM_REQUEST_TIMEOUT, - ) - .map_err(|e| format!("client: {e}"))?; - let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/')); +/// VLM 单条描述提示词:真实识图链路与「测试 VLM」共用(同源契约,spec §5.1)。 +const VLM_DESCRIBE_PROMPT: &str = "请描述图片内容。如包含文字,请精确提取图片中的文字。"; + +/// 构造 VLM chat/completions 请求体。真实链路(call_vlm_batch)与测试入口 +/// (test_vlm_once)共用,保证测试请求与真实请求同源。 +fn build_vlm_request_body(urls: &[String], model: &str) -> Value { let mut parts: Vec = urls .iter() .map(|u| serde_json::json!({"type": "image_url", "image_url": {"url": u}})) .collect(); parts.push(serde_json::json!({ "type": "text", - "text": "请描述图片内容。如包含文字,请精确提取图片中的文字。" + "text": VLM_DESCRIBE_PROMPT })); - let body = serde_json::json!({ - "model": config.model, + serde_json::json!({ + "model": model, "messages": [{"role": "user", "content": parts}], "stream": false, - }); + }) +} + +/// VLM chat/completions 端点:真实链路与「测试 VLM」共用(同源契约,spec §5.1)。 +fn vlm_endpoint(base_url: &str) -> String { + format!("{}/chat/completions", base_url.trim_end_matches('/')) +} + +/// 原始报文展示截断上限(字符数):防止 HTML 错误页/超大响应撑爆界面。 +const RAW_SNIPPET_LIMIT: usize = 4000; + +fn raw_snippet(text: &str) -> String { + if text.chars().count() <= RAW_SNIPPET_LIMIT { + text.to_string() + } else { + let head: String = text.chars().take(RAW_SNIPPET_LIMIT).collect(); + format!("{head}\n…(已截断)") + } +} + +/// 脱敏占位符:读者可辨识此处发生过脱敏,且无法从占位符还原原文。 +const REDACTED_PLACEHOLDER: &str = "***"; + +/// 排障文本凭据脱敏(加固 spec §4.2):① 已配置 Key 的精确字符串(Key 为空时 +/// 跳过——对空串做替换会毁掉整个文本);② 授权头形态:`Authorization: <值>` +/// 掩到行/结构分隔符、独立词 `Bearer <令牌>` 掩单个词,匹配不区分大小写。 +/// description(OCR 结果)为功能本体,不经过本函数。 +pub fn redact_secrets(text: &str, api_key: &str) -> String { + let out = if api_key.is_empty() { + text.to_string() + } else { + text.replace(api_key, REDACTED_PLACEHOLDER) + }; + mask_bearer_tokens(&mask_authorization_values(&out)) +} + +/// 掩码值段的词边界:空白、引号、逗号、花/尖括号。 +fn is_word_delimiter(b: u8) -> bool { + matches!( + b, + b' ' | b'\t' | b'\n' | b'\r' | b',' | b'"' | b'\'' | b'}' | b'{' | b'<' | b'>' + ) +} + +/// 掩码 `Authorization: <值>`。头名后允许 JSON 引号(`"authorization":`); +/// 值可含空格(`Bearer sk-x` 是一个头值),掩到行/结构分隔符为止。 +fn mask_authorization_values(text: &str) -> String { + const WORD: &str = "authorization"; + let lower = text.to_ascii_lowercase(); + let bytes = text.as_bytes(); + let mut result = String::with_capacity(text.len()); + let mut i = 0; + while let Some(rel) = lower[i..].find(WORD) { + let at = i + rel; + let mut j = at + WORD.len(); + while j < bytes.len() && bytes[j] == b'"' { + j += 1; + } + if j >= bytes.len() || bytes[j] != b':' { + // 不是头形态(如普通文案 "authorization failed"):原样保留 + result.push_str(&text[i..j]); + i = j; + continue; + } + j += 1; // 冒号 + while j < bytes.len() && matches!(bytes[j], b' ' | b'\t') { + j += 1; + } + result.push_str(&text[i..j]); + // JSON 字符串值掩到闭引号;裸值可含空格(`Bearer sk-x` 是一个头值), + // 空白不截断,掩到行/结构分隔符为止。空值不掩(j == value_start)。 + let value_start; + if j < bytes.len() && bytes[j] == b'"' { + result.push('"'); + j += 1; + value_start = j; + while j < bytes.len() && bytes[j] != b'"' { + j += 1; + } + } else { + value_start = j; + while j < bytes.len() + && (matches!(bytes[j], b' ' | b'\t') || !is_word_delimiter(bytes[j])) + { + j += 1; + } + } + if j > value_start { + result.push_str(REDACTED_PLACEHOLDER); + } + i = j; // 闭引号/分隔符留给下一轮原样输出 + } + result.push_str(&text[i..]); + result +} + +/// 掩码独立词 `Bearer <令牌>`:bearer 前一字符不是字母数字/下划线、后随空白 +/// 才算命中,掩码其后的单个词。 +fn mask_bearer_tokens(text: &str) -> String { + const WORD: &str = "bearer"; + let lower = text.to_ascii_lowercase(); + let bytes = text.as_bytes(); + let mut result = String::with_capacity(text.len()); + let mut i = 0; + while let Some(rel) = lower[i..].find(WORD) { + let at = i + rel; + let after = at + WORD.len(); + // 前一字符是字母数字或 `_` 视为词内(如 token_bearer),不算独立词 + let prev_in_word = at > 0 + && ((bytes[at - 1] as char).is_ascii_alphanumeric() || bytes[at - 1] == b'_'); + let next_is_ws = after < bytes.len() && matches!(bytes[after], b' ' | b'\t'); + result.push_str(&text[i..after]); + if prev_in_word || !next_is_ws { + i = after; + continue; + } + let mut j = after; + while j < bytes.len() && matches!(bytes[j], b' ' | b'\t') { + j += 1; + } + // bearer 与令牌之间的空白原样保留 + result.push_str(&text[after..j]); + let value_start = j; + while j < bytes.len() && !is_word_delimiter(bytes[j]) { + j += 1; + } + if j > value_start { + result.push_str(REDACTED_PLACEHOLDER); + } + i = j; + } + result.push_str(&text[i..]); + result +} + +/// 单次 VLM 测试调用的结构化结果(spec §4/§5)。 +/// raw_request/raw_response 供排障折叠区展示;raw_request 是展示副本,图片 +/// base64 截断为 64 字符前缀 + 占位符(真实请求体可含数 MB base64),构造上 +/// 不含 API Key(Key 在请求头)。raw_response/error 携带上游可控内容,构造时 +/// 经 redact_secrets 机器脱敏(加固 spec §4.5:请求侧天然不含 + 响应侧脱敏)。 +/// description(OCR 结果)为功能本体,从原始响应解析、逐字可见、不脱敏。 +#[derive(Debug, Clone, serde::Serialize)] +pub struct VlCallOutcome { + pub status: String, + pub http_code: Option, + pub duration_ms: u64, + pub error: Option, + pub text: Option, + pub raw_request: Option, + pub raw_response: Option, +} + +/// 「测试 VLM」输入图片上限:与前端 VLM_TEST_MAX_IMAGE_BYTES(App.tsx)同口径。 +pub const VLM_TEST_MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; + +/// 校验测试入口的 image_data_url(加固 spec §4.1)。仅由 test_vlm 命令调用, +/// 真实识图链路不经过(其 URL 来自会话消息,本就不限类型与大小——同源契约)。 +/// 口径:data:image/* + base64 段 + 解码后字节 ≤ 10MB。量解码后字节而非字符 +/// 串长度:base64 膨胀约 4/3,量字符串会把前端合法放行的图误拒。 +pub fn validate_image_data_url(url: &str) -> Result<(), String> { + use base64::Engine as _; + let Some((meta, payload)) = url.split_once(',') else { + return Err("image_data_url 不是合法的 data URL(缺少元数据段)".to_string()); + }; + if !meta.starts_with("data:image/") { + return Err(format!("仅支持图片文件(data:image/*),收到:{meta}")); + } + if !meta.ends_with(";base64") { + return Err("仅支持 base64 编码的 data URL".to_string()); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(payload) + .map_err(|e| format!("图片 base64 解码失败:{e}"))?; + if bytes.len() > VLM_TEST_MAX_IMAGE_BYTES { + return Err(format!("图片超过 10MB 上限(解码后 {} 字节)", bytes.len())); + } + Ok(()) +} + +/// 「测试 VLM」单图调用:与真实识图链路共用提示词与请求体构造(spec §5.1)。 +/// 先读响应体再判 HTTP 码:非 2xx 一律 http_error,HTML 错误页不误判为 +/// json_error(spec §5.3)。status 口径:ok/http_error/timeout/send_error/ +/// json_error/no_text(client_error 由命令层产生)。 +pub async fn test_vlm_once( + config: &VlmConfig, + image_data_url: &str, + client: &reqwest::Client, +) -> VlCallOutcome { + let _permit = vlm_semaphore() + .acquire() + .await + .expect("vlm semaphore closed"); + let endpoint = vlm_endpoint(&config.base_url); + let body = build_vlm_request_body(&[image_data_url.to_string()], &config.model); + let mut display = body.clone(); + // 展示副本:图片 base64 可达数 MB,占位替换避免拖垮 IPC/界面(与 spec §4 大小限制同因) + if let Some(parts) = display["messages"][0]["content"].as_array_mut() { + for part in parts.iter_mut() { + if part["type"] == "image_url" { + if let Some(url) = part["image_url"]["url"].as_str() { + let head: String = url.chars().take(64).collect(); + part["image_url"]["url"] = + serde_json::json!(format!("{head}…(图片 base64 已省略)")); + } + } + } + } + let raw_request = serde_json::to_string_pretty(&display).ok(); + let started = std::time::Instant::now(); + let response = match client + .post(&endpoint) + .bearer_auth(&config.api_key) + .json(&body) + .timeout(VLM_REQUEST_TIMEOUT) + .send() + .await + { + Ok(r) => r, + Err(e) => { + let s = if e.is_timeout() { + "timeout" + } else { + "send_error" + }; + return VlCallOutcome { + status: s.to_string(), + http_code: None, + duration_ms: started.elapsed().as_millis() as u64, + error: Some(redact_secrets(&e.to_string(), &config.api_key)), + text: None, + raw_request, + raw_response: None, + }; + } + }; + let http_code = response.status().as_u16(); + let body_text = response.text().await.unwrap_or_default(); + // 加固 spec §4.2:脱敏只应用于 raw_response/error 展示字段;description + // (OCR 结果)从原始 body 解析、逐字可见(spec §3/§5)。脱敏副本在截断前 + // 生成——先截断会把跨界 Key 截成半截,精确替换就匹配不到了。 + let redacted_body = redact_secrets(&body_text, &config.api_key); + let raw_response = Some(raw_snippet(&redacted_body)); + let finish = |status: &str, error: Option, text: Option| VlCallOutcome { + status: status.to_string(), + http_code: Some(http_code), + duration_ms: started.elapsed().as_millis() as u64, + error, + text, + raw_request: raw_request.clone(), + raw_response: raw_response.clone(), + }; + if !(200..300).contains(&http_code) { + let snippet: String = redacted_body.chars().take(ERROR_BODY_TRUNCATE).collect(); + return finish( + "http_error", + Some(format!("VLM API {http_code}: {snippet}")), + None, + ); + } + let response_body: Value = match serde_json::from_str(&body_text) { + Ok(v) => v, + Err(e) => { + let snippet: String = redacted_body.chars().take(ERROR_BODY_TRUNCATE).collect(); + return finish( + "json_error", + Some(format!("JSON parse failed: {e} | body: {snippet}")), + None, + ); + } + }; + match response_body["choices"][0]["message"]["content"] + .as_str() + .map(String::from) + { + Some(t) => finish("ok", None, Some(t)), + None => finish("no_text", Some("no content".to_string()), None), + } +} + +/// 单 batch VLM 调用(含错误详情截断)。 +async fn call_vlm_batch(urls: &[String], config: &VlmConfig) -> Result { + let client = crate::http_client::vlm_http_client_with_timeout( + std::time::Duration::from_secs(5), + VLM_REQUEST_TIMEOUT, + ) + .map_err(|e| format!("client: {e}"))?; + let url = vlm_endpoint(&config.base_url); + let body = build_vlm_request_body(urls, &config.model); let resp = client .post(&url) .header("Authorization", format!("Bearer {}", config.api_key)) @@ -976,6 +1261,28 @@ mod tests { assert_eq!(image_handling_mode("gpt-4", ""), ImageHandling::SendAsIs); } + // ── build_vlm_request_body(测试 VLM 同源契约)───────────────── + + #[test] + fn build_vlm_request_body_shape_and_prompt() { + let body = + build_vlm_request_body(&["data:image/png;base64,QUJD".to_string()], "qwen-vl-max"); + assert_eq!(body["model"], "qwen-vl-max"); + assert_eq!(body["stream"], false); + assert_eq!(body["messages"].as_array().map(Vec::len), Some(1)); + assert_eq!(body["messages"][0]["role"], "user"); + let content = &body["messages"][0]["content"]; + assert_eq!(content.as_array().map(Vec::len), Some(2)); + assert_eq!(content[0]["type"], "image_url"); + assert_eq!(content[0]["image_url"]["url"], "data:image/png;base64,QUJD"); + assert_eq!(content[1]["type"], "text"); + // 提示词必须与真实链路 call_vlm_batch 所用完全一致(同源,spec §5.1) + assert_eq!( + content[1]["text"], + "请描述图片内容。如包含文字,请精确提取图片中的文字。" + ); + } + // ── strip_images_only ───────────────────────────────────────── #[test] @@ -2177,6 +2484,386 @@ mod tests { } } + // ── redact_secrets(加固 spec §4.2 脱敏)───────────────────────── + + #[test] + fn redact_secrets_replaces_exact_key_everywhere() { + assert_eq!( + redact_secrets("Incorrect API key provided: sk-test-123", "sk-test-123"), + "Incorrect API key provided: ***" + ); + assert_eq!(redact_secrets("sk-a and sk-a", "sk-a"), "*** and ***"); + } + + #[test] + fn redact_secrets_skips_exact_replacement_when_key_empty() { + // 空串不做精确替换(会把整段文本毁掉),但授权头形态规则照常生效 + assert_eq!(redact_secrets("bearer abc123", ""), "bearer ***"); + assert_eq!(redact_secrets("plain text", ""), "plain text"); + } + + #[test] + fn redact_secrets_masks_authorization_header_forms() { + let cases = [ + ("Authorization: Bearer sk-abc", "Authorization: ***"), + ("authorization: Basic dXNlcjpwYXNz", "authorization: ***"), + ("\"Authorization\": \"Bearer xyz\"", "\"Authorization\": \"***\""), + // 空值不掩成占位符 + ("\"Authorization\": \"\"", "\"Authorization\": \"\""), + ]; + for (input, expected) in cases { + assert_eq!(redact_secrets(input, "unused-key"), expected, "input: {input}"); + } + } + + #[test] + fn redact_secrets_masks_standalone_bearer_words() { + assert_eq!( + redact_secrets("header was bearer tok123, then more", "unused"), + "header was bearer ***, then more" + ); + // 非独立词 / 后面不是空白:不误伤 + assert_eq!(redact_secrets("unbearer something", "unused"), "unbearer something"); + assert_eq!(redact_secrets("bearerxyz", "unused"), "bearerxyz"); + // 下划线词内(token_bearer)不算独立词 + assert_eq!(redact_secrets("token_bearer abc", "unused"), "token_bearer abc"); + } + + #[test] + fn redact_secrets_keeps_normal_text_intact() { + assert_eq!(redact_secrets("HTTP 401 未授权", "sk-test"), "HTTP 401 未授权"); + // authorization 作为普通单词(无冒号)不触发 + assert_eq!(redact_secrets("authorization failed", "sk-test"), "authorization failed"); + } + + // ── validate_image_data_url(加固 spec §4.1 第二道门)──────────── + + #[test] + fn validate_image_data_url_accepts_normal_image() { + assert!(validate_image_data_url("data:image/png;base64,QUJD").is_ok()); + assert!(validate_image_data_url("data:image/jpeg;base64,/9j/4AAQ").is_ok()); + } + + #[test] + fn validate_image_data_url_rejects_non_image_and_non_data() { + assert!(validate_image_data_url("https://example.com/cat.png").is_err()); + assert!(validate_image_data_url("data:text/html;base64,PGh0bWw+").is_err()); + // 非 base64 编码段(正常前端 FileReader 产物恒为 base64) + assert!(validate_image_data_url("data:image/png,QUJD").is_err()); + assert!(validate_image_data_url("not-a-url").is_err()); + } + + #[test] + fn validate_image_data_url_enforces_size_limit_on_decoded_bytes() { + use base64::Engine as _; + let encode = |n: usize| { + let payload = base64::engine::general_purpose::STANDARD.encode(vec![b'A'; n]); + format!("data:image/png;base64,{payload}") + }; + // 恰好 10MB 放行(与前端 > 判断口径一致);量的是解码后字节而非字符串长度 + assert!(validate_image_data_url(&encode(VLM_TEST_MAX_IMAGE_BYTES)).is_ok()); + assert!(validate_image_data_url(&encode(VLM_TEST_MAX_IMAGE_BYTES + 1)).is_err()); + } + + #[test] + fn validate_image_data_url_rejects_broken_base64() { + assert!(validate_image_data_url("data:image/png;base64,!!!not-base64!!!").is_err()); + } + + // ── test_vlm_once(测试 VLM 命令核心)───────────────────────── + + fn test_vlm_config(base_url: String) -> VlmConfig { + VlmConfig { + api_key: "sk-test".to_string(), + model: "vlm-mock".to_string(), + base_url, + } + } + + #[tokio::test] + async fn test_vlm_once_ok_returns_description_and_same_source_body() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "choices": [{"message": {"content": "mock: a cat on a sofa"}}], + // 模拟上游在成功响应里夹带回显凭据(加固 spec §5 边界情况) + "echo": "sk-test" + }))) + .mount(&mock_server) + .await; + let client = reqwest::Client::new(); + let outcome = test_vlm_once( + &test_vlm_config(mock_server.uri()), + "data:image/png;base64,QUJD", + &client, + ) + .await; + assert_eq!(outcome.status, "ok"); + assert_eq!(outcome.http_code, Some(200)); + assert_eq!(outcome.text.as_deref(), Some("mock: a cat on a sofa")); + assert!(outcome.error.is_none()); + // 同源:测试请求体就是共享原语的产物(含真实链路同款提示词与图片块) + let raw = outcome.raw_request.as_deref().unwrap(); + assert!(raw.contains("请描述图片内容")); + assert!(raw.contains("data:image/png;base64,QUJD")); + assert!(raw.contains("vlm-mock")); + // 展示副本:图片 base64 截断为前缀 + 占位符,真实 body 照常发送 + assert!(raw.contains("图片 base64 已省略")); + // spec §5.4:raw_request 不得泄露 API Key + assert!(!raw.contains("sk-test")); + assert!(outcome.raw_response.as_deref().unwrap().contains("a cat")); + // 加固 spec §4.2:raw_response 对已配置 Key 机器脱敏(响应侧回显场景) + let raw_resp = outcome.raw_response.as_deref().unwrap(); + assert!(!raw_resp.contains("sk-test")); + assert!(raw_resp.contains("***")); + } + + #[tokio::test] + async fn test_vlm_once_redacts_upstream_echoed_credentials() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(401).set_body_string( + "{\"error\":{\"message\":\"Incorrect API key provided: sk-test\",\"authorization\":\"Bearer sk-test\"}}", + )) + .mount(&mock_server) + .await; + let client = reqwest::Client::new(); + let outcome = test_vlm_once( + &test_vlm_config(mock_server.uri()), + "data:image/png;base64,QUJD", + &client, + ) + .await; + assert_eq!(outcome.status, "http_error"); + for field in [ + outcome.error.as_deref().unwrap_or(""), + outcome.raw_response.as_deref().unwrap_or(""), + ] { + assert!(!field.contains("sk-test"), "leaked in: {field}"); + assert!(field.contains("***")); + } + } + + #[tokio::test] + async fn test_vlm_once_keeps_description_verbatim_while_redacting_raw_and_error() { + // 加固 spec §3/§5:图里印着已配置 Key 且成功——description(OCR 结果) + // 逐字可见;脱敏只应用于 raw_response/error,不吞 description。 + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "choices": [{ + "message": { + "content": "the image prints the key sk-test on a red badge", + // 模拟上游把 Key 回显在 content 之外的结构化字段 + "reflected_key": "sk-test", + } + }] + }))) + .mount(&mock_server) + .await; + let client = reqwest::Client::new(); + let outcome = test_vlm_once( + &test_vlm_config(mock_server.uri()), + "data:image/png;base64,QUJD", + &client, + ) + .await; + assert_eq!(outcome.status, "ok"); + // description 逐字可见:命中精确匹配的那串不被掩 + let desc = outcome.text.as_deref().unwrap(); + assert!(desc.contains("sk-test"), "description must stay verbatim: {desc}"); + assert!(!desc.contains("***"), "description must stay verbatim: {desc}"); + // raw_response 仍整段脱敏 + let raw_resp = outcome.raw_response.as_deref().unwrap(); + assert!(!raw_resp.contains("sk-test")); + assert!(raw_resp.contains("***")); + } + + #[tokio::test] + async fn test_vlm_once_http_error_401() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(401).set_body_string("{\"error\":\"bad key\"}")) + .mount(&mock_server) + .await; + let client = reqwest::Client::new(); + let outcome = test_vlm_once( + &test_vlm_config(mock_server.uri()), + "data:image/png;base64,QUJD", + &client, + ) + .await; + assert_eq!(outcome.status, "http_error"); + assert_eq!(outcome.http_code, Some(401)); + assert!(outcome.error.as_deref().unwrap().contains("401")); + assert!(outcome.text.is_none()); + assert!(outcome.raw_response.as_deref().unwrap().contains("bad key")); + } + + #[tokio::test] + async fn test_vlm_once_http_error_404() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(404).set_body_string("{\"error\":\"not found\"}")) + .mount(&mock_server) + .await; + let client = reqwest::Client::new(); + let outcome = test_vlm_once( + &test_vlm_config(mock_server.uri()), + "data:image/png;base64,QUJD", + &client, + ) + .await; + assert_eq!(outcome.status, "http_error"); + assert_eq!(outcome.http_code, Some(404)); + assert!(outcome.error.as_deref().unwrap().contains("404")); + } + + #[tokio::test] + async fn test_vlm_once_http_error_429() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with( + ResponseTemplate::new(429).set_body_string("{\"error\":\"rate limited\"}"), + ) + .mount(&mock_server) + .await; + let client = reqwest::Client::new(); + let outcome = test_vlm_once( + &test_vlm_config(mock_server.uri()), + "data:image/png;base64,QUJD", + &client, + ) + .await; + assert_eq!(outcome.status, "http_error"); + assert_eq!(outcome.http_code, Some(429)); + assert!(outcome.error.as_deref().unwrap().contains("429")); + } + + /// spec §5.3:非 2xx 的 HTML 错误页必须报 http_error,不得误判为 json_error。 + #[tokio::test] + async fn test_vlm_once_html_error_page_reports_http_error() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with( + ResponseTemplate::new(502) + .insert_header("content-type", "text/html") + .set_body_string("Bad Gateway"), + ) + .mount(&mock_server) + .await; + let client = reqwest::Client::new(); + let outcome = test_vlm_once( + &test_vlm_config(mock_server.uri()), + "data:image/png;base64,QUJD", + &client, + ) + .await; + assert_eq!(outcome.status, "http_error"); + assert_eq!(outcome.http_code, Some(502)); + assert!(outcome.raw_response.as_deref().unwrap().contains("")); + } + + #[tokio::test] + async fn test_vlm_once_json_error_on_200_non_json() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_string("plain text not json")) + .mount(&mock_server) + .await; + let client = reqwest::Client::new(); + let outcome = test_vlm_once( + &test_vlm_config(mock_server.uri()), + "data:image/png;base64,QUJD", + &client, + ) + .await; + assert_eq!(outcome.status, "json_error"); + assert_eq!(outcome.http_code, Some(200)); + assert!( + outcome + .error + .as_deref() + .unwrap() + .contains("JSON parse failed") + ); + assert!( + outcome + .raw_response + .as_deref() + .unwrap() + .contains("plain text not json") + ); + } + + #[tokio::test] + async fn test_vlm_once_no_text_when_content_missing() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "choices": [{"message": {}}] + }))) + .mount(&mock_server) + .await; + let client = reqwest::Client::new(); + let outcome = test_vlm_once( + &test_vlm_config(mock_server.uri()), + "data:image/png;base64,QUJD", + &client, + ) + .await; + assert_eq!(outcome.status, "no_text"); + assert_eq!(outcome.http_code, Some(200)); + assert!(outcome.error.as_deref().unwrap().contains("no content")); + } + + /// cfg(test) 下 VLM_REQUEST_TIMEOUT=2s,mock 延迟 3s 触发超时路径。 + #[tokio::test] + async fn test_vlm_once_timeout() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(ResponseTemplate::new(200).set_delay(std::time::Duration::from_secs(3))) + .mount(&mock_server) + .await; + let client = reqwest::Client::new(); + let outcome = test_vlm_once( + &test_vlm_config(mock_server.uri()), + "data:image/png;base64,QUJD", + &client, + ) + .await; + assert_eq!(outcome.status, "timeout"); + assert!(outcome.http_code.is_none()); + } + + /// 连接错误(非超时)→ send_error。 + /// 用端口 0:Windows 安全软件可能让「连接被拒」延迟 ~2s 才返回,恰好撞上 + /// cfg(test) 的 2s 请求超时而被误判为 timeout;连接端口 0 则立即报 + /// 传输层错误(WSAEADDRNOTAVAIL),确定性地走 send_error 路径。 + #[tokio::test] + async fn test_vlm_once_send_error_on_connection_refused() { + let client = reqwest::Client::new(); + let outcome = test_vlm_once( + &test_vlm_config("http://127.0.0.1:0".to_string()), + "data:image/png;base64,QUJD", + &client, + ) + .await; + assert_eq!(outcome.status, "send_error"); + assert!(outcome.http_code.is_none()); + assert!(outcome.raw_request.is_some()); + } + // ── Responses 协议下的 tool 输出(issue #1996 的第二处缺口)────── // // Responses 协议不做消息转换,tool 输出保持 `function_call_output.output[]` diff --git a/docs/superpowers/specs/2026-08-27-vlm-test-entry-design.md b/docs/superpowers/specs/2026-08-27-vlm-test-entry-design.md new file mode 100644 index 000000000..c829c241f --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-vlm-test-entry-design.md @@ -0,0 +1,116 @@ +# VLM 测试入口设计(vlm-test-entry) + +> 日期:2026-08-27 +> 状态:已评审(brainstorm 定稿) +> 分支:`vlm-test-entry`(基线:`95abcf6`,上游 main 的祖先——main tip 因 #1691 在 Windows 目标编译损坏暂不可基线) +> worktree:`CodexPlusPlus-VLMtest` + +--- + +## 1. 背景与动机 + +用户为纯文本模型配置 VLM 视觉辅助分析后,**没有任何验证手段**——只能等到真实对话里发图,靠结果反推配置是否生效。常见遭遇:模型先说"不接受图片输入",追问后却开始幻觉式描述图片,用户无法判断是 Key 错、Base URL 错、模型名错,还是 VLM 服务本身异常。 + +本 PR 在 VLM 配置区提供即测即看的验证入口,并对 main 的现状做适配(main 的 VLM 调用只有一种提示词、仅支持 Chat Completions 格式)。 + +## 2. 目标与非目标 + +**目标** + +1. **即测即看**:配好 VLM 后选一张图立刻验证,不等真实对话触发。 +2. **与真实链路同源**:测试请求与真实识图链路使用同一条提示词、同一套请求体构造——测试通过即代表真实对话识图可用,测试失败的原因就是真实链路会遇到的失败原因。 +3. **失败可诊断**:一句人话诊断 + 可折叠的原始请求/响应报文 + 一键复制完整排障信息。 +4. **零门槛**:用表单当前值即可测试,不要求先保存。 + +**非目标(YAGNI)** + +- 不做多图批量测试、不做流式。 +- 不做拖拽上传(按钮选图,follow-up 可加)。 +- 不改动图片处理管线的行为:strip/vlm 判定、缓存、注入逻辑原样保留;对现有代码的唯一触碰是等价重构(提示词/请求体构造/端点构造提取为共享,见 §5.1),现有测试兜底。 +- 不引入 VLM 多协议格式选择(main 的 VLM 调用仅 Chat Completions,测试入口保持一致;多格式留待上游有需求时再说)。 + +## 3. 用户场景 + +| 场景 | 用户动作 | 得到什么 | +|---|---|---| +| 验证配置 | 配好 Key/模型/地址 → 点「测试 VLM」→ 选一张图 | "✅ 识别成功(耗时 X 秒)"+ 描述原文,确认配置可用 | +| 排障 | 识图异常 → 打开测试面板复现 | 人话诊断(如"认证失败:API Key 或模型名可能不正确")+ 原始请求/响应报文,一眼定位配错项 | +| 报 issue | 排障后仍无法解决 | 点「复制错误」得到完整排障信息(诊断 + 报文),直接贴进 issue | + +## 4. 交互设计 + +**入口**:VLM 配置区底部「测试 VLM」按钮(与现有 VLM API Key / VLM Model / VLM Base URL 字段同区)。 + +**面板**:点击后**就地内联展开**(不弹模态框、不整页滚动);再次点击入口或点「收起」关闭。 + +**测试流程(选图即测)**:面板内只有一个动作按钮「选择图片并测试」——选完文件**自动发起测试**,没有第二次点击。进行中状态明确可见(加载指示 + 按钮防重复触发)。 + +**结果区**: +- 成功:结果行(含耗时)+ 模型返回的描述原文 + 可折叠「原始报文」(请求与响应均可见)。 +- 失败:人话诊断(口径见 §5.2)+「复制错误」按钮 + 可折叠「原始请求」「原始响应」。 + +**重跑**:「重测」用原图再来一次(适合限流后稍候重试);「换图」重新打开文件选择器,选完同样自动开跑。 + +**预览与限制**:选中图片显示缩略图预览;图片超过 **10MB** 或**非图片文件**拒绝并提示(避免超大 base64 拖垮界面、无效调用浪费)。 + +## 5. 行为契约 + +### 5.1 同源保证(本 PR 的核心主张) + +测试请求与真实识图链路**共享同一提示词、请求体构造与端点构造**。真实链路仅做等价重构(行为零变化,由现有测试兜底),测试链路为新增的单图调用。测试口径 = 真实口径。 + +### 5.2 失败分类与通俗翻译 + +| 情况 | 用户看到 | +|---|---| +| 成功 | ✅ 识别成功(耗时 {秒}) | +| 认证失败(401/403) | ❌ 认证失败(HTTP {码}):API Key 或模型名可能不正确 | +| 接口不存在(404) | ❌ 接口不存在:Base URL 或模型名有误(HTTP 404) | +| 被限流(429) | ❌ 被限流,稍后再试(HTTP 429) | +| 其他 HTTP 错误 | ❌ 服务返回错误(HTTP {码}) | +| 超时 | ❌ 请求超时:VLM 响应过慢或网络不通 | +| 发送失败 | ❌ 发送失败:网络错误,检查 Base URL 是否可达 | +| 返回非有效 JSON | ❌ 返回内容解析失败:上游返回的不是有效 JSON | +| 返回中无描述文本 | ❌ 返回中未找到描述文本:模型可能不支持视觉或返回格式异常 | +| 本地客户端构建失败 | ❌ HTTP 客户端构建失败 | + +### 5.3 错误判定顺序 + +先读取响应体、再判定 HTTP 状态码:**非 2xx 一律按 HTTP 错误上报**(带状态码与响应体片段)。上游返回 HTML 错误页(404 着陆页、网关 502 页)时不再被误报为"解析失败"。 + +### 5.4 安全 + +- 展示与复制的原始报文**永不包含 API Key**(Key 仅存在于请求头,请求体天然不含)。 +- 日志不记录 Key。 +- 上传的测试图片仅用于本次调用,不落盘、不缓存、不进入任何对话。 + +## 6. 多语言 + +所有新增界面文案中英双语。i18n 校验采用机械口径:本 PR 新增的全部键位零缺失、零冗余(`node tools/i18n-verify.mjs` 的缺失/冗余清单与本 PR 键集不相交);上游 main 既有词典漂移(DreamSkin/Sub2API 等历史欠账)不属于本 PR 范围,留待上游单独清理。 + +## 7. 测试与验收 + +### 7.1 自动化 + +- **后端**:按 §5.2 分类逐类覆盖(成功、401、404、429、其他 HTTP、超时、发送失败、非 JSON、无描述文本);HTML 错误页场景(验证 §5.3 判定顺序);同源等价(测试请求体与真实链路构造一致)。 +- **前端**:通俗翻译映射为纯函数,单测逐类覆盖(含 401/403 归一为模板文案);类型检查零错误。 +- 三平台 CI 全绿。 + +### 7.2 手工验收清单 + +1. 真实 VLM 配置 → 识别成功,描述可读,耗时合理。 +2. 依次改错 Key / 改错 Base URL / 改错模型名 → 对应诊断文案出现。 +3. 表单**未保存**状态下测试 → 用的是当前输入值。 +4. 超过 10MB 的图片 → 拒绝并提示。 +5. 「复制错误」→ 粘贴出来包含诊断与原始报文、且无 Key。 + +## 8. 交付与 PR 策略 + +- worktree `CodexPlusPlus-VLMtest`,分支 `vlm-test-entry`,基线 `95abcf6`(上游 main 祖先,见文首说明);从 fork 发 PR。 +- **小而聚焦**:功能纯增量;对现有代码仅有等价重构(提示词/请求体构造/端点构造提取共享),不夹带任何其他改动。 +- PR 描述:强调「与真实链路同源」契约、失败分类口径与手工验收结果。 +- 本设计文档随分支提交。 + +## 关联 + +- 上下游:main 的 VLM 视觉分析能力(#1405 引入,#1849 完善 Responses 协议注入) diff --git a/scripts/installer/macos/package-dmg.sh b/scripts/installer/macos/package-dmg.sh index 33e9a3d5d..120c8ea60 100755 --- a/scripts/installer/macos/package-dmg.sh +++ b/scripts/installer/macos/package-dmg.sh @@ -227,25 +227,46 @@ then echo "warning: unable to persist Finder DMG window layout; the background is still included" >&2 fi -if ! hdiutil detach "$MOUNT_POINT" >/dev/null; then - sleep 1 - hdiutil detach "$MOUNT_POINT" -force >/dev/null +# GitHub macOS runner 上 Finder 刚完成窗口布局,卷可能仍被短暂占用 +# (Resource busy);且优雅 detach 失败也可能已触发延迟弹出,后续重试会报 +# No such file or directory(卷已消失,应视为成功)。退避重试后仍失败才 +# -force;-force 后卷已消失同样视为成功。 +detach_volume() { + local attempt + for attempt in 1 2 3; do + if hdiutil detach "$MOUNT_POINT" >/dev/null; then + return 0 + fi + if [ ! -e "$MOUNT_POINT" ]; then + return 0 + fi + sleep "$((attempt * 2))" + done + hdiutil detach "$MOUNT_POINT" -force >/dev/null 2>&1 + [ ! -e "$MOUNT_POINT" ] +} + +if ! detach_volume; then + echo "error: failed to detach DMG volume $MOUNT_POINT" >&2 + exit 1 fi MOUNT_POINT="" -for attempt in 1 2 3; do +# 上一步 detach 可能触发延迟弹出:卷目录已消失但磁盘镜像仍在弹出中, +# convert 会暂时报 Resource temporarily unavailable——退避重试等它完成。 +for attempt in 1 2 3 4 5; do if hdiutil convert "$DMG_WORK_PATH" -format UDZO -ov -o "$DMG"; then DMG_CREATED=true break fi - if [ "$attempt" -lt 3 ]; then - sleep "$((attempt * 2))" + if [ "$attempt" -lt 5 ]; then + sleep "$((attempt * 3))" fi done if [ "$DMG_CREATED" != true ]; then - echo "error: failed to create DMG after 3 attempts" >&2 + echo "error: failed to create DMG after 5 attempts" >&2 exit 1 fi diff --git a/tools/i18n-verify.mjs b/tools/i18n-verify.mjs index d18a8c68d..82ae021d6 100644 --- a/tools/i18n-verify.mjs +++ b/tools/i18n-verify.mjs @@ -22,6 +22,7 @@ const ts = require("typescript"); const SRC_FILES = [ "src/App.tsx", "src/components/ProviderPresetSelector.tsx", + "src/vlm-test-translation.ts", ]; // ── Collect the keys referenced by t()/tf() across the source. ────────────── @@ -40,8 +41,9 @@ function scanFile(relPath) { const literal = arg && (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) ? arg.text : null; if (literal !== null) { - if (fn === "t") usedPlain.add(literal); - else if (fn === "tf") usedTemplate.add(literal); + // tr 是 vlm-test-translation.ts 注入的翻译回调:1 参->plain,2 参->template。 + if (fn === "t" || (fn === "tr" && node.arguments.length === 1)) usedPlain.add(literal); + else if (fn === "tf" || (fn === "tr" && node.arguments.length >= 2)) usedTemplate.add(literal); } } ts.forEachChild(node, visit);