feat(jimeng-ui): record Jimeng connections via sites/accounts (方案甲)
Onboard Jimeng (火山视觉) as a `platform = "jimeng"` entry in the existing sites/accounts console, reusing the CRUD/list/status UI while branching every OpenAI-specific path so no other platform is affected. Jimeng is not OpenAI-compatible (Volcengine V4 HMAC signing, async two-step generation, no `/models` endpoint), so each branch keys off the single source of truth `routes::admin::accounts::is_jimeng_platform` (case-insensitive). Backend (src/routes/admin, src/services/jimeng): - create_account → create_jimeng_account: AK lands in accounts.access_token; SK + region/host/sessionToken nest under extra_config.jimeng.*; credentialMode forced to "apikey". No spawn_route_rebuild — Jimeng never enters the route table. - Secret protection: SK/sessionToken masked with •••• on list/get/update responses (mask_account_extra_config); update_account rejects a masked extra_config write-back so an edit round-trip can't clobber the real secret. - verify_token → verify_jimeng_token: signs a sentinel GetResult probe via JimengProvider::from_credentials(...).verify(). The Volcengine gateway checks the V4 signature before the business layer, so a valid AK/SK is Authenticated (business layer reports "not found") and a bad one yields an auth-class Code (classify_verify_response / is_auth_error_code). Never calls /models, never triggers generation. - build_probe_plan short-circuits Jimeng (probe-now/-stream) with a BadRequest pointing operators at the connection "验证" flow. - runtimeHealth: Jimeng has no proxy_logs, so derive_jimeng_runtime_health reads the latest jimeng background_tasks row; account_response_value_with_jimeng applies it only when the site is Jimeng and ignores the override otherwise. Frontend (src/web): - Sites.tsx: add jimeng platform option; prefill visual.volcengineapi.com; hide URL pool + auto-probe sections. - Accounts.tsx: AK + SK(required) + region/host/session fields under the API Key segment; "验证 AK/SK 签名" gates add; skip model sync / route rebuild. - api.ts: verify-token payload carries secretKey/region/host/sessionToken. Tests: unit coverage for credential nesting, secret masking, verify-response classification, and regression guards proving non-Jimeng accounts ignore the Jimeng health override and keep their proxy-log-derived health. Also: stop tracking the stray root package-lock.json (repo installs via pnpm) and gitignore it.
This commit is contained in:
@@ -1861,6 +1861,12 @@ export const api = {
|
||||
accessToken: string;
|
||||
platformUserId?: number;
|
||||
credentialMode?: "auto" | "session" | "apikey";
|
||||
// Jimeng connections (platform === "jimeng") carry a Volcengine AK/SK pair
|
||||
// plus optional region/host/session token instead of a single Bearer token.
|
||||
secretKey?: string;
|
||||
region?: string;
|
||||
host?: string;
|
||||
sessionToken?: string;
|
||||
}) =>
|
||||
request("/api/accounts/verify-token", {
|
||||
method: "POST",
|
||||
|
||||
+149
-13
@@ -94,6 +94,12 @@ function createTokenForm(credentialMode: "session" | "apikey" = "session") {
|
||||
tokenExpiresAt: "",
|
||||
credentialMode,
|
||||
skipModelFetch: false,
|
||||
// Jimeng connections (platform === "jimeng") carry a Volcengine AK/SK pair
|
||||
// plus optional region/host/session token. accessToken doubles as the AK.
|
||||
jimengSecretKey: "",
|
||||
jimengRegion: "",
|
||||
jimengHost: "",
|
||||
jimengSessionToken: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -290,6 +296,8 @@ export default function Accounts() {
|
||||
);
|
||||
const isSub2ApiSelected =
|
||||
(selectedTokenSite?.platform || "").toLowerCase() === "sub2api";
|
||||
const isJimengSelected =
|
||||
(selectedTokenSite?.platform || "").toLowerCase() === "jimeng";
|
||||
const activeAddCredentialMode =
|
||||
activeSegment === "apikey" ? "apikey" : "session";
|
||||
const createIntentPreset = useMemo(
|
||||
@@ -456,7 +464,7 @@ export default function Accounts() {
|
||||
|
||||
const handleVerifyToken = async () => {
|
||||
if (!tokenForm.siteId || !tokenForm.accessToken) return;
|
||||
if (isBatchApiKeyInput) {
|
||||
if (!isJimengSelected && isBatchApiKeyInput) {
|
||||
toast.info(
|
||||
`检测到 ${parsedApiKeys.length} 个 API Key,批量模式会在添加时逐条校验`,
|
||||
);
|
||||
@@ -473,10 +481,20 @@ export default function Accounts() {
|
||||
? parseInt(tokenForm.platformUserId)
|
||||
: undefined,
|
||||
credentialMode,
|
||||
...(isJimengSelected
|
||||
? {
|
||||
secretKey: tokenForm.jimengSecretKey.trim() || undefined,
|
||||
region: tokenForm.jimengRegion.trim() || undefined,
|
||||
host: tokenForm.jimengHost.trim() || undefined,
|
||||
sessionToken: tokenForm.jimengSessionToken.trim() || undefined,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
setVerifyResult(result);
|
||||
if (result.success) {
|
||||
if (result.tokenType === "apikey") {
|
||||
if (result.tokenType === "jimeng") {
|
||||
toast.success("即梦签名验证成功(AK/SK 可用)");
|
||||
} else if (result.tokenType === "apikey") {
|
||||
toast.success(
|
||||
`API Key 验证成功(可用模型 ${result.modelCount || 0} 个)`,
|
||||
);
|
||||
@@ -500,7 +518,12 @@ export default function Accounts() {
|
||||
|
||||
const handleTokenAdd = async () => {
|
||||
if (!tokenForm.siteId || !tokenForm.accessToken) return;
|
||||
if (isJimengSelected && !verifyResult?.success) {
|
||||
toast.error("请先验证即梦 AK/SK 成功后再添加连接");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isJimengSelected &&
|
||||
!isBatchApiKeyInput &&
|
||||
!verifyResult?.success &&
|
||||
!tokenForm.skipModelFetch
|
||||
@@ -516,7 +539,8 @@ export default function Accounts() {
|
||||
siteId: tokenForm.siteId,
|
||||
username: tokenForm.username.trim() || undefined,
|
||||
accessToken: tokenForm.accessToken,
|
||||
accessTokens: isBatchApiKeyInput ? parsedApiKeys : undefined,
|
||||
accessTokens:
|
||||
!isJimengSelected && isBatchApiKeyInput ? parsedApiKeys : undefined,
|
||||
platformUserId: tokenForm.platformUserId
|
||||
? parseInt(tokenForm.platformUserId)
|
||||
: undefined,
|
||||
@@ -530,7 +554,23 @@ export default function Accounts() {
|
||||
: undefined,
|
||||
credentialMode,
|
||||
skipModelFetch: tokenForm.skipModelFetch,
|
||||
...(isJimengSelected
|
||||
? {
|
||||
secretKey: tokenForm.jimengSecretKey.trim() || undefined,
|
||||
region: tokenForm.jimengRegion.trim() || undefined,
|
||||
host: tokenForm.jimengHost.trim() || undefined,
|
||||
sessionToken: tokenForm.jimengSessionToken.trim() || undefined,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
// Jimeng connections never join the OpenAI route table: no model sync,
|
||||
// no route rebuild. Just close, toast, refresh and bail.
|
||||
if (isJimengSelected) {
|
||||
closeAddPanel();
|
||||
toast.success("即梦连接已添加(AK/SK 已保存,SK 已掩码)");
|
||||
load(true);
|
||||
return;
|
||||
}
|
||||
const createdAccountIds = result?.batch
|
||||
? Array.isArray(result?.data)
|
||||
? result.data
|
||||
@@ -1510,14 +1550,18 @@ export default function Accounts() {
|
||||
|
||||
const canAddVerifiedConnection = Boolean(
|
||||
verifyResult?.success &&
|
||||
((activeSegment === "apikey" && verifyResult.tokenType === "apikey") ||
|
||||
((activeSegment === "apikey" &&
|
||||
(verifyResult.tokenType === "apikey" ||
|
||||
verifyResult.tokenType === "jimeng")) ||
|
||||
(activeSegment === "session" && verifyResult.tokenType === "session")),
|
||||
);
|
||||
const canSubmitApiKeyConnection =
|
||||
activeSegment === "apikey"
|
||||
? isBatchApiKeyInput ||
|
||||
canAddVerifiedConnection ||
|
||||
!!tokenForm.skipModelFetch
|
||||
? isJimengSelected
|
||||
? canAddVerifiedConnection
|
||||
: isBatchApiKeyInput ||
|
||||
canAddVerifiedConnection ||
|
||||
!!tokenForm.skipModelFetch
|
||||
: canAddVerifiedConnection;
|
||||
const accountsListBusy =
|
||||
!loaded ||
|
||||
@@ -2449,7 +2493,11 @@ export default function Accounts() {
|
||||
style={inputStyle}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="粘贴 API Key"
|
||||
placeholder={
|
||||
isJimengSelected
|
||||
? "粘贴 Access Key(AK)"
|
||||
: "粘贴 API Key"
|
||||
}
|
||||
value={tokenForm.accessToken}
|
||||
onChange={(e) => {
|
||||
setTokenForm((f) => ({
|
||||
@@ -2466,7 +2514,67 @@ export default function Accounts() {
|
||||
resize: "none" as const,
|
||||
}}
|
||||
/>
|
||||
{parsedApiKeys.length > 0 && (
|
||||
{isJimengSelected && (
|
||||
<>
|
||||
<input
|
||||
placeholder="Secret Key(SK,必填)"
|
||||
type="password"
|
||||
value={tokenForm.jimengSecretKey}
|
||||
onChange={(e) => {
|
||||
setTokenForm((f) => ({
|
||||
...f,
|
||||
jimengSecretKey: e.target.value,
|
||||
credentialMode: "apikey",
|
||||
}));
|
||||
setVerifyResult(null);
|
||||
}}
|
||||
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<input
|
||||
placeholder="region(默认 cn-north-1)"
|
||||
value={tokenForm.jimengRegion}
|
||||
onChange={(e) =>
|
||||
setTokenForm((f) => ({
|
||||
...f,
|
||||
jimengRegion: e.target.value.trim(),
|
||||
}))
|
||||
}
|
||||
style={{ ...inputStyle, flex: 1, minWidth: 160 }}
|
||||
/>
|
||||
<input
|
||||
placeholder="host(默认 visual.volcengineapi.com)"
|
||||
value={tokenForm.jimengHost}
|
||||
onChange={(e) =>
|
||||
setTokenForm((f) => ({
|
||||
...f,
|
||||
jimengHost: e.target.value.trim(),
|
||||
}))
|
||||
}
|
||||
style={{ ...inputStyle, flex: 1, minWidth: 160 }}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
placeholder="session token(STS,可选)"
|
||||
value={tokenForm.jimengSessionToken}
|
||||
onChange={(e) =>
|
||||
setTokenForm((f) => ({
|
||||
...f,
|
||||
jimengSessionToken: e.target.value.trim(),
|
||||
}))
|
||||
}
|
||||
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
|
||||
/>
|
||||
<div
|
||||
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
|
||||
>
|
||||
即梦走火山 V4 签名(非 OpenAI 协议):填 AK/SK
|
||||
后点「验证」做签名连通性探测,不会调用 /models,也不进路由表。SK
|
||||
保存后以 •••• 掩码回显。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isJimengSelected && parsedApiKeys.length > 0 && (
|
||||
<div
|
||||
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
|
||||
>
|
||||
@@ -2476,9 +2584,12 @@ export default function Accounts() {
|
||||
: ""}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
支持换行、空格、逗号批量粘贴多个 API Key。
|
||||
</div>
|
||||
{!isJimengSelected && (
|
||||
<div style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
支持换行、空格、逗号批量粘贴多个 API Key。
|
||||
</div>
|
||||
)}
|
||||
{!isJimengSelected && (
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
@@ -2501,6 +2612,8 @@ export default function Accounts() {
|
||||
若站点要求 New-Api-User / User-ID,请在这里提前填写。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isJimengSelected && (
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -2524,6 +2637,25 @@ export default function Accounts() {
|
||||
/>
|
||||
<span>跳过模型验证(直接添加 API Key)</span>
|
||||
</label>
|
||||
)}
|
||||
{verifyResult &&
|
||||
verifyResult.success &&
|
||||
verifyResult.tokenType === "jimeng" && (
|
||||
<div className="alert alert-info animate-scale-in">
|
||||
<div className="alert-title">
|
||||
即梦签名验证成功(AK/SK 可用)
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--color-text-muted)",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
火山网关已接受 V4 签名。保存后 SK 将以 •••• 掩码回显。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{verifyResult &&
|
||||
verifyResult.success &&
|
||||
verifyResult.tokenType === "apikey" && (
|
||||
@@ -2610,7 +2742,9 @@ export default function Accounts() {
|
||||
verifying ||
|
||||
!tokenForm.siteId ||
|
||||
!tokenForm.accessToken ||
|
||||
isBatchApiKeyInput
|
||||
(isJimengSelected
|
||||
? !tokenForm.jimengSecretKey.trim()
|
||||
: isBatchApiKeyInput)
|
||||
}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
@@ -2623,6 +2757,8 @@ export default function Accounts() {
|
||||
<span className="spinner spinner-sm" />
|
||||
验证中...
|
||||
</>
|
||||
) : isJimengSelected ? (
|
||||
"验证 AK/SK 签名"
|
||||
) : isBatchApiKeyInput ? (
|
||||
"批量添加时校验"
|
||||
) : (
|
||||
|
||||
+19
-2
@@ -707,6 +707,7 @@ const SITE_PLATFORM_OPTIONS = [
|
||||
{ value: 'gemini', label: 'gemini', description: '通用 Gemini / Google AI 兼容接口' },
|
||||
{ value: 'cliproxyapi', label: 'cliproxyapi', description: 'CPA接入口' },
|
||||
{ value: 'comfy_cloud', label: 'Comfy Cloud', description: '工作流执行 API(数字人/图片视频)' },
|
||||
{ value: 'jimeng', label: '即梦 (火山视觉)', description: '火山引擎即梦 AIGC,V4 签名调用,异步生成(无 /models 探测)' },
|
||||
];
|
||||
|
||||
export default function Sites() {
|
||||
@@ -1942,7 +1943,21 @@ export default function Sites() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
setForm((prev) => ({ ...prev, platform: value }));
|
||||
setForm((prev) => {
|
||||
// Jimeng's real requests carry Action/Version query params the
|
||||
// URL field can't express; prefill the Volcengine endpoint as a
|
||||
// placeholder when the field is empty so operators aren't asked
|
||||
// to invent one.
|
||||
if (value === 'jimeng') {
|
||||
const currentUrl = prev.url.trim();
|
||||
return {
|
||||
...prev,
|
||||
platform: value,
|
||||
url: currentUrl ? prev.url : 'https://visual.volcengineapi.com',
|
||||
};
|
||||
}
|
||||
return { ...prev, platform: value };
|
||||
});
|
||||
setSelectedInitializationPresetId(null);
|
||||
}}
|
||||
options={platformOptions}
|
||||
@@ -1995,6 +2010,7 @@ export default function Sites() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{form.platform.trim() !== 'jimeng' && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -2086,6 +2102,7 @@ export default function Sites() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -2288,7 +2305,7 @@ export default function Sites() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
{isEditing && form.platform.trim() !== 'jimeng' && (
|
||||
<div style={{ marginTop: 16, padding: '14px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>刷新后自动测试请求</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
||||
|
||||
Reference in New Issue
Block a user