diff --git a/web/src/components/layout/client-root-init.tsx b/web/src/components/layout/client-root-init.tsx
index 3523ee4..11da38a 100644
--- a/web/src/components/layout/client-root-init.tsx
+++ b/web/src/components/layout/client-root-init.tsx
@@ -2,54 +2,39 @@
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
-import { usePathname } from "next/navigation";
import { App } from "antd";
import { useConfigStore } from "@/stores/use-config-store";
-import { useUserStore } from "@/stores/use-user-store";
export function ClientRootInit({ children }: { children: ReactNode }) {
const { message } = App.useApp();
const handledConfigParams = useRef(false);
- const pathname = usePathname();
- const hydrateUser = useUserStore((state) => state.hydrateUser);
const loadPublicSettings = useConfigStore((state) => state.loadPublicSettings);
- const publicSettings = useConfigStore((state) => state.publicSettings);
const updateConfig = useConfigStore((state) => state.updateConfig);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
- const isLoginPage = pathname === "/login" || pathname === "/admin/login";
useEffect(() => {
void loadPublicSettings();
}, [loadPublicSettings]);
- useEffect(() => {
- if (!isLoginPage) void hydrateUser();
- }, [hydrateUser, isLoginPage]);
-
useEffect(() => {
if (handledConfigParams.current) return;
const searchParams = new URLSearchParams(window.location.search);
const baseUrl = searchParams.get("baseUrl") || searchParams.get("baseurl");
const apiKey = searchParams.get("apiKey") || searchParams.get("apikey");
if (!baseUrl && !apiKey) return;
- if (!publicSettings) return;
handledConfigParams.current = true;
searchParams.delete("baseUrl");
searchParams.delete("baseurl");
searchParams.delete("apiKey");
searchParams.delete("apikey");
window.history.replaceState(null, "", `${window.location.pathname}${searchParams.size ? `?${searchParams}` : ""}${window.location.hash}`);
- if (!publicSettings.modelChannel.allowCustomChannel) {
- openConfigDialog(false);
- message.error("后台未允许用户自定义渠道,请联系管理员进行配置");
- return;
- }
updateConfig("channelMode", "local");
if (baseUrl) updateConfig("baseUrl", baseUrl);
if (apiKey) updateConfig("apiKey", apiKey);
openConfigDialog(false);
- }, [message, openConfigDialog, publicSettings, updateConfig]);
+ message.success("已导入本地直连配置");
+ }, [message, openConfigDialog, updateConfig]);
return <>{children}>;
}
diff --git a/web/src/components/layout/user-status-actions.tsx b/web/src/components/layout/user-status-actions.tsx
index a035278..829615d 100644
--- a/web/src/components/layout/user-status-actions.tsx
+++ b/web/src/components/layout/user-status-actions.tsx
@@ -1,15 +1,13 @@
"use client";
import type { CSSProperties, RefObject } from "react";
-import { Avatar, Dropdown, Tooltip } from "antd";
-import { BookOpen, Keyboard, LogOut, Settings2, Shield } from "lucide-react";
+import { Avatar, Dropdown } from "antd";
+import { BookOpen, Keyboard, LogOut, Settings2 } from "lucide-react";
import type { ItemType } from "antd/es/menu/interface";
-import Link from "next/link";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { GitHubLink } from "@/components/layout/github-link";
import { VersionReleaseModal } from "@/components/layout/version-release-modal";
-import { CreditSymbol } from "@/constant/credits";
import { DOCS_URL } from "@/constant/env";
import { cn } from "@/lib/utils";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -35,7 +33,6 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const canvasTheme = canvasThemes[theme];
const userName = user?.displayName || user?.username || "";
- const credits = user?.credits ?? 0;
const avatarUrl = user?.avatarUrl?.trim();
const avatarText = (userName.trim()[0] || "U").toUpperCase();
const naturalIconClass = "inline-flex size-7 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4";
@@ -46,10 +43,9 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
const avatarStyle: CSSProperties | undefined = variant === "canvas" ? { borderColor: canvasTheme.toolbar.border, color: canvasTheme.node.text, background: "transparent" } : undefined;
const menuItems: ItemType[] = [
{ key: "user", disabled: true, label:
{userName} },
- ...(user?.role === "admin" ? [{ key: "admin", icon:
, label:
管理后台 }] : []),
...(onOpenShortcuts ? [{ key: "shortcuts", icon:
, label: "快捷键", onClick: onOpenShortcuts }] : []),
{ type: "divider" },
- { key: "logout", icon:
, label: "退出登录", onClick: logout },
+ { key: "logout", icon:
, label: "清除本地账户", onClick: logout },
];
return (
@@ -65,24 +61,11 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
- {variant === "canvas" && user ? (
-
-
-
- {credits.toLocaleString()}
-
-
- ) : null}
{!user && onOpenShortcuts ? (
) : null}
- {!user ? (
-
- 登录
-
- ) : null}
{user ? (
diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx
index 869f3dd..6065af3 100644
--- a/web/src/components/model-picker.tsx
+++ b/web/src/components/model-picker.tsx
@@ -85,7 +85,6 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : "";
- if (config.channelMode === "remote") return `暂无可用${label}模型`;
if (capability && config.models.length) return "请先在上方配置可选模型";
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里拉取模型列表";
}
diff --git a/web/src/services/api/admin.ts b/web/src/services/api/admin.ts
index d4971ab..d04ef70 100644
--- a/web/src/services/api/admin.ts
+++ b/web/src/services/api/admin.ts
@@ -1,5 +1,7 @@
-import { apiDelete, apiGet, apiPost, compactApiParams } from "@/services/api/request";
-import type { Prompt, PromptListResponse } from "@/services/api/prompts";
+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;
@@ -57,39 +59,40 @@ export type AdminUserQuery = {
};
export async function fetchAdminUsers(token: string, query: AdminUserQuery = {}) {
- return apiGet("/api/admin/users", compactApiParams(query), token);
+ return { items: [], total: 0 };
}
export async function saveAdminUser(token: string, user: Partial & { password?: string }) {
- return apiPost("/api/admin/users", user, token);
+ return { ...emptyAdminUser(), ...user, id: user.id || nanoid() };
}
export async function adjustAdminUserCredits(token: string, id: string, credits: number) {
- return apiPost(`/api/admin/users/${encodeURIComponent(id)}/credits`, { credits }, token);
+ return { ...emptyAdminUser(), id, credits };
}
export async function deleteAdminUser(token: string, id: string) {
- return apiDelete(`/api/admin/users/${encodeURIComponent(id)}`, token);
+ return true;
}
export async function fetchAdminCreditLogs(token: string, query: AdminUserQuery = {}) {
- return apiGet("/api/admin/credit-logs", compactApiParams(query), token);
+ return { items: [], total: 0 };
}
export async function saveAdminCreditLog(token: string, log: Partial) {
- return apiPost("/api/admin/credit-logs", log, token);
+ 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 apiDelete(`/api/admin/credit-logs/${encodeURIComponent(id)}`, token);
+ return true;
}
export async function fetchAdminPromptCategories(token: string) {
- return apiGet("/api/admin/prompt-categories", undefined, token);
+ return promptCategories;
}
export async function syncAdminPromptCategory(token: string, category: string) {
- return apiPost("/api/admin/prompt-categories/sync", { category }, token);
+ await fetchPrompts({ category, pageSize: 1 });
+ return promptCategories;
}
export type AdminPromptQuery = {
@@ -121,19 +124,20 @@ export type AdminAssetListResponse = {
};
export async function fetchAdminPrompts(token: string, query: AdminPromptQuery = {}) {
- return apiGet("/api/admin/prompts", compactApiParams(query), token);
+ return fetchPrompts(query);
}
export async function saveAdminPrompt(token: string, prompt: Partial) {
- return apiPost("/api/admin/prompts", prompt, token);
+ 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 apiDelete(`/api/admin/prompts/${encodeURIComponent(id)}`, token);
+ return true;
}
export async function deleteAdminPrompts(token: string, ids: string[]) {
- return apiPost("/api/admin/prompts/batch-delete", { ids }, token);
+ return true;
}
export type AdminAssetQuery = {
@@ -145,15 +149,15 @@ export type AdminAssetQuery = {
};
export async function fetchAdminAssets(token: string, query: AdminAssetQuery = {}) {
- return apiGet("/api/admin/assets", compactApiParams(query), token);
+ return { items: [], tags: [], total: 0 };
}
export async function saveAdminAsset(token: string, asset: Partial) {
- return apiPost("/api/admin/assets", asset, token);
+ 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 apiDelete(`/api/admin/assets/${encodeURIComponent(id)}`, token);
+ return true;
}
export type AdminModelChannel = {
@@ -213,11 +217,11 @@ export type AdminSettings = {
};
export async function fetchAdminSettings(token: string) {
- return apiGet("/api/admin/settings", undefined, token);
+ return defaultAdminSettings();
}
export async function saveAdminSettings(token: string, settings: AdminSettings) {
- return apiPost("/api/admin/settings", settings, token);
+ return settings;
}
export type AdminChannelActionRequest = {
@@ -227,9 +231,64 @@ export type AdminChannelActionRequest = {
};
export async function fetchChannelModels(token: string, payload: AdminChannelActionRequest) {
- return apiPost("/api/admin/settings/channel-models", payload, token);
+ 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 apiPost("/api/admin/settings/channel-test", payload, token);
+ 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: "",
+ };
}
diff --git a/web/src/services/api/assets.ts b/web/src/services/api/assets.ts
index cc8ddf9..236479f 100644
--- a/web/src/services/api/assets.ts
+++ b/web/src/services/api/assets.ts
@@ -1,5 +1,3 @@
-import { apiGet, compactApiParams } from "@/services/api/request";
-
export type AssetLibraryItem = {
id: string;
title: string;
@@ -29,5 +27,19 @@ export type AssetLibraryQuery = {
};
export async function fetchAssetLibrary(query: AssetLibraryQuery = {}) {
- return apiGet("/api/assets", compactApiParams(query));
+ const items: AssetLibraryItem[] = [];
+ const filtered = items.filter((item) => {
+ const keyword = query.keyword?.trim().toLowerCase();
+ if (query.type && item.type !== query.type) return false;
+ if (query.tag?.length && !query.tag.some((tag) => item.tags.includes(tag))) return false;
+ if (!keyword) return true;
+ return [item.title, item.description, item.content, item.url, item.category, ...item.tags].join(" ").toLowerCase().includes(keyword);
+ });
+ const page = Math.max(1, Number(query.page) || 1);
+ const pageSize = Math.max(1, Number(query.pageSize) || filtered.length || 1);
+ return {
+ items: filtered.slice((page - 1) * pageSize, page * pageSize),
+ tags: Array.from(new Set(filtered.flatMap((item) => item.tags).filter(Boolean))),
+ total: filtered.length,
+ };
}
diff --git a/web/src/services/api/audio.ts b/web/src/services/api/audio.ts
index e68db39..a951c84 100644
--- a/web/src/services/api/audio.ts
+++ b/web/src/services/api/audio.ts
@@ -3,27 +3,16 @@ 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 { useUserStore } from "@/stores/use-user-store";
function aiApiUrl(config: AiConfig, path: string) {
- return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
+ return buildApiUrl(config.baseUrl, path);
}
function aiHeaders(config: AiConfig) {
- const token = useUserStore.getState().token;
- return config.channelMode === "remote"
- ? {
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
- "Content-Type": "application/json",
- }
- : {
- Authorization: `Bearer ${config.apiKey}`,
- "Content-Type": "application/json",
- };
-}
-
-function refreshRemoteUser(config: AiConfig) {
- if (config.channelMode === "remote") void useUserStore.getState().hydrateUser();
+ return {
+ Authorization: `Bearer ${config.apiKey}`,
+ "Content-Type": "application/json",
+ };
}
export async function requestAudioGeneration(config: AiConfig, prompt: string): Promise {
@@ -46,7 +35,6 @@ export async function requestAudioGeneration(config: AiConfig, prompt: string):
{ headers: aiHeaders(config), responseType: "blob" },
);
await assertAudioBlob(response.data);
- refreshRemoteUser(config);
return response.data.type.startsWith("audio/") ? response.data : new Blob([response.data], { type: audioMimeType(format) });
} catch (error) {
throw new Error(readAxiosError(error, "音频生成失败"));
@@ -60,8 +48,8 @@ export async function storeGeneratedAudio(blob: Blob, format = "mp3"): Promise("/api/auth/login", payload);
+ 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 apiPost("/api/auth/register", payload);
+ return login(payload);
}
export async function fetchCurrentUser(token?: string) {
- return apiGet("/api/auth/me", undefined, token);
+ return { id: "local-user", username: "local", displayName: "本地用户", avatarUrl: "", role: "user" as const, credits: 0, createdAt: "", updatedAt: "" };
}
diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts
index c5a6c77..e7706c4 100644
--- a/web/src/services/api/image.ts
+++ b/web/src/services/api/image.ts
@@ -1,7 +1,6 @@
import axios from "axios";
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
-import { useUserStore } from "@/stores/use-user-store";
import { nanoid } from "nanoid";
import { dataUrlToFile } from "@/lib/image-utils";
import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
@@ -169,24 +168,14 @@ function withSystemPrompt(config: AiConfig, prompt: string) {
}
function aiApiUrl(config: AiConfig, path: string) {
- return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
+ return buildApiUrl(config.baseUrl, path);
}
function aiHeaders(config: AiConfig, contentType?: string) {
- const token = useUserStore.getState().token;
- return config.channelMode === "remote"
- ? {
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
- ...(contentType ? { "Content-Type": contentType } : {}),
- }
- : {
- Authorization: `Bearer ${config.apiKey}`,
- ...(contentType ? { "Content-Type": contentType } : {}),
- };
-}
-
-function refreshRemoteUser(config: AiConfig) {
- if (config.channelMode === "remote") void useUserStore.getState().hydrateUser();
+ return {
+ Authorization: `Bearer ${config.apiKey}`,
+ ...(contentType ? { "Content-Type": contentType } : {}),
+ };
}
function withSystemMessage(config: AiConfig, messages: ChatCompletionMessage[]) {
@@ -215,7 +204,6 @@ export async function requestGeneration(config: AiConfig, prompt: string) {
},
);
const images = parseImagePayload(response.data);
- refreshRemoteUser(config);
return images;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
@@ -246,7 +234,6 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
try {
const response = await axios.post(aiApiUrl(config, "/images/edits"), formData, { headers: aiHeaders(config) });
const images = parseImagePayload(response.data);
- refreshRemoteUser(config);
return images;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
@@ -311,12 +298,10 @@ export async function requestImageQuestion(config: AiConfig, messages: ChatCompl
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
- refreshRemoteUser(config);
return answer || "没有返回内容";
}
export async function fetchImageModels(config: AiConfig) {
- if (config.channelMode === "remote") return config.models;
try {
const response = await axios.get<{ data?: Array<{ id?: string }>; error?: { message?: string } }>(buildApiUrl(config.baseUrl, "/models"), {
headers: {
diff --git a/web/src/services/api/prompts.ts b/web/src/services/api/prompts.ts
index ed653ae..09b4de0 100644
--- a/web/src/services/api/prompts.ts
+++ b/web/src/services/api/prompts.ts
@@ -1,4 +1,4 @@
-import { apiGet, compactApiParams } from "@/services/api/request";
+import { compactApiParams, serializeApiParams } from "@/services/api/request";
export type Prompt = {
id: string;
@@ -23,8 +23,7 @@ export type PromptListResponse = {
};
export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROMPTS_OPTION, page, pageSize }: { keyword?: string; tag?: string[]; category?: string; page?: number; pageSize?: number } = {}) {
- return apiGet(
- "/api/prompts",
+ const params = serializeApiParams(
compactApiParams({
...(keyword ? { keyword } : {}),
...(tag.length ? { tag } : {}),
@@ -33,6 +32,9 @@ export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROM
...(pageSize ? { pageSize } : {}),
}),
);
+ const response = await fetch(`/api/prompts${params.size ? `?${params}` : ""}`);
+ if (!response.ok) throw new Error("获取提示词失败");
+ return (await response.json()) as PromptListResponse;
}
export function formatPromptDate(value: string) {
diff --git a/web/src/services/api/request.ts b/web/src/services/api/request.ts
index 8423063..9375bfe 100644
--- a/web/src/services/api/request.ts
+++ b/web/src/services/api/request.ts
@@ -1,13 +1,5 @@
-import axios from "axios";
-
export type ApiParams = Record;
-type ApiResponse = {
- code: number;
- data: T;
- msg: string;
-};
-
export function compactApiParams(params: ApiParams) {
return Object.fromEntries(Object.entries(params).filter(([, value]) => value !== "" && value !== undefined && (!Array.isArray(value) || value.length > 0))) as ApiParams;
}
@@ -21,61 +13,3 @@ export function serializeApiParams(params?: ApiParams) {
}
return queryParams;
}
-
-export async function apiGet(url: string, params?: ApiParams, token?: string) {
- return apiRequest({
- url,
- method: "GET",
- params: params || undefined,
- headers: token ? { Authorization: `Bearer ${token}` } : undefined,
- });
-}
-
-export async function apiPost(url: string, body?: unknown, token?: string) {
- return apiRequest({
- url,
- method: "POST",
- data: body ?? {},
- headers: {
- "Content-Type": "application/json",
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
- },
- });
-}
-
-export async function apiDelete(url: string, token?: string) {
- return apiRequest({
- url,
- method: "DELETE",
- headers: token ? { Authorization: `Bearer ${token}` } : undefined,
- });
-}
-
-async function apiRequest(config: { url: string; method: "GET" | "POST" | "DELETE"; params?: ApiParams; data?: unknown; headers?: Record }) {
- let response;
- try {
- response = await axios.request>({
- url: config.url,
- method: config.method,
- params: config.params,
- paramsSerializer: { serialize: (params) => serializeApiParams(params as ApiParams).toString() },
- data: config.data,
- headers: config.headers,
- validateStatus: () => true,
- });
- } catch {
- throw new Error("接口连接失败,请确认后端服务已启动");
- }
-
- const result = response.data;
- if (!result || typeof result !== "object") {
- throw new Error(response.status === 404 ? "接口不存在,请确认后端服务已启动" : "接口返回异常,请稍后重试");
- }
-
- const payload = result as ApiResponse;
- if (response.status < 200 || response.status >= 300 || payload.code !== 0) {
- throw new Error(payload.msg || "请求失败");
- }
-
- return payload.data;
-}
diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts
index b5f36fb..648beaf 100644
--- a/web/src/services/api/video.ts
+++ b/web/src/services/api/video.ts
@@ -5,7 +5,6 @@ import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/fil
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 { useUserStore } from "@/stores/use-user-store";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
@@ -18,31 +17,20 @@ type SeedanceTask = {
content?: { video_url?: string; last_frame_url?: string } | null;
};
type ApiEnvelope = T | { code?: number; data?: T | null; msg?: string };
-type ReferenceMediaUploadResponse = { id: string; url: string; mimeType: string; bytes: number };
export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string };
export type VideoGenerationTask = { id: string; provider: "openai" | "seedance"; model: string };
export type VideoGenerationTaskState = { status: "pending" } | { status: "completed"; result: VideoGenerationResult } | { status: "failed"; error: string };
function aiApiUrl(config: AiConfig, path: string) {
- return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
+ return buildApiUrl(config.baseUrl, path);
}
function aiHeaders(config: AiConfig, contentType?: string) {
- const token = useUserStore.getState().token;
- return config.channelMode === "remote"
- ? {
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
- ...(contentType ? { "Content-Type": contentType } : {}),
- }
- : {
- Authorization: `Bearer ${config.apiKey}`,
- ...(contentType ? { "Content-Type": contentType } : {}),
- };
-}
-
-function refreshRemoteUser(config: AiConfig) {
- if (config.channelMode === "remote") void useUserStore.getState().hydrateUser();
+ return {
+ Authorization: `Bearer ${config.apiKey}`,
+ ...(contentType ? { "Content-Type": contentType } : {}),
+ };
}
export async function requestVideoGeneration(config: AiConfig, prompt: string, references: ReferenceImage[] = [], videoReferences: ReferenceVideo[] = [], audioReferences: ReferenceAudio[] = []): Promise {
@@ -102,11 +90,10 @@ async function createOpenAIVideoTask(config: AiConfig, model: string, prompt: st
async function pollOpenAIVideoTask(config: AiConfig, task: VideoGenerationTask): Promise {
try {
- const video = unwrapVideoResponse((await axios.get(aiApiUrl(config, `/videos/${task.id}`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined })).data);
+ const video = unwrapVideoResponse((await axios.get(aiApiUrl(config, `/videos/${task.id}`), { headers: aiHeaders(config) })).data);
if (video.status === "completed") {
- const content = await axios.get(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined, responseType: "blob" });
+ const content = await axios.get(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), responseType: "blob" });
await assertVideoBlob(content.data);
- refreshRemoteUser(config);
return { status: "completed", result: { blob: content.data } };
}
if (video.status === "failed" || video.status === "cancelled") return { status: "failed", error: video.error?.message || "视频生成失败" };
@@ -145,11 +132,10 @@ async function createSeedanceTask(config: AiConfig, model: string, prompt: strin
async function pollSeedanceTask(config: AiConfig, task: VideoGenerationTask): Promise {
try {
- const state = unwrapSeedanceTask((await axios.get>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined })).data);
+ const state = unwrapSeedanceTask((await axios.get>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config) })).data);
if (state.status === "succeeded") {
const url = state.content?.video_url;
if (!url) return { status: "failed", error: "Seedance 任务成功但没有返回视频 URL" };
- refreshRemoteUser(config);
return { status: "completed", result: await videoResultFromUrl(url) };
}
if (state.status === "failed" || state.status === "cancelled" || state.status === "expired") return { status: "failed", error: state.error?.message || `Seedance 视频生成${state.status === "expired" ? "超时" : "失败"}` };
@@ -182,7 +168,6 @@ function assertSeedanceAudioReferences(audioReferences: ReferenceAudio[]) {
}
function seedanceApiUrl(config: AiConfig, taskId?: string) {
- if (config.channelMode === "remote") return taskId ? `/api/v1/videos/${encodeURIComponent(taskId)}` : "/api/v1/videos";
return buildApiUrl(config.baseUrl, `/contents/generations/tasks${taskId ? `/${encodeURIComponent(taskId)}` : ""}`);
}
@@ -207,9 +192,6 @@ async function resolveSeedanceImageUrl(config: AiConfig, image: ReferenceImage)
if (isPublicMediaUrl(directUrl) || directUrl.startsWith("asset://")) return directUrl;
const dataUrl = await imageToDataUrl(image);
if (!dataUrl) throw new Error("参考图读取失败,请换一张图片或重新上传");
- if (config.channelMode === "remote") {
- return uploadReferenceMedia(dataUrlToFile({ ...image, dataUrl }));
- }
return dataUrl;
}
@@ -219,8 +201,7 @@ async function resolveSeedanceVideoUrl(video: ReferenceVideo) {
if (video.storageKey) blob = await getMediaBlob(video.storageKey);
if (!blob && video.url?.startsWith("blob:")) blob = await (await fetch(video.url)).blob();
if (!blob) throw new Error("参考视频必须是公网 URL、素材 ID,或本地已保存的视频");
- const file = new File([blob], video.name || "reference-video.mp4", { type: video.type || blob.type || "video/mp4" });
- return uploadReferenceMedia(file);
+ return blobToDataUrl(blob);
}
async function resolveSeedanceAudioUrl(audio: ReferenceAudio) {
@@ -229,19 +210,7 @@ async function resolveSeedanceAudioUrl(audio: ReferenceAudio) {
if (audio.storageKey) blob = await getMediaBlob(audio.storageKey);
if (!blob && audio.url?.startsWith("blob:")) blob = await (await fetch(audio.url)).blob();
if (!blob) throw new Error("参考音频必须是公网 URL、素材 ID,或本地已保存的音频");
- const file = new File([blob], audio.name || "reference-audio.mp3", { type: audio.type || blob.type || "audio/mpeg" });
- return uploadReferenceMedia(file);
-}
-
-async function uploadReferenceMedia(file: File) {
- const token = useUserStore.getState().token;
- if (!token) throw new Error("使用本地参考素材需要先登录,并在服务端配置 PUBLIC_BASE_URL");
- const body = new FormData();
- body.append("file", file, file.name);
- const response = await axios.post>("/api/v1/media/references", body, { headers: { Authorization: `Bearer ${token}` } });
- const payload = unwrapEnvelope(response.data, "参考素材上传失败");
- if (!payload.url) throw new Error("参考素材上传后没有返回公网 URL");
- return payload.url;
+ return blobToDataUrl(blob);
}
async function videoResultFromUrl(url: string): Promise {
@@ -256,8 +225,8 @@ async function videoResultFromUrl(url: string): Promise {
function assertVideoConfig(config: AiConfig, model: string) {
if (!model) throw new Error("请先配置视频模型");
- if (config.channelMode === "local" && !config.baseUrl.trim()) throw new Error("请先配置 Base URL");
- if (config.channelMode === "local" && !config.apiKey.trim()) throw new Error("请先配置 API Key");
+ if (!config.baseUrl.trim()) throw new Error("请先配置 Base URL");
+ if (!config.apiKey.trim()) throw new Error("请先配置 API Key");
}
function normalizeVideoSeconds(value: string) {
@@ -330,3 +299,12 @@ function isPublicMediaUrl(value: string) {
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+
+function blobToDataUrl(blob: Blob) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result || ""));
+ reader.onerror = () => reject(new Error("读取本地素材失败"));
+ reader.readAsDataURL(blob);
+ });
+}
diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts
index c3ba817..0a61927 100644
--- a/web/src/stores/use-config-store.ts
+++ b/web/src/stores/use-config-store.ts
@@ -4,7 +4,6 @@ import { useMemo } from "react";
import { create } from "zustand";
import { persist } from "zustand/middleware";
-import { apiGet } from "@/services/api/request";
import type { AdminPublicSettings } from "@/services/api/admin";
export type AiConfig = {
@@ -102,40 +101,6 @@ type ConfigStore = {
clearPromptContinue: () => void;
};
-function resolveEffectiveConfig(config: AiConfig, modelChannel: AdminPublicSettings["modelChannel"] | null) {
- const channelMode = modelChannel?.allowCustomChannel ? config.channelMode : "remote";
- if (channelMode === "local" || !modelChannel) return { ...config, channelMode };
- const models = modelChannel.availableModels;
- const textModels = filterModelsByCapability(models, "text");
- const imageModels = filterModelsByCapability(models, "image");
- const videoModels = filterModelsByCapability(models, "video");
- const audioModels = filterModelsByCapability(models, "audio");
- const fallbackTextModel = validDefault(modelChannel.defaultTextModel, textModels) || preferredModel(textModels, isTextModelName);
- const fallbackModel = validDefault(modelChannel.defaultModel, textModels) || fallbackTextModel;
- const fallbackImageModel = validDefault(modelChannel.defaultImageModel, imageModels) || preferredModel(imageModels, isImageModelName);
- const fallbackVideoModel = validDefault(modelChannel.defaultVideoModel, videoModels) || preferredModel(videoModels, isVideoModelName);
- const fallbackAudioModel = preferredModel(audioModels, isAudioModelName);
- return {
- ...config,
- channelMode,
- models,
- imageModels,
- videoModels,
- textModels,
- audioModels,
- model: textModels.includes(config.model) ? config.model : fallbackModel,
- imageModel: imageModels.includes(config.imageModel) ? config.imageModel : fallbackImageModel,
- videoModel: videoModels.includes(config.videoModel) ? config.videoModel : fallbackVideoModel,
- textModel: textModels.includes(config.textModel) ? config.textModel : fallbackTextModel || fallbackModel,
- audioModel: audioModels.includes(config.audioModel) ? config.audioModel : fallbackAudioModel,
- systemPrompt: modelChannel.systemPrompt,
- };
-}
-
-function validDefault(model: string, models: string[]) {
- return models.includes(model) ? model : "";
-}
-
function preferredModel(models: string[], predicate: (model: string) => boolean) {
return models.find(predicate) || "";
}
@@ -181,7 +146,7 @@ function modelListKey(capability: ModelCapability) {
}
function isAiConfigReady(config: AiConfig, model: string) {
- return Boolean(model.trim()) && (config.channelMode === "remote" || Boolean(config.baseUrl.trim() && config.apiKey.trim()));
+ return Boolean(model.trim() && config.baseUrl.trim() && config.apiKey.trim());
}
export const useConfigStore = create()(
@@ -211,7 +176,7 @@ export const useConfigStore = create()(
if (get().isPublicSettingsLoading) return;
set({ isPublicSettingsLoading: true });
try {
- set({ publicSettings: await apiGet("/api/settings") });
+ set({ publicSettings: defaultPublicSettings(get().config) });
} finally {
set({ isPublicSettingsLoading: false });
}
@@ -234,7 +199,7 @@ export const useConfigStore = create()(
webdav: { ...defaultWebdavSyncConfig, ...persistedWebdav },
config: {
...config,
- channelMode: config.channelMode || "remote",
+ channelMode: "local",
imageModel: config.imageModel || config.model,
videoModel: config.videoModel || "grok-imagine-video",
textModel: config.textModel || config.model,
@@ -265,8 +230,23 @@ function normalizeModelList(models: string[]) {
export function useEffectiveConfig() {
const config = useConfigStore((state) => state.config);
- const modelChannel = useConfigStore((state) => state.publicSettings?.modelChannel || null);
- return useMemo(() => resolveEffectiveConfig(config, modelChannel), [config, modelChannel]);
+ return useMemo(() => ({ ...config, channelMode: "local" as const }), [config]);
+}
+
+function defaultPublicSettings(config: AiConfig): AdminPublicSettings {
+ return {
+ modelChannel: {
+ availableModels: config.models,
+ modelCosts: [],
+ defaultModel: config.model,
+ defaultImageModel: config.imageModel,
+ defaultVideoModel: config.videoModel,
+ defaultTextModel: config.textModel,
+ systemPrompt: config.systemPrompt,
+ allowCustomChannel: true,
+ },
+ auth: { allowRegister: false, linuxDo: { enabled: false } },
+ };
}
export function buildApiUrl(baseUrl: string, path: string) {
diff --git a/web/src/stores/use-user-store.ts b/web/src/stores/use-user-store.ts
index 14628e3..f107230 100644
--- a/web/src/stores/use-user-store.ts
+++ b/web/src/stores/use-user-store.ts
@@ -3,7 +3,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
-import { AUTH_TOKEN_KEY, fetchCurrentUser, login, register, type AuthPayload, type AuthUser } from "@/services/api/auth";
+import { AUTH_TOKEN_KEY, type AuthPayload, type AuthUser } from "@/services/api/auth";
type UserStore = {
token: string;
@@ -27,44 +27,17 @@ export const useUserStore = create()(
setSession: (token, user) => set({ token, user, isReady: true }),
clearSession: () => set({ token: "", user: null, isReady: true }),
hydrateUser: async () => {
- const token = get().token;
- if (!token) {
- set({ user: null, isReady: true });
- return;
- }
- set({ isLoading: true });
- try {
- const user = await fetchCurrentUser(token);
- if (user.role === "guest") {
- set({ token: "", user: null, isReady: true, isLoading: false });
- return;
- }
- set({ user, isReady: true, isLoading: false });
- } catch {
- set({ token: "", user: null, isReady: true, isLoading: false });
- }
+ set({ token: "", user: null, isReady: true, isLoading: false });
},
login: async (payload) => {
set({ isLoading: true });
- try {
- const session = await login(payload);
- set({ token: session.token, user: session.user, isReady: true, isLoading: false });
- return session.user;
- } catch (error) {
- set({ isLoading: false });
- throw error;
- }
+ const now = new Date().toISOString();
+ const user: AuthUser = { id: "local-user", username: payload.username || "local", displayName: payload.username || "本地用户", avatarUrl: "", role: "user", credits: 0, createdAt: now, updatedAt: now };
+ set({ token: "", user, isReady: true, isLoading: false });
+ return user;
},
register: async (payload) => {
- set({ isLoading: true });
- try {
- const session = await register(payload);
- set({ token: session.token, user: session.user, isReady: true, isLoading: false });
- return session.user;
- } catch (error) {
- set({ isLoading: false });
- throw error;
- }
+ return get().login(payload);
},
}),
{