feat(platform): 开源发布 - 初始化 Open AI Canvas

This commit is contained in:
ddCat committed 2026-07-22 14:29:10 +08:00
commit aea35896eb
365 files changed
+66410

No files matched your search

@@ -0,0 +1,65 @@
import { create } from "zustand";
import type { CanvasAgentOp } from "@/lib/canvas/canvas-agent-ops";
export type AgentChatRole = "user" | "assistant" | "system" | "tool" | "error";
export type AgentAttachment = { id: string; name: string; type: string; size: number; url: string; dataUrl: string };
export type AgentChatItem = { id: string; role: AgentChatRole; title?: string; text: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string };
export type AgentEventLog = { id: string; time: string; title: string; text: string; raw?: unknown };
export type AgentPendingToolCall = { requestId: string; name: string; input?: { ops?: CanvasAgentOp[] } };
export type AgentThreadSummary = { id: string; preview: string; name?: string | null; cwd?: string; status?: string; source?: unknown; createdAt?: number; updatedAt?: number };
export type AgentPanelTab = "chat" | "setup" | "history" | "log";
type CanvasAgentStore = {
width: number;
url: string;
token: string;
connected: boolean;
enabled: boolean;
prompt: string;
attachments: AgentAttachment[];
sending: boolean;
waiting: boolean;
messages: AgentChatItem[];
eventLogs: AgentEventLog[];
threads: AgentThreadSummary[];
activeThreadId: string;
workspacePath: string;
loadingThreads: boolean;
activeTab: AgentPanelTab;
confirmTools: boolean;
activity: string;
connectError: string;
pendingTool: AgentPendingToolCall | null;
setAgentState: (patch: Partial<Omit<CanvasAgentStore, "setAgentState" | "addMessage" | "addEventLog" | "clearEventLogs">>) => void;
addMessage: (item: AgentChatItem) => void;
addEventLog: (item: AgentEventLog) => void;
clearEventLogs: () => void;
};
export const useCanvasAgentStore = create<CanvasAgentStore>((set) => ({
width: typeof window === "undefined" ? 440 : Number(localStorage.getItem("canvas-agent-panel-width")) || 440,
url: typeof window === "undefined" ? "http://127.0.0.1:17371" : localStorage.getItem("canvas-agent-url") || "http://127.0.0.1:17371",
token: typeof window === "undefined" ? "" : localStorage.getItem("canvas-agent-token") || "",
connected: false,
enabled: false,
prompt: "",
attachments: [],
sending: false,
waiting: false,
messages: [],
eventLogs: [],
threads: [],
activeThreadId: "",
workspacePath: "",
loadingThreads: false,
activeTab: "setup",
confirmTools: true,
activity: "就绪",
connectError: "",
pendingTool: null,
setAgentState: (patch) => set(patch),
addMessage: (item) => set((state) => ({ messages: [...state.messages.slice(-120), item] })),
addEventLog: (item) => set((state) => ({ eventLogs: [...state.eventLogs.slice(-160), item] })),
clearEventLogs: () => set({ eventLogs: [] }),
}));
+154
View File
@@ -0,0 +1,154 @@
import { create } from "zustand";
import { persist, type PersistStorage, type StorageValue } from "zustand/middleware";
import { nanoid } from "nanoid";
import { localForageStorage } from "@/lib/localforage-storage";
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
import type { DirectorScene } from "@/types/director";
export type CanvasProject = {
id: string;
title: string;
createdAt: string;
updatedAt: string;
nodes: CanvasNodeData[];
connections: CanvasConnection[];
chatSessions: CanvasAssistantSession[];
activeChatId: string | null;
backgroundMode: CanvasBackgroundMode;
showImageInfo: boolean;
viewport: ViewportTransform;
directorScenes: DirectorScene[];
};
type CanvasStore = {
hydrated: boolean;
projects: CanvasProject[];
createProject: (title?: string) => string;
importProject: (project: Partial<CanvasProject>) => string;
openProject: (id: string) => CanvasProject | null;
renameProject: (id: string, title: string) => void;
deleteProjects: (ids: string[]) => void;
replaceProjects: (projects: CanvasProject[]) => void;
updateProject: (id: string, patch: Partial<Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId" | "backgroundMode" | "showImageInfo" | "viewport" | "directorScenes">>) => void;
};
const initialViewport: ViewportTransform = { x: 0, y: 0, k: 1 };
export const CANVAS_STORE_KEY = "infinite-canvas:canvas_store";
type PersistedCanvasState = Pick<CanvasStore, "projects">;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let queuedPersistState: PersistedCanvasState | null = null;
let queuedPersistName = CANVAS_STORE_KEY;
let queuedPersistValue: string | null = null;
const canvasStorage: PersistStorage<CanvasStore> = {
getItem: async (name) => {
const value = await localForageStorage.getItem(name);
if (!value) return null;
const parsed = JSON.parse(value) as StorageValue<CanvasStore>;
queuedPersistState = parsed.state as PersistedCanvasState;
return parsed;
},
setItem: (name, value) => {
const nextState = value.state as PersistedCanvasState;
if (queuedPersistState && queuedPersistState.projects === nextState.projects) return;
queuedPersistState = nextState;
queuedPersistName = name;
queuedPersistValue = JSON.stringify(value);
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
const payload = queuedPersistValue;
queuedPersistValue = null;
if (payload) void localForageStorage.setItem(queuedPersistName, payload);
}, 400);
},
removeItem: (name) => localForageStorage.removeItem(name),
};
export async function flushCanvasStorePersistence() {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
const payload = queuedPersistValue;
queuedPersistValue = null;
if (payload) await localForageStorage.setItem(queuedPersistName, payload);
}
export const useCanvasStore = create<CanvasStore>()(
persist(
(set, get) => ({
hydrated: false,
projects: [],
createProject: (title = "未命名画布") => {
const now = new Date().toISOString();
const id = nanoid();
const project: CanvasProject = {
id,
title,
createdAt: now,
updatedAt: now,
nodes: [],
connections: [],
chatSessions: [],
activeChatId: null,
backgroundMode: "lines",
showImageInfo: false,
viewport: initialViewport,
directorScenes: [],
};
set((state) => ({ projects: [project, ...state.projects] }));
return id;
},
importProject: (source) => {
const now = new Date().toISOString();
const project: CanvasProject = {
id: nanoid(),
title: source.title || "导入画布",
createdAt: source.createdAt || now,
updatedAt: now,
nodes: source.nodes || [],
connections: source.connections || [],
chatSessions: source.chatSessions || [],
activeChatId: source.activeChatId || null,
backgroundMode: source.backgroundMode || "lines",
showImageInfo: source.showImageInfo || false,
viewport: source.viewport || initialViewport,
directorScenes: source.directorScenes || [],
};
set((state) => ({ projects: [project, ...state.projects] }));
return project.id;
},
openProject: (id) => {
return get().projects.find((item) => item.id === id) || null;
},
renameProject: (id, title) =>
set((state) => ({
projects: state.projects.map((project) => (project.id === id ? { ...project, title: title.trim() || project.title, updatedAt: new Date().toISOString() } : project)),
})),
deleteProjects: (ids) =>
set((state) => {
const projects = state.projects.filter((project) => !ids.includes(project.id));
return { projects };
}),
replaceProjects: (projects) => set({ projects }),
updateProject: (id, patch) =>
set((state) => ({
projects: state.projects.map((project) => (project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project)),
})),
}),
{
name: CANVAS_STORE_KEY,
storage: canvasStorage,
partialize: (state) =>
({
projects: state.projects,
}) as StorageValue<CanvasStore>["state"],
onRehydrateStorage: () => () => {
useCanvasStore.setState({ hydrated: true });
},
},
),
);
@@ -0,0 +1,27 @@
import { create } from "zustand";
type CanvasUiStore = {
editingProjectId: string | null;
editingProjectTitle: string;
selectedProjectIds: string[];
deleteProjectIds: string[];
startEditingProject: (id: string, title: string) => void;
setEditingProjectTitle: (title: string) => void;
stopEditingProject: () => void;
toggleSelectedProjectId: (id: string, selected: boolean) => void;
setDeleteProjectIds: (ids: string[]) => void;
removeSelectedProjectIds: (ids: string[]) => void;
};
export const useCanvasUiStore = create<CanvasUiStore>((set) => ({
editingProjectId: null,
editingProjectTitle: "",
selectedProjectIds: [],
deleteProjectIds: [],
startEditingProject: (editingProjectId, editingProjectTitle) => set({ editingProjectId, editingProjectTitle }),
setEditingProjectTitle: (editingProjectTitle) => set({ editingProjectTitle }),
stopEditingProject: () => set({ editingProjectId: null }),
toggleSelectedProjectId: (id, selected) => set((state) => ({ selectedProjectIds: selected ? [...new Set([...state.selectedProjectIds, id])] : state.selectedProjectIds.filter((item) => item !== id) })),
setDeleteProjectIds: (deleteProjectIds) => set({ deleteProjectIds }),
removeSelectedProjectIds: (ids) => set((state) => ({ selectedProjectIds: state.selectedProjectIds.filter((id) => !ids.includes(id)) })),
}));
@@ -0,0 +1,32 @@
import { create } from "zustand";
import type { DirectorRenderMode } from "@/types/director";
type DirectorWorkbenchStore = {
selectedObjectId: string | null;
selectedLightId: string | null;
transformMode: "translate" | "rotate" | "scale";
renderMode: DirectorRenderMode;
playhead: number;
playing: boolean;
setSelectedObjectId: (id: string | null) => void;
setSelectedLightId: (id: string | null) => void;
setTransformMode: (mode: DirectorWorkbenchStore["transformMode"]) => void;
setRenderMode: (mode: DirectorRenderMode) => void;
setPlayhead: (time: number) => void;
setPlaying: (playing: boolean) => void;
reset: () => void;
};
const initialState = { selectedObjectId: null, selectedLightId: null, transformMode: "translate" as const, renderMode: "beauty" as const, playhead: 0, playing: false };
export const useDirectorWorkbenchStore = create<DirectorWorkbenchStore>((set) => ({
...initialState,
setSelectedObjectId: (selectedObjectId) => set({ selectedObjectId, selectedLightId: null }),
setSelectedLightId: (selectedLightId) => set({ selectedLightId, selectedObjectId: null }),
setTransformMode: (transformMode) => set({ transformMode }),
setRenderMode: (renderMode) => set({ renderMode }),
setPlayhead: (playhead) => set({ playhead }),
setPlaying: (playing) => set({ playing }),
reset: () => set(initialState),
}));
+107
View File
@@ -0,0 +1,107 @@
import { create } from "zustand";
import { persist, type PersistStorage, type StorageValue } from "zustand/middleware";
import { nanoid } from "nanoid";
import { localForageStorage } from "@/lib/localforage-storage";
import { cleanupUnusedImages, resolveImageUrl, uploadImage } from "@/services/image-storage";
import { cleanupUnusedMedia, resolveMediaUrl } from "@/services/file-storage";
export type AssetKind = "text" | "image" | "video" | "model";
export type TextAsset = AssetBase<"text"> & { data: { content: string } };
export type ImageAsset = AssetBase<"image"> & { data: { dataUrl: string; storageKey?: string; width: number; height: number; bytes: number; mimeType: string } };
export type VideoAsset = AssetBase<"video"> & { data: { url: string; storageKey?: string; width: number; height: number; bytes: number; mimeType: string } };
export type ModelAsset = AssetBase<"model"> & { data: { url: string; storageKey?: string; bytes: number; mimeType: string; fileName: string } };
export type Asset = TextAsset | ImageAsset | VideoAsset | ModelAsset;
type AssetBase<T extends AssetKind> = {
id: string;
kind: T;
title: string;
coverUrl: string;
tags: string[];
source?: string;
note?: string;
createdAt: string;
updatedAt: string;
metadata?: Record<string, unknown>;
};
type AssetStore = {
hydrated: boolean;
assets: Asset[];
addAsset: (asset: Omit<Asset, "id" | "createdAt" | "updatedAt">) => string;
updateAsset: (id: string, patch: Partial<Omit<Asset, "id" | "createdAt">>) => void;
removeAsset: (id: string) => void;
replaceAssets: (assets: Asset[]) => void;
cleanupImages: (extra?: unknown) => void;
};
export const ASSET_STORE_KEY = "infinite-canvas:asset_store";
const assetStorage: PersistStorage<AssetStore> = {
getItem: async (name) => {
const value = await localForageStorage.getItem(name);
if (!value) return null;
const parsed = JSON.parse(value) as StorageValue<AssetStore>;
parsed.state.assets = await Promise.all(
parsed.state.assets.map(async (asset) => {
if (asset.kind === "video" && asset.data.storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(asset.data.storageKey, asset.data.url) } };
if (asset.kind === "model" && asset.data.storageKey) return { ...asset, data: { ...asset.data, url: await resolveMediaUrl(asset.data.storageKey, asset.data.url) } };
if (asset.kind !== "image") return asset;
if (asset.data.storageKey)
return {
...asset,
coverUrl: asset.coverUrl.startsWith("blob:") ? await resolveImageUrl(asset.data.storageKey, asset.coverUrl) : asset.coverUrl,
data: { ...asset.data, dataUrl: await resolveImageUrl(asset.data.storageKey, asset.data.dataUrl) },
};
if (!asset.data.dataUrl.startsWith("data:image/")) return asset;
const image = await uploadImage(asset.data.dataUrl);
return { ...asset, coverUrl: asset.coverUrl.startsWith("data:image/") ? image.url : asset.coverUrl, data: { ...asset.data, dataUrl: image.url, storageKey: image.storageKey, bytes: image.bytes, mimeType: image.mimeType } };
}),
);
return parsed;
},
setItem: (name, value) => localForageStorage.setItem(name, JSON.stringify(value)),
removeItem: (name) => localForageStorage.removeItem(name),
};
export const useAssetStore = create<AssetStore>()(
persist(
(set, get) => ({
hydrated: false,
assets: [],
addAsset: (asset) => {
const now = new Date().toISOString();
const id = nanoid();
set((state) => ({ assets: [{ ...asset, id, createdAt: now, updatedAt: now } as Asset, ...state.assets] }));
return id;
},
updateAsset: (id, patch) =>
set((state) => ({
assets: state.assets.map((asset) => (asset.id === id ? ({ ...asset, ...patch, updatedAt: new Date().toISOString() } as Asset) : asset)),
})),
removeAsset: (id) =>
set((state) => {
const assets = state.assets.filter((asset) => asset.id !== id);
get().cleanupImages({ assets });
return { assets };
}),
replaceAssets: (assets) => set({ assets }),
cleanupImages: (extra) => {
window.setTimeout(async () => {
const { useCanvasStore } = await import("@/stores/canvas/use-canvas-store");
await cleanupUnusedImages({ assets: get().assets, projects: useCanvasStore.getState().projects, extra });
await cleanupUnusedMedia({ assets: get().assets, projects: useCanvasStore.getState().projects, extra });
}, 0);
},
}),
{
name: ASSET_STORE_KEY,
storage: assetStorage,
partialize: (state) => ({ assets: state.assets }) as StorageValue<AssetStore>["state"],
onRehydrateStorage: () => () => {
useAssetStore.setState({ hydrated: true });
},
},
),
);
+516
View File
@@ -0,0 +1,516 @@
import { useMemo } from "react";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { nanoid } from "nanoid";
import { scopedLocalStorage } from "@/lib/user-scope";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
export type ApiCallFormat = "openai" | "gemini";
export type ChannelInterfaceType = "chat-completion" | "openai-response" | "openai-image" | "newapi" | "newapi-channel-1" | "newapi-channel-2";
export type ModelChannel = {
id: string;
name: string;
baseUrl: string;
apiKey: string;
apiFormat: ApiCallFormat;
interfaceType?: ChannelInterfaceType;
models: string[];
scope?: "system" | "user";
enabled?: boolean;
hasApiKey?: boolean;
modelCosts?: Array<{
model: string;
capability: ModelCapability;
billingMode: "fixed_request";
unitPriceMicrocredits: number;
}>;
};
export type AiConfig = {
channelMode: "remote" | "local";
baseUrl: string;
apiKey: string;
apiFormat: ApiCallFormat;
channels: ModelChannel[];
model: string;
imageModel: string;
videoModel: string;
textModel: string;
audioModel: string;
audioVoice: string;
audioFormat: string;
audioSpeed: string;
audioInstructions: string;
videoSeconds: string;
vquality: string;
videoGenerateAudio: string;
videoWatermark: string;
systemPrompt: string;
models: string[];
imageModels: string[];
videoModels: string[];
textModels: string[];
audioModels: string[];
quality: string;
size: string;
count: string;
canvasImageCount: string;
};
export const CONFIG_STORE_KEY = "open_ai_canvas:ai_config_store";
export type ModelCapability = "image" | "video" | "text" | "audio";
const CHANNEL_MODEL_SEPARATOR = "::";
const OPENAI_BASE_URL = "https://api.openai.com";
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com";
export const defaultConfig: AiConfig = {
channelMode: "local",
baseUrl: OPENAI_BASE_URL,
apiKey: "",
apiFormat: "openai",
channels: [
{
id: "default",
name: "默认渠道",
baseUrl: OPENAI_BASE_URL,
apiKey: "",
apiFormat: "openai",
models: ["gpt-image-2", "grok-imagine-video", "gpt-5.5", "gpt-4o-mini-tts"],
},
],
model: "default::gpt-image-2",
imageModel: "default::gpt-image-2",
videoModel: "default::grok-imagine-video",
textModel: "default::gpt-5.5",
audioModel: "default::gpt-4o-mini-tts",
audioVoice: "alloy",
audioFormat: "mp3",
audioSpeed: "1",
audioInstructions: "",
videoSeconds: "6",
vquality: "720",
videoGenerateAudio: "true",
videoWatermark: "false",
systemPrompt: "",
models: ["default::gpt-image-2", "default::grok-imagine-video", "default::gpt-5.5", "default::gpt-4o-mini-tts"],
imageModels: ["default::gpt-image-2"],
videoModels: ["default::grok-imagine-video"],
textModels: ["default::gpt-5.5"],
audioModels: ["default::gpt-4o-mini-tts"],
quality: "auto",
size: "1:1",
count: "1",
canvasImageCount: "1",
};
type ConfigStore = {
config: AiConfig;
isConfigOpen: boolean;
shouldPromptContinue: boolean;
updateConfig: <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
replaceConfig: (config: AiConfig) => void;
mergeSystemChannels: (channels: ModelChannel[]) => void;
isAiConfigReady: (config: AiConfig, model: string) => boolean;
configDialogSection?: "channels" | "models" | "preferences" | "storage";
openConfigDialog: (shouldPromptContinue?: boolean, section?: "channels" | "models" | "preferences" | "storage") => void;
setConfigDialogOpen: (isOpen: boolean) => void;
clearPromptContinue: () => void;
};
export type ConfigStoreSnapshot = {
config?: Partial<AiConfig>;
};
function isVideoModelName(model: string) {
const value = modelOptionName(model).toLowerCase();
return value.includes("seedance") || value.includes("video") || value.includes("sora") || value.includes("veo") || value.includes("kling") || value.includes("wan") || value.includes("hailuo");
}
function isImageModelName(model: string) {
const value = modelOptionName(model).toLowerCase();
return (
!isVideoModelName(model) &&
!isAudioModelName(model) &&
(value.includes("seedream") ||
value.includes("gpt-image") ||
value.includes("image") ||
value.includes("dall-e") ||
value.includes("dalle") ||
value.includes("imagen") ||
value.includes("flux") ||
value.includes("sdxl") ||
value.includes("stable-diffusion") ||
value.includes("midjourney"))
);
}
function isAudioModelName(model: string) {
const value = modelOptionName(model).toLowerCase();
return value.includes("audio") || value.includes("tts") || value.includes("speech") || value.includes("voice") || value.includes("music") || value.includes("sound");
}
function isTextModelName(model: string) {
return !isImageModelName(model) && !isVideoModelName(model) && !isAudioModelName(model);
}
export function modelMatchesCapability(model: string, capability?: ModelCapability) {
if (!capability) return true;
if (capability === "image") return isImageModelName(model);
if (capability === "video") return isVideoModelName(model);
if (capability === "audio") return isAudioModelName(model);
return isTextModelName(model);
}
export function filterModelsByCapability(models: string[], capability?: ModelCapability, channels?: ModelChannel[]) {
if (!capability) return models;
return models.filter((model) => {
const decoded = decodeChannelModel(model);
const channel = decoded ? channels?.find((item) => item.id === decoded.channelId) : undefined;
const channelCapability = capabilityForChannelInterface(channel?.interfaceType);
return channelCapability ? channelCapability === capability : modelMatchesCapability(model, capability);
});
}
export function selectableModelsByCapability(config: AiConfig, capability?: ModelCapability) {
if (!capability) return config.models;
return config[modelListKey(capability)];
}
export function configuredModelMatchesCapability(config: AiConfig, model: string, capability?: ModelCapability) {
const normalized = normalizeModelOptionValue(model, config.channels);
if (!normalized || !config.models.includes(normalized)) return false;
return capability ? selectableModelsByCapability(config, capability).includes(normalized) : true;
}
function modelListKey(capability: ModelCapability) {
return `${capability}Models` as "imageModels" | "videoModels" | "textModels" | "audioModels";
}
function isAiConfigReady(config: AiConfig, model: string) {
const channel = resolveModelChannel(config, model);
return Boolean(model.trim() && channel.baseUrl.trim() && channel.apiKey.trim());
}
export const useConfigStore = create<ConfigStore>()(
persist(
(set) => ({
config: defaultConfig,
isConfigOpen: false,
shouldPromptContinue: false,
configDialogSection: undefined,
updateConfig: (key, value) =>
set((state) => ({
config: {
...state.config,
[key]: value,
},
})),
replaceConfig: (config) => set({ config }),
mergeSystemChannels: (channels) =>
set((state) => {
const systemChannels = channels.map((channel, index) =>
createModelChannel({
...channel,
id: channel.id || `system-${index + 1}`,
name: channel.name || `系统渠道 ${index + 1}`,
scope: "system",
apiKey: channel.apiKey || "system",
}),
);
const userChannels = state.config.channels.filter((channel) => channel.scope !== "system");
return normalizeConfigSnapshot({ config: { ...state.config, channels: [...systemChannels, ...userChannels] } });
}),
isAiConfigReady: (config, model) => isAiConfigReady(config, model),
openConfigDialog: (shouldPromptContinue = false, configDialogSection) => set({ isConfigOpen: true, shouldPromptContinue, configDialogSection }),
setConfigDialogOpen: (isConfigOpen) => set({ isConfigOpen }),
clearPromptContinue: () => set({ shouldPromptContinue: false }),
}),
{
name: CONFIG_STORE_KEY,
storage: createJSONStorage(() => scopedLocalStorage),
partialize: (state) => ({ config: state.config }),
merge: (persisted, current) => {
const persistedState = (persisted || {}) as Partial<ConfigStore>;
return {
...current,
...normalizeConfigSnapshot({ config: persistedState.config }),
};
},
},
),
);
export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot) {
const persistedConfig = (snapshot.config || {}) as Partial<AiConfig>;
const config = { ...defaultConfig, ...persistedConfig };
const hasPersistedChannels = Array.isArray(persistedConfig.channels);
if (!hasPersistedChannels) config.channels = [];
const channels = normalizeChannels(config, !hasPersistedChannels);
const models = modelOptionsFromChannels(channels);
const imageModels = normalizeSelectableModelList(persistedConfig.imageModels, channels, models, "image");
const videoModels = normalizeSelectableModelList(persistedConfig.videoModels, channels, models, "video");
const textModels = normalizeSelectableModelList(persistedConfig.textModels, channels, models, "text");
const audioModels = normalizeSelectableModelList(persistedConfig.audioModels, channels, models, "audio");
const model = normalizeSelectedModel(config.model || config.imageModel || config.textModel, channels, models);
return {
config: {
...config,
channelMode: "local" as const,
apiFormat: normalizeApiFormat(config.apiFormat),
channels,
models,
model,
imageModel: normalizeSelectedModel(config.imageModel || model, channels, imageModels),
videoModel: normalizeSelectedModel(config.videoModel || "grok-imagine-video", channels, videoModels),
textModel: normalizeSelectedModel(config.textModel || model, channels, textModels),
audioModel: normalizeSelectedModel(config.audioModel || defaultConfig.audioModel, channels, audioModels),
audioVoice: config.audioVoice || defaultConfig.audioVoice,
audioFormat: config.audioFormat || defaultConfig.audioFormat,
audioSpeed: config.audioSpeed || defaultConfig.audioSpeed,
audioInstructions: config.audioInstructions || "",
videoSeconds: normalizeVideoDuration(config.videoSeconds),
vquality: normalizeVideoResolution(config.vquality),
videoGenerateAudio: config.videoGenerateAudio || "true",
videoWatermark: config.videoWatermark || "false",
canvasImageCount: config.canvasImageCount || defaultConfig.canvasImageCount,
imageModels,
videoModels,
textModels,
audioModels,
},
};
}
function normalizeModelList(models: string[], channels: ModelChannel[]) {
const allModelOptions = channels.flatMap((channel) => channel.models.map((model) => encodeChannelModel(channel.id, model)));
return Array.from(new Set((models || []).map((model) => normalizeModelOptionValue(model, channels)).filter(Boolean)))
.filter((model) => allModelOptions.includes(model));
}
function normalizeSelectedModel(value: string, channels: ModelChannel[], options: string[]) {
const model = normalizeModelOptionValue(value, channels);
return model && options.includes(model) ? model : options[0] || "";
}
export function useEffectiveConfig() {
const config = useConfigStore((state) => state.config);
return useMemo(() => ({ ...config, channelMode: "local" as const }), [config]);
}
export function createModelChannel(channel?: Partial<ModelChannel>): ModelChannel {
const apiFormat = normalizeApiFormat(channel?.apiFormat);
const interfaceType = normalizeChannelInterfaceType(channel?.interfaceType);
const providedBaseUrl = channel?.baseUrl?.trim();
return {
id: channel?.id?.trim() || nanoid(),
name: channel?.name?.trim() || "新渠道",
baseUrl: providedBaseUrl || (interfaceType ? defaultBaseUrlForChannelInterface(interfaceType) : defaultBaseUrlForApiFormat(apiFormat)),
apiKey: channel?.apiKey || "",
apiFormat,
interfaceType,
models: uniqueRawModels(channel?.models || []),
scope: channel?.scope === "system" ? "system" : "user",
enabled: channel?.enabled !== false,
hasApiKey: channel?.hasApiKey,
modelCosts: channel?.modelCosts,
};
}
export function encodeChannelModel(channelId: string, model: string) {
return `${channelId}${CHANNEL_MODEL_SEPARATOR}${model.trim()}`;
}
export function isChannelModelValue(value: string) {
return value.includes(CHANNEL_MODEL_SEPARATOR);
}
export function decodeChannelModel(value: string) {
const index = value.indexOf(CHANNEL_MODEL_SEPARATOR);
if (index < 0) return null;
return { channelId: value.slice(0, index), model: value.slice(index + CHANNEL_MODEL_SEPARATOR.length) };
}
export function modelOptionName(value: string) {
return decodeChannelModel(value)?.model || value;
}
export function modelOptionLabel(config: AiConfig, value: string) {
const decoded = decodeChannelModel(value);
if (!decoded) return value;
const channel = config.channels.find((item) => item.id === decoded.channelId);
return channel ? `${decoded.model}(${channel.name})` : decoded.model;
}
export function modelOptionsFromChannels(channels: ModelChannel[]) {
return uniqueModelOptions(
channels.flatMap((channel) =>
channel.models
.map(normalizeRawModelName)
.filter(Boolean)
.filter((model) => channel.scope !== "system" || hasSystemModelPrice(channel, model))
.map((model) => encodeChannelModel(channel.id, model)),
),
);
}
export function hasSystemModelPrice(channel: ModelChannel, model: string) {
if (channel.scope !== "system") return true;
return channel.modelCosts?.some((item) => item.model === model && Number.isFinite(item.unitPriceMicrocredits) && item.unitPriceMicrocredits >= 0) === true;
}
export function normalizeModelOptionValue(value: unknown, channels: ModelChannel[]) {
const model = typeof value === "string" ? value.trim() : "";
if (!normalizeRawModelName(model)) return "";
const decoded = decodeChannelModel(model);
if (decoded) {
const channel = channels.find((item) => item.id === decoded.channelId);
return channel && channel.models.includes(decoded.model) ? model : "";
}
const channel = channels.find((item) => item.models.includes(model)) || channels[0];
return channel && channel.models.includes(model) ? encodeChannelModel(channel.id, model) : "";
}
export function resolveModelChannel(config: AiConfig, value: string) {
const decoded = decodeChannelModel(value);
const model = decoded?.model || value;
const matched = decoded ? config.channels.find((channel) => channel.id === decoded.channelId) : config.channels.find((channel) => channel.models.includes(model));
return matched || config.channels[0] || createModelChannel({ id: "default", name: "默认渠道", baseUrl: config.baseUrl, apiKey: config.apiKey, apiFormat: config.apiFormat, models: config.models.map(modelOptionName) });
}
export function resolveModelRequestConfig(config: AiConfig, value: string) {
const channel = resolveModelChannel(config, value);
return {
...config,
model: modelOptionName(value || config.model),
baseUrl: channel.baseUrl,
apiKey: channel.apiKey,
apiFormat: channel.apiFormat,
interfaceType: channel.interfaceType,
channelId: channel.scope === "system" ? channel.id : "",
};
}
function normalizeChannels(config: AiConfig, ensureDefault = true) {
const persistedChannels = Array.isArray(config.channels) ? config.channels : [];
const channels = persistedChannels
.map((channel, index) =>
createModelChannel({
...channel,
id: channel.id || (index === 0 ? "default" : `channel-${index + 1}`),
name: channel.name || (index === 0 ? "默认渠道" : `渠道 ${index + 1}`),
models: uniqueRawModels(channel.models || []),
}),
)
.filter((channel) => !isEmptyDefaultChannel(channel));
if (!channels.length && ensureDefault && config.apiKey.trim()) {
channels.push(
createModelChannel({
id: "default",
name: "默认渠道",
baseUrl: config.baseUrl || defaultConfig.baseUrl,
apiKey: config.apiKey || "",
apiFormat: config.apiFormat || defaultConfig.apiFormat,
models: uniqueRawModels([...(config.models || []), config.model, config.imageModel, config.videoModel, config.textModel, config.audioModel]),
}),
);
}
return channels.map((channel) => ({ ...channel, models: uniqueRawModels(channel.models) }));
}
function normalizeSelectableModelList(value: string[] | undefined, channels: ModelChannel[], models: string[], capability: ModelCapability) {
const suggested = filterModelsByCapability(models, capability, channels);
if (!Array.isArray(value)) return suggested;
// 字段缺失表示旧配置尚未选择过;显式空数组表示用户主动清空,不能重新补回推荐模型。
return normalizeModelList(value, channels);
}
function isEmptyDefaultChannel(channel: ModelChannel) {
if (channel.scope === "system") return false;
if (channel.id !== "default" || channel.name.trim() !== "默认渠道" || channel.apiKey.trim()) return false;
const baseUrl = channel.baseUrl.trim().replace(/\/+$/, "");
const defaultBaseUrl = defaultConfig.baseUrl.trim().replace(/\/+$/, "");
if (baseUrl && baseUrl !== defaultBaseUrl) return false;
const defaultModels = new Set((defaultConfig.channels[0]?.models || []).map(modelOptionName));
return !channel.models.length || channel.models.every((model) => defaultModels.has(modelOptionName(model)));
}
export function defaultBaseUrlForApiFormat(apiFormat: ApiCallFormat) {
return apiFormat === "gemini" ? GEMINI_BASE_URL : OPENAI_BASE_URL;
}
export function defaultBaseUrlForChannelInterface(interfaceType?: ChannelInterfaceType) {
if (interfaceType === "newapi" || interfaceType === "newapi-channel-1" || interfaceType === "newapi-channel-2") return "";
return OPENAI_BASE_URL;
}
function capabilityForChannelInterface(interfaceType?: ChannelInterfaceType): ModelCapability | undefined {
if (interfaceType === "chat-completion" || interfaceType === "openai-response") return "text";
if (interfaceType === "openai-image") return "image";
if (interfaceType === "newapi" || interfaceType === "newapi-channel-1" || interfaceType === "newapi-channel-2") return "video";
return undefined;
}
function normalizeApiFormat(apiFormat: unknown): ApiCallFormat {
return apiFormat === "gemini" ? "gemini" : "openai";
}
function normalizeChannelInterfaceType(value: unknown): ChannelInterfaceType | undefined {
return value === "chat-completion" || value === "openai-response" || value === "openai-image" || value === "newapi" || value === "newapi-channel-1" || value === "newapi-channel-2" ? value : undefined;
}
function uniqueRawModels(models: string[]) {
return Array.from(new Set((models || []).map(normalizeRawModelName).filter(Boolean)));
}
function uniqueModelOptions(models: string[]) {
return Array.from(new Set((models || []).filter((model): model is string => typeof model === "string").map((model) => model.trim()).filter(Boolean)));
}
function normalizeRawModelName(value: unknown) {
if (typeof value !== "string") return "";
const model = modelOptionName(value).trim();
return model && model !== "undefined" && model !== "null" ? model : "";
}
export function buildApiUrl(baseUrl: string, path: string) {
let normalizedBaseUrl = resolveBackendApiUrl(baseUrl).replace(/\/+$/, "");
normalizedBaseUrl = normalizeArkPlanBaseUrl(normalizedBaseUrl);
const lowerBaseUrl = normalizedBaseUrl.toLowerCase();
const apiBaseUrl = isSystemProxyBaseUrl(normalizedBaseUrl) || lowerBaseUrl.endsWith("/v1") || lowerBaseUrl.endsWith("/api/v3") || lowerBaseUrl.endsWith("/api/plan/v3") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`;
return `${apiBaseUrl}${path}`;
}
export function resolveBackendApiUrl(value: string) {
const url = value.trim();
if (!url.startsWith("/api/")) return url;
const backendBaseUrl = String(import.meta.env.VITE_CANVAS_BACKEND_URL || "/api").trim().replace(/\/+$/, "");
return backendBaseUrl === "/api" ? url : `${backendBaseUrl}${url.slice("/api".length)}`;
}
export function isSystemProxyBaseUrl(baseUrl: string) {
const marker = "/api/ai/system/";
const index = baseUrl.toLowerCase().indexOf(marker);
if (index < 0) return false;
const channelId = baseUrl.slice(index + marker.length);
return Boolean(channelId && !channelId.includes("/") && !channelId.includes("?") && !channelId.includes("#"));
}
function normalizeArkPlanBaseUrl(baseUrl: string) {
try {
const url = new URL(baseUrl);
const path = url.pathname.replace(/\/+$/, "");
const lowerPath = path.toLowerCase();
const arkPlanIndex = lowerPath.indexOf("/api/plan/v3");
if (arkPlanIndex < 0) return baseUrl;
const end = arkPlanIndex + "/api/plan/v3".length;
if (lowerPath.length !== end && lowerPath[end] !== "/") return baseUrl;
url.pathname = path.slice(0, end);
url.search = "";
url.hash = "";
return url.toString().replace(/\/+$/, "");
} catch {
return baseUrl;
}
}
+19
View File
@@ -0,0 +1,19 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type ThemeName = "light" | "dark";
type ThemeStore = {
theme: ThemeName;
setTheme: (theme: ThemeName) => void;
};
export const useThemeStore = create<ThemeStore>()(
persist(
(set) => ({
theme: "dark",
setTheme: (theme) => set({ theme }),
}),
{ name: "infinite-canvas:theme_store" },
),
);
+33
View File
@@ -0,0 +1,33 @@
import { create } from "zustand";
export type LocalUser = {
id: string;
username: string;
email?: string;
displayName: string;
avatarUrl?: string;
identityProvider?: string;
identityId?: string;
identityUsername?: string;
role: "admin" | "user";
status: "active" | "disabled";
lastLoginAt?: string;
createdAt?: string;
updatedAt?: string;
};
type UserStore = {
hydrated: boolean;
user: LocalUser | null;
setUser: (user: LocalUser | null) => void;
setHydrated: (hydrated: boolean) => void;
clearSession: () => void;
};
export const useUserStore = create<UserStore>()((set) => ({
hydrated: false,
user: null,
setUser: (user) => set({ user }),
setHydrated: (hydrated) => set({ hydrated }),
clearSession: () => set({ user: null }),
}));