Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions apps/codex-plus-manager/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u16>,
pub duration_ms: u64,
pub error: Option<String>,
pub description: Option<String>,
pub model: String,
pub raw_request: Option<String>,
pub raw_response: Option<String>,
}

/// 用表单当前 VLM 配置 + 用户上传图片(data URL)测试 VLM 可用性。
/// 用表单当前值(未保存亦可);失败也返回结构化 payload 供前端渲染诊断。
#[tauri::command]
pub async fn test_vlm(request: TestVlmRequest) -> CommandResult<TestVlmResult> {
// 加固 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<T: Serialize>(message: &str, payload: T) -> CommandResult<T> {
CommandResult {
status: "ok".to_string(),
Expand Down
1 change: 1 addition & 0 deletions apps/codex-plus-manager/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
214 changes: 214 additions & 0 deletions apps/codex-plus-manager/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -8692,6 +8694,17 @@ function RelayProfileEditor({
{modelWindowRows.some((row) => row.imageHandling === "vlm") && (!profile.vlmApiKey || !profile.vlmModel || !profile.vlmBaseUrl) ? (
<p className="field-hint warn">{t("VLM 配置不完整:API Key、Model 和 Base URL 为必填项,否则 VLM 不会生效。")}</p>
) : null}
<div className="vlm-test-entry">
<Button
onClick={() => setVlmTestOpen((v) => !v)}
size="sm"
type="button"
variant="secondary"
>
{vlmTestOpen ? t("收起测试面板") : t("测试 VLM")}
</Button>
</div>
{vlmTestOpen ? <VlmTestPanel profile={profile} onClose={() => setVlmTestOpen(false)} /> : null}
</div>
) : null}
{showApiFields ? (
Expand Down Expand Up @@ -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<RelayProfile, "vlmApiKey" | "vlmModel" | "vlmBaseUrl">;
onClose: () => void;
}) {
const [dataUrl, setDataUrl] = useState<string | null>(null);
const [state, setState] = useState<VlmTestState>({ kind: "idle" });
const [showRaw, setShowRaw] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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<TestVlmResult>("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 (
<div className="vlm-test-panel">
<div className="modal-head">
<div>
<h2>{t("测试 VLM")}</h2>
<p className="modal-message">
{t("选一张图片立即验证当前 VLM 配置(使用表单当前值,无需保存)。")}
</p>
</div>
<button className="toast-close" aria-label={t("关闭窗口")} onClick={onClose} type="button">×</button>
</div>

<div className="vlm-test-upload">
<input
ref={fileInputRef}
accept="image/*"
onChange={(e) => {
onFile(e.currentTarget.files?.[0]);
e.currentTarget.value = "";
}}
type="file"
style={{ display: "none" }}
/>
<Button disabled={running || !formReady} onClick={() => fileInputRef.current?.click()} size="sm" type="button" variant="secondary">
{dataUrl ? t("换图并测试") : t("选择图片并测试")}
</Button>
{dataUrl ? <img alt={t("图片预览")} className="vlm-test-preview" src={dataUrl} /> : null}
{running ? (
<p className="vlm-test-running">
<span className="vlm-test-spinner" aria-hidden="true" />
{t("正在调用 VLM…")}
</p>
) : null}
</div>

{localError ? <p className="field-hint warn">{localError}</p> : null}

{done ? (
<div className="vlm-test-result">
<p className="vlm-test-summary" role="status">
{vlmTestTranslation(done.vlmStatus, done.httpCode ?? undefined, done.durationMs, tr)}
</p>
{done.description ? (
<pre className="vlm-test-description">{done.description}</pre>
) : null}
{done.vlmStatus !== "ok" ? (
<button className="vlm-test-detail-toggle" onClick={() => void copyError()} type="button">
{t("复制错误")}
</button>
) : null}
<button
className="vlm-test-detail-toggle"
aria-expanded={showRaw}
onClick={() => setShowRaw((v) => !v)}
type="button"
>
{showRaw ? t("隐藏原始报文") : t("显示原始报文")}
</button>
{showRaw ? (
<div className="vlm-test-raw">
<div className="label">{t("原始请求")}</div>
<pre className="vlm-test-description">{done.rawRequest ?? "-"}</pre>
<div className="label">{t("原始响应")}</div>
<pre className="vlm-test-description">{done.rawResponse ?? "-"}</pre>
</div>
) : null}
</div>
) : null}

<Toolbar>
{dataUrl ? (
<Button disabled={!canRun || running} onClick={() => dataUrl && void runTest(dataUrl)} type="button">
{t("重测")}
</Button>
) : null}
<Button onClick={onClose} type="button" variant="secondary">
{t("收起")}
</Button>
</Toolbar>
</div>
);
}

function AggregateRelayProfileEditor({
profile,
form,
Expand Down
Loading
Loading