feat: transition to a fully frontend architecture by removing backend dependencies and updating configuration for local deployment

This commit is contained in:
HouYunFei
2026-06-16 12:56:49 +08:00
parent 8a66524ea7
commit 7f0199da59
36 changed files with 571 additions and 1773 deletions
-294
View File
@@ -1,294 +0,0 @@
import { nanoid } from "nanoid";
import { fetchPrompts, type Prompt, type PromptListResponse } from "@/services/api/prompts";
import { fetchImageModels } from "@/services/api/image";
export type AdminPromptCategory = {
category: string;
name: string;
description: string;
file: string;
githubUrl: string;
remote: boolean;
};
export type AdminUser = {
id: string;
username: string;
email: string;
displayName: string;
avatarUrl: string;
role: "user" | "admin";
credits: number;
affCode: string;
affCount: number;
inviterId: string;
linuxDoId: string;
status: "active" | "ban";
lastLoginAt: string;
createdAt: string;
updatedAt: string;
};
export type AdminUserListResponse = {
items: AdminUser[];
total: number;
};
export type AdminCreditLog = {
id: string;
userId: string;
type: string;
amount: number;
balance: number;
relatedId: string;
remark: string;
extra: string;
createdAt: string;
};
export type AdminCreditLogListResponse = {
items: AdminCreditLog[];
total: number;
};
export type AdminUserQuery = {
keyword?: string;
page?: number;
pageSize?: number;
};
export async function fetchAdminUsers(token: string, query: AdminUserQuery = {}) {
return { items: [], total: 0 };
}
export async function saveAdminUser(token: string, user: Partial<AdminUser> & { password?: string }) {
return { ...emptyAdminUser(), ...user, id: user.id || nanoid() };
}
export async function adjustAdminUserCredits(token: string, id: string, credits: number) {
return { ...emptyAdminUser(), id, credits };
}
export async function deleteAdminUser(token: string, id: string) {
return true;
}
export async function fetchAdminCreditLogs(token: string, query: AdminUserQuery = {}) {
return { items: [], total: 0 };
}
export async function saveAdminCreditLog(token: string, log: Partial<AdminCreditLog>) {
return { id: log.id || nanoid(), userId: "", type: "", amount: 0, balance: 0, relatedId: "", remark: "", extra: "", createdAt: new Date().toISOString(), ...log };
}
export async function deleteAdminCreditLog(token: string, id: string) {
return true;
}
export async function fetchAdminPromptCategories(token: string) {
return promptCategories;
}
export async function syncAdminPromptCategory(token: string, category: string) {
await fetchPrompts({ category, pageSize: 1 });
return promptCategories;
}
export type AdminPromptQuery = {
keyword?: string;
category?: string;
tag?: string[];
page?: number;
pageSize?: number;
};
export type AdminAsset = {
id: string;
title: string;
type: "text" | "image" | "video";
coverUrl: string;
tags: string[];
category: string;
description: string;
content: string;
url: string;
createdAt: string;
updatedAt: string;
};
export type AdminAssetListResponse = {
items: AdminAsset[];
tags: string[];
total: number;
};
export async function fetchAdminPrompts(token: string, query: AdminPromptQuery = {}) {
return fetchPrompts(query);
}
export async function saveAdminPrompt(token: string, prompt: Partial<Prompt>) {
const now = new Date().toISOString();
return { id: prompt.id || nanoid(), title: "", coverUrl: "", prompt: "", tags: [], category: "", githubUrl: "", preview: "", createdAt: now, updatedAt: now, ...prompt };
}
export async function deleteAdminPrompt(token: string, id: string) {
return true;
}
export async function deleteAdminPrompts(token: string, ids: string[]) {
return true;
}
export type AdminAssetQuery = {
keyword?: string;
type?: string;
tag?: string[];
page?: number;
pageSize?: number;
};
export async function fetchAdminAssets(token: string, query: AdminAssetQuery = {}) {
return { items: [], tags: [], total: 0 };
}
export async function saveAdminAsset(token: string, asset: Partial<AdminAsset>) {
return { id: asset.id || nanoid(), title: "", type: "text", coverUrl: "", tags: [], category: "", description: "", content: "", url: "", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...asset };
}
export async function deleteAdminAsset(token: string, id: string) {
return true;
}
export type AdminModelChannel = {
protocol: "openai";
name: string;
baseUrl: string;
apiKey: string;
models: string[];
weight: number;
enabled: boolean;
remark: string;
};
export type AdminPublicModelChannelSettings = {
availableModels: string[];
modelCosts: AdminModelCost[];
defaultModel: string;
defaultImageModel: string;
defaultVideoModel: string;
defaultTextModel: string;
systemPrompt: string;
allowCustomChannel: boolean;
};
export type AdminModelCost = {
model: string;
credits: number;
};
export type AdminPublicSettings = {
modelChannel: AdminPublicModelChannelSettings;
auth: {
allowRegister: boolean;
linuxDo: {
enabled: boolean;
};
};
};
export type AdminPrivateSettings = {
channels: AdminModelChannel[];
promptSync: {
enabled: boolean;
cron: string;
};
auth: {
linuxDo: {
clientId: string;
clientSecret: string;
};
};
};
export type AdminSettings = {
public: AdminPublicSettings;
private: AdminPrivateSettings;
};
export async function fetchAdminSettings(token: string) {
return defaultAdminSettings();
}
export async function saveAdminSettings(token: string, settings: AdminSettings) {
return settings;
}
export type AdminChannelActionRequest = {
index?: number;
channel: AdminModelChannel;
model?: string;
};
export async function fetchChannelModels(token: string, payload: AdminChannelActionRequest) {
return fetchImageModels({ ...defaultAiConfig(), baseUrl: payload.channel.baseUrl, apiKey: payload.channel.apiKey || "local", model: payload.model || "", models: payload.channel.models });
}
export async function testChannelModel(token: string, payload: AdminChannelActionRequest) {
return "前台直连配置已保存,请在前台生成时验证模型可用性";
}
const promptCategories: AdminPromptCategory[] = [
{ category: "gpt-image-2-prompts", name: "GPT Image 2 Prompts", description: "", file: "", githubUrl: "https://github.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts", remote: true },
{ category: "awesome-gpt-image", name: "Awesome GPT Image", description: "", file: "", githubUrl: "https://github.com/ZeroLu/awesome-gpt-image", remote: true },
{ category: "awesome-gpt4o-image-prompts", name: "Awesome GPT-4o Image Prompts", description: "", file: "", githubUrl: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", remote: true },
{ category: "youmind-gpt-image-2", name: "YouMind GPT Image 2", description: "", file: "", githubUrl: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2", remote: true },
{ category: "youmind-nano-banana-pro", name: "YouMind Nano Banana Pro", description: "", file: "", githubUrl: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts", remote: true },
{ category: "davidwu-gpt-image2-prompts", name: "DavidWu GPT Image2 Prompts", description: "", file: "", githubUrl: "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts", remote: true },
];
function emptyAdminUser(): AdminUser {
const now = new Date().toISOString();
return { id: "", username: "", email: "", displayName: "", avatarUrl: "", role: "user", credits: 0, affCode: "", affCount: 0, inviterId: "", linuxDoId: "", status: "active", lastLoginAt: "", createdAt: now, updatedAt: now };
}
function defaultAdminSettings(): AdminSettings {
return {
public: {
modelChannel: { availableModels: [], modelCosts: [], defaultModel: "", defaultImageModel: "", defaultVideoModel: "", defaultTextModel: "", systemPrompt: "", allowCustomChannel: true },
auth: { allowRegister: false, linuxDo: { enabled: false } },
},
private: { channels: [], promptSync: { enabled: false, cron: "" }, auth: { linuxDo: { clientId: "", clientSecret: "" } } },
};
}
function defaultAiConfig() {
return {
channelMode: "local" as const,
baseUrl: "",
apiKey: "",
model: "",
imageModel: "",
videoModel: "",
textModel: "",
audioModel: "",
audioVoice: "",
audioFormat: "",
audioSpeed: "",
audioInstructions: "",
videoSeconds: "",
vquality: "",
videoGenerateAudio: "",
videoWatermark: "",
systemPrompt: "",
models: [],
imageModels: [],
videoModels: [],
textModels: [],
audioModels: [],
quality: "",
size: "",
count: "",
canvasImageCount: "",
};
}
+6 -5
View File
@@ -2,7 +2,7 @@ import axios from "axios";
import { audioMimeType, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation";
import { uploadMediaFile, type UploadedFile } from "@/services/file-storage";
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
import { buildApiUrl, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
function aiApiUrl(config: AiConfig, path: string) {
return buildApiUrl(config.baseUrl, path);
@@ -16,14 +16,15 @@ function aiHeaders(config: AiConfig) {
}
export async function requestAudioGeneration(config: AiConfig, prompt: string): Promise<Blob> {
const model = (config.model || config.audioModel).trim();
assertAudioConfig(config, model);
const requestConfig = resolveModelRequestConfig(config, config.model || config.audioModel);
const model = requestConfig.model.trim();
assertAudioConfig(requestConfig, model);
const format = normalizeAudioFormatValue(config.audioFormat);
const instructions = config.audioInstructions.trim();
try {
const response = await axios.post<Blob>(
aiApiUrl(config, "/audio/speech"),
aiApiUrl(requestConfig, "/audio/speech"),
{
model,
input: prompt,
@@ -32,7 +33,7 @@ export async function requestAudioGeneration(config: AiConfig, prompt: string):
speed: Number(normalizeAudioSpeedValue(config.audioSpeed)),
...(instructions ? { instructions } : {}),
},
{ headers: aiHeaders(config), responseType: "blob" },
{ headers: aiHeaders(requestConfig), responseType: "blob" },
);
await assertAudioBlob(response.data);
return response.data.type.startsWith("audio/") ? response.data : new Blob([response.data], { type: audioMimeType(format) });
-37
View File
@@ -1,37 +0,0 @@
export const AUTH_TOKEN_KEY = "infinite-canvas-auth-token-v1";
export type UserRole = "guest" | "user" | "admin";
export type AuthUser = {
id: string;
username: string;
displayName: string;
avatarUrl: string;
role: UserRole;
credits: number;
createdAt: string;
updatedAt: string;
};
export type AuthSession = {
token: string;
user: AuthUser;
};
export type AuthPayload = {
username: string;
password: string;
};
export async function login(payload: AuthPayload) {
const now = new Date().toISOString();
return { token: "", user: { id: "local-user", username: payload.username || "local", displayName: payload.username || "本地用户", avatarUrl: "", role: "user" as const, credits: 0, createdAt: now, updatedAt: now } };
}
export async function register(payload: AuthPayload) {
return login(payload);
}
export async function fetchCurrentUser(token?: string) {
return { id: "local-user", username: "local", displayName: "本地用户", avatarUrl: "", role: "user" as const, credits: 0, createdAt: "", updatedAt: "" };
}
+20 -13
View File
@@ -1,6 +1,6 @@
import axios from "axios";
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
import { buildApiUrl, resolveModelRequestConfig, type AiConfig, type ModelChannel } from "@/stores/use-config-store";
import { nanoid } from "nanoid";
import { dataUrlToFile } from "@/lib/image-utils";
import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
@@ -184,15 +184,16 @@ function withSystemMessage(config: AiConfig, messages: ChatCompletionMessage[])
}
export async function requestGeneration(config: AiConfig, prompt: string) {
const requestConfig = resolveModelRequestConfig(config, config.model || config.imageModel);
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size);
try {
const response = await axios.post<ImageApiResponse>(
aiApiUrl(config, "/images/generations"),
aiApiUrl(requestConfig, "/images/generations"),
{
model: config.model,
prompt: withSystemPrompt(config, prompt),
model: requestConfig.model,
prompt: withSystemPrompt(requestConfig, prompt),
n,
...(quality ? { quality } : {}),
...(requestSize ? { size: requestSize } : {}),
@@ -200,7 +201,7 @@ export async function requestGeneration(config: AiConfig, prompt: string) {
output_format: IMAGE_OUTPUT_FORMAT,
},
{
headers: aiHeaders(config, "application/json"),
headers: aiHeaders(requestConfig, "application/json"),
},
);
const images = parseImagePayload(response.data);
@@ -211,13 +212,14 @@ export async function requestGeneration(config: AiConfig, prompt: string) {
}
export async function requestEdit(config: AiConfig, prompt: string, references: ReferenceImage[], mask?: ReferenceImage) {
const requestConfig = resolveModelRequestConfig(config, config.model || config.imageModel);
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size);
const requestPrompt = buildImageReferencePromptText(prompt, references);
const formData = new FormData();
formData.set("model", config.model);
formData.set("prompt", withSystemPrompt(config, requestPrompt));
formData.set("model", requestConfig.model);
formData.set("prompt", withSystemPrompt(requestConfig, requestPrompt));
formData.set("n", String(n));
formData.set("response_format", "b64_json");
formData.set("output_format", IMAGE_OUTPUT_FORMAT);
@@ -232,7 +234,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
if (mask) formData.set("mask", dataUrlToFile(mask));
try {
const response = await axios.post<ImageApiResponse>(aiApiUrl(config, "/images/edits"), formData, { headers: aiHeaders(config) });
const response = await axios.post<ImageApiResponse>(aiApiUrl(requestConfig, "/images/edits"), formData, { headers: aiHeaders(requestConfig) });
const images = parseImagePayload(response.data);
return images;
} catch (error) {
@@ -241,21 +243,22 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
}
export async function requestImageQuestion(config: AiConfig, messages: ChatCompletionMessage[], onDelta: (text: string) => void) {
const requestConfig = resolveModelRequestConfig(config, config.model || config.textModel);
let buffer = "";
let answer = "";
let processedLength = 0;
try {
const response = await axios.post(
aiApiUrl(config, "/chat/completions"),
aiApiUrl(requestConfig, "/chat/completions"),
{
model: config.model,
messages: withSystemMessage(config, messages),
model: requestConfig.model,
messages: withSystemMessage(requestConfig, messages),
stream: true,
},
{
headers: {
...aiHeaders(config, "application/json"),
...aiHeaders(requestConfig, "application/json"),
} as Record<string, string>,
responseType: "text",
onDownloadProgress: (event) => {
@@ -301,7 +304,7 @@ export async function requestImageQuestion(config: AiConfig, messages: ChatCompl
return answer || "没有返回内容";
}
export async function fetchImageModels(config: AiConfig) {
export async function fetchImageModels(config: Pick<AiConfig, "baseUrl" | "apiKey">) {
try {
const response = await axios.get<{ data?: Array<{ id?: string }>; error?: { message?: string } }>(buildApiUrl(config.baseUrl, "/models"), {
headers: {
@@ -316,3 +319,7 @@ export async function fetchImageModels(config: AiConfig) {
throw new Error(readAxiosError(error, "读取模型失败"));
}
}
export async function fetchChannelModels(channel: ModelChannel) {
return fetchImageModels({ baseUrl: channel.baseUrl, apiKey: channel.apiKey });
}
+13 -11
View File
@@ -4,7 +4,7 @@ import { dataUrlToFile } from "@/lib/image-utils";
import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/file-storage";
import { imageToDataUrl } from "@/services/image-storage";
import { boolConfig, buildSeedancePromptText, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceVideoReferenceError, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
import { buildApiUrl, modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
@@ -47,20 +47,22 @@ export async function requestVideoGeneration(config: AiConfig, prompt: string, r
}
export async function createVideoGenerationTask(config: AiConfig, prompt: string, references: ReferenceImage[] = [], videoReferences: ReferenceVideo[] = [], audioReferences: ReferenceAudio[] = []): Promise<VideoGenerationTask> {
const model = (config.model || config.videoModel).trim();
assertVideoConfig(config, model);
if (isSeedanceVideoConfig({ ...config, model })) {
return createSeedanceTask(config, model, prompt, references, videoReferences, audioReferences);
const selectedModel = (config.model || config.videoModel).trim();
const requestConfig = resolveModelRequestConfig(config, selectedModel);
assertVideoConfig(requestConfig, requestConfig.model);
if (isSeedanceVideoConfig(requestConfig)) {
return createSeedanceTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences);
}
if (videoReferences.length || audioReferences.length) {
throw new Error("当前视频接口不支持参考视频或参考音频,请切换到 Seedance 2.0 / 火山 Agent Plan 模型,或移除参考素材");
}
return createOpenAIVideoTask(config, model, prompt, references);
return createOpenAIVideoTask(requestConfig, selectedModel, prompt, references);
}
export async function pollVideoGenerationTask(config: AiConfig, task: VideoGenerationTask): Promise<VideoGenerationTaskState> {
assertVideoConfig(config, task.model);
return task.provider === "seedance" ? pollSeedanceTask(config, task) : pollOpenAIVideoTask(config, task);
const requestConfig = resolveModelRequestConfig(config, task.model);
assertVideoConfig(requestConfig, requestConfig.model);
return task.provider === "seedance" ? pollSeedanceTask(requestConfig, task) : pollOpenAIVideoTask(requestConfig, task);
}
export async function storeGeneratedVideo(result: VideoGenerationResult): Promise<UploadedFile> {
@@ -71,7 +73,7 @@ export async function storeGeneratedVideo(result: VideoGenerationResult): Promis
async function createOpenAIVideoTask(config: AiConfig, model: string, prompt: string, references: ReferenceImage[]): Promise<VideoGenerationTask> {
const body = new FormData();
body.append("model", model);
body.append("model", modelOptionName(model));
body.append("prompt", prompt);
body.append("seconds", normalizeVideoSeconds(config.videoSeconds));
if (normalizeVideoSize(config.size)) body.append("size", normalizeVideoSize(config.size)!);
@@ -112,10 +114,10 @@ async function createSeedanceTask(config: AiConfig, model: string, prompt: strin
const content = await buildSeedanceContent(config, prompt, references, videoReferences, audioReferences);
if (!content.length) throw new Error("请输入视频提示词,或连接参考图片/视频/音频");
const payload = {
model,
model: modelOptionName(model),
content,
ratio: normalizeSeedanceRatio(config.size),
resolution: normalizeSeedanceResolution(config.vquality, model),
resolution: normalizeSeedanceResolution(config.vquality, modelOptionName(model)),
duration: normalizeSeedanceDuration(config.videoSeconds),
generate_audio: boolConfig(config.videoGenerateAudio, true),
watermark: boolConfig(config.videoWatermark, false),