403 lines
14 KiB
TypeScript
403 lines
14 KiB
TypeScript
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||
import { dirname, join } from "node:path";
|
||
import { getEvolinkImageSettings, getSelectedImageEngine, shouldMockEvolinkApi, type ImageCreationEngine } from "@/lib/evolink/image-client";
|
||
import { authConfigSummary, getAuthRuntimeConfig } from "@/lib/auth/config";
|
||
import { getJimengCapabilities } from "@/lib/jimeng/capabilities";
|
||
import { getSeedanceConfig, shouldMockSeedance } from "@/lib/seedance/client";
|
||
import { platformAccountStoreConfigured } from "@/lib/server/account-store";
|
||
import { rootDir } from "@/lib/server/runtime";
|
||
import { shouldMockVisualApi } from "@/lib/volcengine/visual-client";
|
||
import type { EnabledImageCapability } from "@/lib/types";
|
||
import { getBailianConfig, shouldMockBailian } from "@/lib/bailian/client";
|
||
|
||
export type SettingsField = {
|
||
key: string;
|
||
label: string;
|
||
description?: string;
|
||
secret?: boolean;
|
||
type?: "text" | "password" | "number" | "select";
|
||
options?: Array<{ label: string; value: string }>;
|
||
value: string;
|
||
configured: boolean;
|
||
};
|
||
|
||
export type SettingsGroup = {
|
||
id: string;
|
||
title: string;
|
||
description: string;
|
||
fields: SettingsField[];
|
||
};
|
||
|
||
export type EngineAssignment = {
|
||
id: string;
|
||
label: string;
|
||
engine: string;
|
||
engineLabel: string;
|
||
connected: boolean;
|
||
connectionLabel: string;
|
||
reqKey: string;
|
||
configurable: boolean;
|
||
field?: SettingsField;
|
||
};
|
||
|
||
type FieldDefinition = Omit<SettingsField, "value" | "configured"> & {
|
||
defaultValue?: string;
|
||
};
|
||
|
||
const settingDefinitions: Array<{
|
||
id: string;
|
||
title: string;
|
||
description: string;
|
||
fields: FieldDefinition[];
|
||
}> = [
|
||
{
|
||
id: "auth",
|
||
title: "平台账号安全",
|
||
description: "平台自建手机号账号登录;密码哈希只保存在服务端,登录会话使用 HttpOnly 签名 Cookie。",
|
||
fields: [
|
||
{
|
||
key: "ZHINIAN_AUTH_REQUIRED",
|
||
label: "登录保护",
|
||
type: "select",
|
||
defaultValue: "auto",
|
||
options: [
|
||
{ label: "自动", value: "auto" },
|
||
{ label: "启用", value: "1" },
|
||
{ label: "停用", value: "0" }
|
||
]
|
||
},
|
||
{ key: "ZHINIAN_AUTH_SESSION_SECRET", label: "会话签名密钥", secret: true, type: "password" }
|
||
]
|
||
},
|
||
{
|
||
id: "billing",
|
||
title: "企业计费",
|
||
description: "管理真实任务计费开关和成员线下转账时看到的对公账户信息。",
|
||
fields: [
|
||
{
|
||
key: "ZHINIAN_BILLING_REQUIRED",
|
||
label: "真实任务计费",
|
||
type: "select",
|
||
defaultValue: "1",
|
||
options: [
|
||
{ label: "启用", value: "1" },
|
||
{ label: "停用(免计费)", value: "0" }
|
||
]
|
||
},
|
||
{ key: "ZHINIAN_BILLING_ACCOUNT_NAME", label: "对公账户名称" },
|
||
{ key: "ZHINIAN_BILLING_ACCOUNT_BANK", label: "开户行" },
|
||
{ key: "ZHINIAN_BILLING_ACCOUNT_NUMBER", label: "银行账号" },
|
||
{ key: "ZHINIAN_BILLING_CONTACT", label: "充值对接信息" }
|
||
]
|
||
},
|
||
{
|
||
id: "visual",
|
||
title: "即梦图片 API",
|
||
description: "只需填写火山 AK/SK;Region、Endpoint 和 req_key 使用系统默认值。",
|
||
fields: [
|
||
{ key: "VOLCENGINE_ACCESS_KEY_ID", label: "Access Key ID", secret: true, type: "password" },
|
||
{ key: "VOLCENGINE_SECRET_ACCESS_KEY", label: "Secret Access Key", secret: true, type: "password" }
|
||
]
|
||
},
|
||
{
|
||
id: "evolink",
|
||
title: "EvoLink 图片 API",
|
||
description: "用于 GPT Image 2 图片生成;Base URL 和模型使用系统默认值即可。",
|
||
fields: [
|
||
{ key: "EVOLINK_API_KEY", label: "EvoLink API Key", secret: true, type: "password" },
|
||
{ key: "EVOLINK_BASE_URL", label: "Base URL", defaultValue: "https://api.evolink.ai" },
|
||
{ key: "EVOLINK_IMAGE_MODEL", label: "图片模型", defaultValue: "gpt-image-2" },
|
||
{ key: "EVOLINK_IMAGE_QUALITY", label: "质量", defaultValue: "medium" }
|
||
]
|
||
},
|
||
{
|
||
id: "seedance",
|
||
title: "Seedance 视频 API",
|
||
description: "只需填写火山方舟 API Key;Base URL、模型和默认参数使用系统默认值。",
|
||
fields: [
|
||
{ key: "SEEDANCE_API_KEY", label: "方舟 API Key", secret: true, type: "password" }
|
||
]
|
||
},
|
||
{
|
||
id: "bailian",
|
||
title: "阿里云百炼 API",
|
||
description: "用于万相 2.7 文生图、参考图生图和图生视频。Base URL 会自动派生原生异步接口。",
|
||
fields: [
|
||
{ key: "BAILIAN_API_KEY", label: "百炼 API Key", secret: true, type: "password" },
|
||
{ key: "BAILIAN_BASE_URL", label: "Base URL", defaultValue: "https://llm-126wneubbdo6dbr5.cn-beijing.maas.aliyuncs.com/compatible-mode/v1" },
|
||
{ key: "BAILIAN_IMAGE_MODEL", label: "图片模型", defaultValue: "wan2.7-image-pro" },
|
||
{ key: "BAILIAN_VIDEO_MODEL", label: "视频模型", defaultValue: "wan2.7-i2v-2026-04-25" }
|
||
]
|
||
},
|
||
{
|
||
id: "oss",
|
||
title: "OSS 资产存储",
|
||
description: "用于上传素材、生成图和视频结果的公网转存;对象前缀使用默认值。",
|
||
fields: [
|
||
{ key: "ALI_OSS_ENDPOINT", label: "Endpoint" },
|
||
{ key: "ALI_OSS_BUCKET", label: "Bucket" },
|
||
{ key: "ALI_OSS_ACCESS_KEY_ID", label: "Access Key ID", secret: true, type: "password" },
|
||
{ key: "ALI_OSS_ACCESS_KEY_SECRET", label: "Access Key Secret", secret: true, type: "password" },
|
||
{ key: "ALI_OSS_PUBLIC_BASE_URL", label: "公开访问 Base URL" }
|
||
]
|
||
}
|
||
];
|
||
|
||
const engineFieldDefinitions: FieldDefinition[] = [
|
||
{
|
||
key: "IMAGE_GENERATE_ENGINE",
|
||
label: "图片生成",
|
||
type: "select",
|
||
defaultValue: "jimeng",
|
||
options: [
|
||
{ label: "即梦 / 火山视觉", value: "jimeng" },
|
||
{ label: "EvoLink GPT Image 2", value: "evolink" },
|
||
{ label: "阿里云百炼 Wan 2.7", value: "bailian" }
|
||
]
|
||
},
|
||
{
|
||
key: "VIDEO_GENERATE_ENGINE",
|
||
label: "视频生成",
|
||
type: "select",
|
||
defaultValue: "bailian",
|
||
options: [
|
||
{ label: "Seedance", value: "seedance" },
|
||
{ label: "阿里云百炼 Wan 2.7", value: "bailian" }
|
||
]
|
||
},
|
||
];
|
||
|
||
const allowedKeys = new Set([
|
||
...settingDefinitions.flatMap((group) => group.fields.map((field) => field.key)),
|
||
...engineFieldDefinitions.map((field) => field.key)
|
||
]);
|
||
|
||
export async function getApiSettings() {
|
||
const fileEnv = await readEnvFile();
|
||
const groups: SettingsGroup[] = settingDefinitions.map((group) => ({
|
||
...group,
|
||
fields: group.fields.map((field) => {
|
||
const rawValue = currentValue(field, fileEnv);
|
||
return {
|
||
...field,
|
||
value: field.secret ? "" : rawValue,
|
||
configured: Boolean(rawValue)
|
||
};
|
||
})
|
||
}));
|
||
const seedance = getSeedanceConfig();
|
||
const auth = getAuthRuntimeConfig();
|
||
const engineAssignments = buildEngineAssignments(fileEnv);
|
||
return {
|
||
services: {
|
||
visual: !shouldMockVisualApi(),
|
||
evolink: !shouldMockEvolinkApi(),
|
||
seedance: !shouldMockSeedance(),
|
||
bailian: !shouldMockBailian() && Boolean(getBailianConfig().apiKey),
|
||
auth: authConfigSummary(auth) === "configured",
|
||
organization: platformAccountStoreConfigured()
|
||
},
|
||
capabilities: [
|
||
...Object.values(getJimengCapabilities()).map((capability) => {
|
||
const assignment = engineAssignments.find((item) => item.id === capability.id);
|
||
return {
|
||
id: capability.id,
|
||
label: capability.label,
|
||
reqKey: assignment?.reqKey || capability.reqKey,
|
||
engine: assignment?.engine,
|
||
engineLabel: assignment?.engineLabel,
|
||
enabled: capability.enabled
|
||
};
|
||
}),
|
||
{
|
||
id: "video.generate",
|
||
label: "视频生成",
|
||
reqKey: engineAssignments.find((item) => item.id === "video.generate")?.reqKey || seedance.model,
|
||
engine: engineAssignments.find((item) => item.id === "video.generate")?.engine || "seedance",
|
||
engineLabel: engineAssignments.find((item) => item.id === "video.generate")?.engineLabel || "Seedance",
|
||
enabled: true
|
||
}
|
||
],
|
||
engineAssignments,
|
||
groups
|
||
};
|
||
}
|
||
|
||
export async function saveApiSettings(values: Record<string, unknown>) {
|
||
const updates = new Map<string, string>();
|
||
for (const field of [...settingDefinitions.flatMap((group) => group.fields), ...engineFieldDefinitions]) {
|
||
if (!allowedKeys.has(field.key)) continue;
|
||
const nextValue = values[field.key];
|
||
if (typeof nextValue !== "string") continue;
|
||
const normalized = nextValue.trim();
|
||
if (field.secret && !normalized) continue;
|
||
updates.set(field.key, normalized);
|
||
}
|
||
if (updates.size) {
|
||
await writeEnvFile(updates);
|
||
for (const [key, value] of updates) process.env[key] = value;
|
||
}
|
||
return getApiSettings();
|
||
}
|
||
|
||
function currentValue(field: FieldDefinition, fileEnv: Map<string, string>) {
|
||
return process.env[field.key] ?? fileEnv.get(field.key) ?? field.defaultValue ?? "";
|
||
}
|
||
|
||
function buildEngineAssignments(fileEnv: Map<string, string>): EngineAssignment[] {
|
||
const capabilities = getJimengCapabilities();
|
||
const evolink = getEvolinkImageSettings();
|
||
const seedance = getSeedanceConfig();
|
||
const bailian = getBailianConfig();
|
||
const generateEngine = currentEngineValue(engineFieldDefinitions[0], fileEnv);
|
||
const videoEngine = currentVideoEngineValue(engineFieldDefinitions[1], fileEnv);
|
||
const videoConnected = videoEngine === "bailian" ? !shouldMockBailian() : !shouldMockSeedance();
|
||
|
||
return [
|
||
imageEngineAssignment({
|
||
capability: "image.generate",
|
||
label: "图片生成",
|
||
engine: generateEngine,
|
||
reqKey: generateEngine === "bailian" ? bailian.imageModel : generateEngine === "evolink" ? evolink.model : capabilities["image.generate"].reqKey,
|
||
field: engineFieldDefinitions[0],
|
||
fileEnv
|
||
}),
|
||
{
|
||
id: "video.generate",
|
||
label: "视频生成",
|
||
engine: videoEngine,
|
||
engineLabel: videoEngine === "bailian" ? "阿里云百炼" : "Seedance",
|
||
connected: videoConnected,
|
||
connectionLabel: videoConnected ? "已连接" : "待配置",
|
||
reqKey: videoEngine === "bailian" ? bailian.videoModel : seedance.model,
|
||
configurable: true,
|
||
field: fieldWithValue(engineFieldDefinitions[1], videoEngine, isConfigured(engineFieldDefinitions[1].key, fileEnv))
|
||
}
|
||
];
|
||
}
|
||
|
||
function imageEngineAssignment(input: {
|
||
capability: EnabledImageCapability;
|
||
label: string;
|
||
engine: ImageCreationEngine;
|
||
reqKey: string;
|
||
field: FieldDefinition;
|
||
fileEnv: Map<string, string>;
|
||
}): EngineAssignment {
|
||
const connected = imageEngineConnected(input.engine);
|
||
return {
|
||
id: input.capability,
|
||
label: input.label,
|
||
engine: input.engine,
|
||
engineLabel: imageEngineLabel(input.engine),
|
||
connected,
|
||
connectionLabel: connected ? "已连接" : "待配置",
|
||
reqKey: input.reqKey,
|
||
configurable: true,
|
||
field: fieldWithValue(input.field, currentEngineValue(input.field, input.fileEnv), isConfigured(input.field.key, input.fileEnv))
|
||
};
|
||
}
|
||
|
||
function currentEngineValue(field: FieldDefinition, fileEnv: Map<string, string>): ImageCreationEngine {
|
||
const explicit = process.env[field.key] ?? fileEnv.get(field.key);
|
||
if (explicit) return normalizeImageEngine(explicit);
|
||
return normalizeImageEngine(process.env.IMAGE_CREATION_ENGINE ?? fileEnv.get("IMAGE_CREATION_ENGINE") ?? process.env.IMAGE_PROVIDER ?? fileEnv.get("IMAGE_PROVIDER") ?? field.defaultValue);
|
||
}
|
||
|
||
function fieldWithValue(field: FieldDefinition, value: string, configured: boolean): SettingsField {
|
||
return {
|
||
...field,
|
||
value,
|
||
configured
|
||
};
|
||
}
|
||
|
||
function isConfigured(key: string, fileEnv: Map<string, string>) {
|
||
return Boolean(process.env[key] ?? fileEnv.get(key));
|
||
}
|
||
|
||
function normalizeImageEngine(value: string | undefined): ImageCreationEngine {
|
||
const normalized = value?.trim().toLowerCase();
|
||
return normalized === "evolink" || normalized === "bailian" ? normalized : "jimeng";
|
||
}
|
||
|
||
function imageEngineLabel(engine: ImageCreationEngine) {
|
||
return engine === "evolink" ? "EvoLink" : engine === "bailian" ? "阿里云百炼" : "即梦";
|
||
}
|
||
|
||
function imageEngineConnected(engine: ImageCreationEngine) {
|
||
if (engine === "evolink") return !shouldMockEvolinkApi();
|
||
if (engine === "bailian") return !shouldMockBailian();
|
||
return !shouldMockVisualApi();
|
||
}
|
||
|
||
function currentVideoEngineValue(field: FieldDefinition, fileEnv: Map<string, string>) {
|
||
return (process.env[field.key] ?? fileEnv.get(field.key) ?? field.defaultValue) === "bailian" ? "bailian" : "seedance";
|
||
}
|
||
|
||
function envFilePath() {
|
||
return join(rootDir(), ".env.local");
|
||
}
|
||
|
||
async function readEnvFile() {
|
||
try {
|
||
return parseEnvText(await readFile(envFilePath(), "utf8"));
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return new Map<string, string>();
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function writeEnvFile(updates: Map<string, string>) {
|
||
const path = envFilePath();
|
||
let text = "";
|
||
try {
|
||
text = await readFile(path, "utf8");
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||
text = "# 智念AIGC平台 API 配置\n";
|
||
}
|
||
const seen = new Set<string>();
|
||
const lines = text.split(/\r?\n/).map((line) => {
|
||
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
||
if (!match) return line;
|
||
const key = match[1];
|
||
if (!updates.has(key)) return line;
|
||
seen.add(key);
|
||
return `${key}=${formatEnvValue(updates.get(key) || "")}`;
|
||
});
|
||
const missing = [...updates.entries()].filter(([key]) => !seen.has(key));
|
||
if (missing.length) {
|
||
if (lines.length && lines[lines.length - 1] !== "") lines.push("");
|
||
lines.push("# Managed by 智念AIGC平台 设置页");
|
||
for (const [key, value] of missing) lines.push(`${key}=${formatEnvValue(value)}`);
|
||
}
|
||
await mkdir(dirname(path), { recursive: true });
|
||
await writeFile(path, `${lines.join("\n").replace(/\n+$/, "")}\n`);
|
||
}
|
||
|
||
function parseEnvText(text: string) {
|
||
const env = new Map<string, string>();
|
||
for (const line of text.split(/\r?\n/)) {
|
||
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
|
||
if (!match) continue;
|
||
env.set(match[1], parseEnvValue(match[2]));
|
||
}
|
||
return env;
|
||
}
|
||
|
||
function parseEnvValue(value: string) {
|
||
const trimmed = value.trim();
|
||
if (!trimmed) return "";
|
||
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
||
return trimmed.slice(1, -1).replace(/\\n/g, "\n").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||
}
|
||
return trimmed.replace(/\s+#.*$/, "");
|
||
}
|
||
|
||
function formatEnvValue(value: string) {
|
||
if (!value) return "";
|
||
if (/[\s#"'\\]/.test(value)) return JSON.stringify(value);
|
||
return value;
|
||
}
|