372 lines
12 KiB
TypeScript
372 lines
12 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 { 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: "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" },
|
||
{ 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 Key;Base 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 engineAssignments = buildEngineAssignments(fileEnv);
|
||
return {
|
||
envPath: envFilePath(),
|
||
modes: {
|
||
visual: shouldMockVisualApi() ? "mock" : "real",
|
||
evolink: shouldMockEvolinkApi() ? "mock" : "real",
|
||
seedance: shouldMockSeedance() ? "mock" : "real",
|
||
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;
|
||
}
|