Files
NianAIGC/lib/server/app-settings.ts
2026-05-29 15:54:13 +08:00

399 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { rootDir } from "@/lib/server/runtime";
import { shouldMockVisualApi } from "@/lib/volcengine/visual-client";
import type { EnabledImageCapability } from "@/lib/types";
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;
mode: string;
modeLabel: 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: "账户登录 SSO",
description: "用于发布环境的统一认证中心登录client_secret 与 session secret 只保存在服务端。",
fields: [
{
key: "ZHINIAN_AUTH_REQUIRED",
label: "登录保护",
type: "select",
defaultValue: "auto",
options: [
{ label: "自动", value: "auto" },
{ label: "启用", value: "1" },
{ label: "停用", value: "0" }
]
},
{ key: "ZHINIAN_AUTH_BASE_URL", label: "Auth Base URL" },
{ key: "ZHINIAN_AUTH_CLIENT_ID", label: "客户端 ID", defaultValue: "customPC" },
{ key: "ZHINIAN_AUTH_CLIENT_SECRET", label: "客户端密钥", secret: true, type: "password" },
{ key: "ZHINIAN_AUTH_SCOPE", label: "Scope", defaultValue: "server" },
{ key: "ZHINIAN_AUTH_ISSUER", label: "Issuer", defaultValue: "https://pig4cloud.com" },
{ key: "ZHINIAN_AUTH_SESSION_SECRET", label: "会话签名密钥", secret: true, type: "password" }
]
},
{
id: "visual",
title: "即梦图片 API",
description: "只需填写火山 AK/SKRegion、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" },
{ key: "EVOLINK_IMAGE_RESOLUTION", label: "分辨率", defaultValue: "2K" },
{
key: "EVOLINK_MOCK",
label: "Mock 策略",
type: "select",
defaultValue: "auto",
options: [
{ label: "自动", value: "auto" },
{ label: "总是 Mock", value: "true" },
{ label: "总是真实接口", value: "false" }
]
}
]
},
{
id: "seedance",
title: "Seedance 视频 API",
description: "只需填写火山方舟 API KeyBase URL、模型和默认参数使用系统默认值。",
fields: [
{ key: "SEEDANCE_API_KEY", label: "方舟 API Key", secret: true, type: "password" }
]
},
{
id: "oss",
title: "OSS 资产存储",
description: "用于上传素材、mask、生成图和视频结果的公网转存对象前缀使用默认值。",
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" }
]
},
{
key: "IMAGE_INPAINT_ENGINE",
label: "局部重绘",
type: "select",
defaultValue: "jimeng",
options: [
{ label: "即梦 / 火山视觉", value: "jimeng" },
{ label: "EvoLink GPT Image 2", value: "evolink" }
]
}
];
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 {
envPath: envFilePath(),
modes: {
visual: shouldMockVisualApi() ? "mock" : "real",
evolink: shouldMockEvolinkApi() ? "mock" : "real",
seedance: shouldMockSeedance() ? "mock" : "real",
auth: authConfigSummary(auth),
data: process.env.SUPABASE_SERVICE_ROLE_KEY ? "supabase" : "local"
},
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: "Seedance 视频生成",
reqKey: seedance.model,
engine: "seedance",
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 generateEngine = currentEngineValue(engineFieldDefinitions[0], fileEnv);
const inpaintEngine = currentEngineValue(engineFieldDefinitions[1], fileEnv);
return [
imageEngineAssignment({
capability: "image.generate",
label: "图片生成",
engine: generateEngine,
reqKey: generateEngine === "evolink" ? evolink.model : capabilities["image.generate"].reqKey,
field: engineFieldDefinitions[0],
fileEnv
}),
imageEngineAssignment({
capability: "image.inpaint",
label: "局部重绘",
engine: inpaintEngine,
reqKey: inpaintEngine === "evolink" ? evolink.model : capabilities["image.inpaint"].reqKey,
field: engineFieldDefinitions[1],
fileEnv
}),
{
id: "image.upscale",
label: "智能超清",
engine: "jimeng",
engineLabel: "即梦",
mode: shouldMockVisualApi() ? "mock" : "volcengine",
modeLabel: shouldMockVisualApi() ? "Mock" : "即梦真实接口",
reqKey: capabilities["image.upscale"].reqKey,
configurable: false
},
{
id: "video.generate",
label: "视频生成",
engine: "seedance",
engineLabel: "Seedance",
mode: shouldMockSeedance() ? "mock" : "seedance",
modeLabel: shouldMockSeedance() ? "Mock" : "Seedance 真实接口",
reqKey: seedance.model,
configurable: false
}
];
}
function imageEngineAssignment(input: {
capability: EnabledImageCapability;
label: string;
engine: ImageCreationEngine;
reqKey: string;
field: FieldDefinition;
fileEnv: Map<string, string>;
}): EngineAssignment {
const mode = modeForImageEngine(input.engine);
return {
id: input.capability,
label: input.label,
engine: input.engine,
engineLabel: imageEngineLabel(input.engine),
mode,
modeLabel: modeLabel(mode, input.engine),
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 {
return value?.trim().toLowerCase() === "evolink" ? "evolink" : "jimeng";
}
function imageEngineLabel(engine: ImageCreationEngine) {
return engine === "evolink" ? "EvoLink" : "即梦";
}
function modeForImageEngine(engine: ImageCreationEngine) {
if (engine === "evolink") return shouldMockEvolinkApi() ? "mock" : "evolink";
return shouldMockVisualApi() ? "mock" : "volcengine";
}
function modeLabel(mode: string, engine: ImageCreationEngine) {
if (mode === "mock") return "Mock";
return engine === "evolink" ? "EvoLink 真实接口" : "即梦真实接口";
}
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;
}