Files
NianAIGC/lib/server/app-settings.ts

403 lines
14 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 { 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/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" }
]
},
{
id: "seedance",
title: "Seedance 视频 API",
description: "只需填写火山方舟 API KeyBase 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;
}