chore: commit workspace updates
This commit is contained in:
@@ -1,104 +0,0 @@
|
||||
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;
|
||||
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;
|
||||
};
|
||||
|
||||
const CANVAS_AGENT_ENABLED_STORAGE_KEY = "canvas-agent-enabled";
|
||||
|
||||
type CanvasAgentPreferenceStorage = {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
};
|
||||
|
||||
export function readCanvasAgentEnabledPreference(storage: Pick<CanvasAgentPreferenceStorage, "getItem"> | undefined = browserPreferenceStorage()) {
|
||||
try {
|
||||
return storage?.getItem(CANVAS_AGENT_ENABLED_STORAGE_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCanvasAgentEnabledPreference(enabled: boolean, storage: Pick<CanvasAgentPreferenceStorage, "setItem"> | undefined = browserPreferenceStorage()) {
|
||||
try {
|
||||
storage?.setItem(CANVAS_AGENT_ENABLED_STORAGE_KEY, String(enabled));
|
||||
} catch {
|
||||
// 浏览器隐私模式可能拒绝本地偏好写入;连接本身仍可继续。
|
||||
}
|
||||
}
|
||||
|
||||
export function canvasAgentConnectionStartingPatch() {
|
||||
return { enabled: true, connected: false, activity: "连接中", connectError: "", activeTab: "chat" as const };
|
||||
}
|
||||
|
||||
export function canvasAgentTransientDisconnectPatch(activity: string, connectError: string) {
|
||||
return { enabled: true, connected: false, activity, connectError, waiting: false, sending: false };
|
||||
}
|
||||
|
||||
export function canvasAgentConnectionStatusText({ enabled, connected, activity, connectError }: Pick<CanvasAgentStore, "enabled" | "connected" | "activity" | "connectError">) {
|
||||
if (enabled && !connected && activity === "正在重连") return activity;
|
||||
return connectError ? "连接失败" : connected ? activity : enabled ? "连接中" : "未连接";
|
||||
}
|
||||
|
||||
function browserPreferenceStorage(): CanvasAgentPreferenceStorage | undefined {
|
||||
return typeof window === "undefined" ? undefined : window.localStorage;
|
||||
}
|
||||
|
||||
export const useCanvasAgentStore = create<CanvasAgentStore>((set) => ({
|
||||
width: typeof window === "undefined" ? 440 : Number(localStorage.getItem("canvas-agent-panel-width")) || 440,
|
||||
connected: false,
|
||||
enabled: readCanvasAgentEnabledPreference(),
|
||||
prompt: "",
|
||||
attachments: [],
|
||||
sending: false,
|
||||
waiting: false,
|
||||
messages: [],
|
||||
eventLogs: [],
|
||||
threads: [],
|
||||
activeThreadId: "",
|
||||
workspacePath: "",
|
||||
loadingThreads: false,
|
||||
activeTab: "chat",
|
||||
confirmTools: true,
|
||||
activity: "就绪",
|
||||
connectError: "",
|
||||
pendingTool: null,
|
||||
setAgentState: (patch) => {
|
||||
if (typeof patch.enabled === "boolean") writeCanvasAgentEnabledPreference(patch.enabled);
|
||||
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: [] }),
|
||||
}));
|
||||
@@ -3,14 +3,11 @@ import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { projectDesktopLocalChannelRuntime } from "@/lib/desktop-local-channel";
|
||||
import { scopedLocalStorage } from "@/lib/user-scope";
|
||||
import { modelProtocolCapability, normalizeModelProtocol, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
import { workflowFieldRole, workflowFieldSafeToOverride, workflowVideoFieldsFromJson, type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { useLocalDreaminaModelStore } from "@/stores/use-local-dreamina-model-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import type { DreaminaLocalModel } from "@/services/local-dreamina-model-catalog";
|
||||
import type { CapabilitySpec, PublicLogicalModelPriceTier } from "@/services/api/logical-models";
|
||||
|
||||
export type ApiCallFormat = "openai" | "gemini" | "claude";
|
||||
@@ -336,30 +333,11 @@ export type RunningHubConfig = {
|
||||
workflows: RunningHubWorkflow[];
|
||||
};
|
||||
|
||||
export type ComfyBridgeWorkflow = {
|
||||
workflowId: string;
|
||||
title?: string;
|
||||
capability: "image" | "video" | "audio";
|
||||
fields?: WorkflowFieldMapping[];
|
||||
workflowJson?: Record<string, unknown>;
|
||||
workflowGraph?: WorkflowGraphPreview;
|
||||
};
|
||||
|
||||
export type WorkflowGraphPreview = {
|
||||
nodes: Array<{ id: string; title?: string; classType?: string }>;
|
||||
edges: Array<{ from: string; to: string }>;
|
||||
};
|
||||
|
||||
export type ComfyBridgeConfig = {
|
||||
enabled: boolean;
|
||||
bridgeId: string;
|
||||
comfyUrl: string;
|
||||
workflowDir: string;
|
||||
workflowId: string;
|
||||
capability: "image" | "video" | "audio";
|
||||
workflows: ComfyBridgeWorkflow[];
|
||||
};
|
||||
|
||||
// 兼容仍在使用旧目录标识的会话恢复和模型选择器。
|
||||
export const PUBLIC_MODEL_CATALOG_ID = "managed";
|
||||
|
||||
@@ -369,7 +347,6 @@ export type ModelChannel = {
|
||||
publicAlias?: string;
|
||||
sortOrder?: number;
|
||||
baseUrl: string;
|
||||
allowLocalChannel?: boolean;
|
||||
apiKey: string;
|
||||
secretKey?: string;
|
||||
headers?: ChannelHeader[];
|
||||
@@ -403,20 +380,17 @@ export type ModelChannel = {
|
||||
logicalPriceTiers?: PublicLogicalModelPriceTier[];
|
||||
defaultOptions?: Record<string, unknown>;
|
||||
}>;
|
||||
transport?: "backend-channel" | "local-runtime";
|
||||
localModels?: DreaminaLocalModel[];
|
||||
};
|
||||
|
||||
export type AiConfig = {
|
||||
channelMode: "remote" | "local";
|
||||
channelMode: "remote";
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
apiFormat: ApiCallFormat;
|
||||
channels: ModelChannel[];
|
||||
runningHub: RunningHubConfig;
|
||||
comfyBridge: ComfyBridgeConfig;
|
||||
/** 仅用于单次生成任务路由,不属于全局渠道启用状态。 */
|
||||
taskWorkflowProvider?: "model" | "runninghub" | "comfyui";
|
||||
taskWorkflowProvider?: "model" | "runninghub";
|
||||
model: string;
|
||||
imageModel: string;
|
||||
videoModel: string;
|
||||
@@ -452,14 +426,13 @@ const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com";
|
||||
const LEGACY_DEFAULT_MODEL_NAMES = new Set(["gpt-image-2", "grok-imagine-video", "gpt-5.5", "gpt-4o-mini-tts"]);
|
||||
|
||||
export const defaultConfig: AiConfig = {
|
||||
channelMode: "local",
|
||||
channelMode: "remote",
|
||||
baseUrl: OPENAI_BASE_URL,
|
||||
apiKey: "",
|
||||
apiFormat: "openai",
|
||||
// 创作端模型目录只能来自后台公开逻辑模型和用户自定义渠道,不能内置供应商模型。
|
||||
channels: [],
|
||||
runningHub: { enabled: false, baseUrl: "https://www.runninghub.cn", apiKey: "", walletApiKey: "", uploadApiKey: "", useWallet: false, capability: "image", selectedKind: "workflow", workflowId: "", workflows: [] },
|
||||
comfyBridge: { enabled: false, bridgeId: "", comfyUrl: "http://127.0.0.1:8188", workflowDir: "D:\\ComfyUI\\workflows", workflowId: "", capability: "image", workflows: [] },
|
||||
taskWorkflowProvider: "model",
|
||||
model: "",
|
||||
imageModel: "",
|
||||
@@ -576,8 +549,6 @@ export function filterModelsByCapability(models: string[], capability?: ModelCap
|
||||
const decoded = decodeChannelModel(model);
|
||||
const channel = decoded ? channels?.find((item) => item.id === decoded.channelId) : undefined;
|
||||
const modelName = decoded?.model || modelOptionName(model);
|
||||
const local = channel?.localModels?.find((item) => item.id === modelName);
|
||||
if (local) return local.modality === capability;
|
||||
const costEntry = channel?.modelCosts?.find((item) => item.model === modelName);
|
||||
// 协议层优先级最高:协议决定 API 端点,明确属于其他能力时直接排除,
|
||||
// 防止用户将 video/image/audio 协议的模型误标为 text 后混入文本下拉。
|
||||
@@ -613,11 +584,7 @@ function isAiConfigReady(config: AiConfig, model: string) {
|
||||
const key = config.runningHub.apiKey;
|
||||
return Boolean(config.runningHub.enabled && config.runningHub.baseUrl.trim() && key.trim() && config.runningHub.workflowId.trim());
|
||||
}
|
||||
if (config.taskWorkflowProvider === "comfyui") {
|
||||
return Boolean(config.comfyBridge.enabled && config.comfyBridge.bridgeId.trim() && config.comfyBridge.workflowId.trim());
|
||||
}
|
||||
const channel = resolveModelChannel(config, model);
|
||||
if (channel.transport === "local-runtime") return channel.enabled !== false && Boolean(channel.localModels?.some((item) => item.id === modelOptionName(model)));
|
||||
return Boolean(model.trim() && channel.baseUrl.trim() && channel.apiKey.trim());
|
||||
}
|
||||
|
||||
@@ -685,21 +652,6 @@ export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot | undefine
|
||||
: [];
|
||||
const runningHubWorkflowID = String(persistedRunningHub?.workflowId || "").trim();
|
||||
const runningHubSelectedKind = persistedRunningHub?.selectedKind ? normalizeRunningHubWorkflowKind(persistedRunningHub.selectedKind) : normalizeRunningHubWorkflowKind(runningHubWorkflows.find((item) => item.workflowId === runningHubWorkflowID)?.kind);
|
||||
const persistedComfyBridge = persistedConfig.comfyBridge;
|
||||
const comfyBridgeCapability = normalizeRunningHubCapability(persistedComfyBridge?.capability, defaultConfig.comfyBridge.capability);
|
||||
const comfyBridgeWorkflows = Array.isArray(persistedComfyBridge?.workflows)
|
||||
? persistedComfyBridge.workflows
|
||||
.filter((item): item is ComfyBridgeWorkflow => Boolean(item && typeof item === "object" && String(item.workflowId || "").trim()))
|
||||
.map((item) => {
|
||||
const capability = normalizeRunningHubCapability(item.capability, comfyBridgeCapability);
|
||||
return {
|
||||
...item,
|
||||
workflowId: String(item.workflowId || "").trim(),
|
||||
capability,
|
||||
fields: normalizeSavedWorkflowFields(item, capability),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
const config = {
|
||||
...defaultConfig,
|
||||
...persistedConfig,
|
||||
@@ -712,13 +664,6 @@ export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot | undefine
|
||||
workflowId: runningHubWorkflowID,
|
||||
workflows: runningHubWorkflows,
|
||||
},
|
||||
comfyBridge: {
|
||||
...defaultConfig.comfyBridge,
|
||||
...(persistedComfyBridge || {}),
|
||||
capability: comfyBridgeCapability,
|
||||
workflowId: String(persistedComfyBridge?.workflowId || "").trim(),
|
||||
workflows: comfyBridgeWorkflows,
|
||||
},
|
||||
};
|
||||
const hasPersistedChannels = Array.isArray(persistedConfig.channels);
|
||||
if (!hasPersistedChannels) config.channels = [];
|
||||
@@ -732,7 +677,7 @@ export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot | undefine
|
||||
return {
|
||||
config: {
|
||||
...config,
|
||||
channelMode: "local" as const,
|
||||
channelMode: "remote" as const,
|
||||
apiFormat: normalizeApiFormat(config.apiFormat),
|
||||
channels,
|
||||
models,
|
||||
@@ -770,45 +715,15 @@ function normalizeSelectedModel(value: string, channels: ModelChannel[], options
|
||||
export function useEffectiveConfig() {
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const customChannelsEnabled = useUserStore((state) => state.features.customChannelsEnabled);
|
||||
const catalogState = useLocalDreaminaModelStore((state) => state.state);
|
||||
const dreaminaModels = useLocalDreaminaModelStore((state) => state.models);
|
||||
return useMemo(() => effectiveConfigWithDreamina(effectiveConfigForCustomChannels(config, customChannelsEnabled), catalogState, dreaminaModels), [catalogState, config, customChannelsEnabled, dreaminaModels]);
|
||||
return useMemo(() => effectiveConfigForCustomChannels(config, customChannelsEnabled), [config, customChannelsEnabled]);
|
||||
}
|
||||
|
||||
export function effectiveConfigForCustomChannels(config: AiConfig, customChannelsEnabled: boolean): AiConfig {
|
||||
if (customChannelsEnabled) return config;
|
||||
const channels = config.channels.filter((channel) => channel.scope === "system" || channel.transport === "local-runtime");
|
||||
const channels = config.channels.filter((channel) => channel.scope === "system");
|
||||
return normalizeConfigSnapshot({ config: { ...config, channels } }).config;
|
||||
}
|
||||
|
||||
export function effectiveConfigWithDreamina(config: AiConfig, catalogState: "idle" | "loading" | "ready" | "error", dreaminaModels: DreaminaLocalModel[]): AiConfig {
|
||||
if (catalogState !== "ready" || !dreaminaModels.length) return { ...config, channelMode: "local" };
|
||||
const channel: ModelChannel = {
|
||||
id: "local:dreamina-cli",
|
||||
name: "官方即梦 CLI",
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
apiFormat: "openai",
|
||||
models: dreaminaModels.map((item) => item.id),
|
||||
scope: "user",
|
||||
enabled: true,
|
||||
transport: "local-runtime",
|
||||
localModels: dreaminaModels,
|
||||
};
|
||||
const channels = [...config.channels.filter((item) => item.id !== channel.id), channel];
|
||||
const models = modelOptionsFromChannels(channels);
|
||||
return {
|
||||
...config,
|
||||
channelMode: "local",
|
||||
channels,
|
||||
models,
|
||||
imageModels: filterModelsByCapability(models, "image", channels),
|
||||
videoModels: filterModelsByCapability(models, "video", channels),
|
||||
textModels: filterModelsByCapability(models, "text", channels),
|
||||
audioModels: filterModelsByCapability(models, "audio", channels),
|
||||
};
|
||||
}
|
||||
|
||||
export function createModelChannel(channel?: Partial<ModelChannel>): ModelChannel {
|
||||
const apiFormat = normalizeApiFormat(channel?.apiFormat);
|
||||
const interfaceType = normalizeChannelInterfaceType(channel?.interfaceType);
|
||||
@@ -818,7 +733,6 @@ export function createModelChannel(channel?: Partial<ModelChannel>): ModelChanne
|
||||
name: channel?.name?.trim() || "新渠道",
|
||||
sortOrder: channel?.sortOrder ?? 0,
|
||||
baseUrl: providedBaseUrl || (interfaceType ? defaultBaseUrlForChannelInterface(interfaceType) : defaultBaseUrlForApiFormat(apiFormat)),
|
||||
allowLocalChannel: channel?.allowLocalChannel === true,
|
||||
apiKey: channel?.apiKey || "",
|
||||
secretKey: channel?.secretKey || "",
|
||||
headers: Array.isArray(channel?.headers) ? channel.headers.map((header) => ({ name: String(header.name || ""), value: String(header.value || "") })) : [],
|
||||
@@ -842,8 +756,6 @@ export function isChannelModelValue(value: string) {
|
||||
}
|
||||
|
||||
export function decodeChannelModel(value: string) {
|
||||
const local = /^local:dreamina-cli:([A-Za-z0-9][A-Za-z0-9._:-]{0,119})$/.exec(value.trim());
|
||||
if (local) return { channelId: "local:dreamina-cli", model: local[1] };
|
||||
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) };
|
||||
@@ -883,7 +795,7 @@ export function modelOptionsFromChannels(channels: ModelChannel[]) {
|
||||
.map(normalizeRawModelName)
|
||||
.filter(Boolean)
|
||||
.filter((model) => channel.scope !== "system" || hasSystemModelPrice(channel, model))
|
||||
.map((model) => (channel.transport === "local-runtime" ? `local:dreamina-cli:${model}` : encodeChannelModel(channel.id, model))),
|
||||
.map((model) => encodeChannelModel(channel.id, model)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -934,7 +846,7 @@ export function logicalModelIDForConfig(config: AiConfig) {
|
||||
}
|
||||
|
||||
export function channelConnectionSignature(channel: ModelChannel) {
|
||||
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.secretKey?.trim() || "", channel.apiFormat, channel.interfaceType || "auto", channel.allowLocalChannel === true ? "local:1" : "local:0", JSON.stringify(channel.headers || [])].join("\n");
|
||||
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.secretKey?.trim() || "", channel.apiFormat, channel.interfaceType || "auto", JSON.stringify(channel.headers || [])].join("\n");
|
||||
}
|
||||
|
||||
export function resolveModelRequestConfig(config: AiConfig, value: string) {
|
||||
@@ -942,18 +854,17 @@ export function resolveModelRequestConfig(config: AiConfig, value: string) {
|
||||
const model = modelOptionName(value || config.model);
|
||||
const modelProtocol = channel.modelCosts?.find((item) => item.model === model)?.protocol;
|
||||
const interfaceType = modelProtocol || channel.interfaceType;
|
||||
return projectDesktopLocalChannelRuntime({
|
||||
return {
|
||||
...config,
|
||||
model,
|
||||
baseUrl: channel.baseUrl,
|
||||
allowLocalChannel: channel.allowLocalChannel === true,
|
||||
apiKey: channel.apiKey,
|
||||
secretKey: channel.secretKey,
|
||||
headers: channel.headers,
|
||||
apiFormat: interfaceType ? (interfaceType === "gemini-veo" || interfaceType === "gemini-image" ? ("gemini" as const) : interfaceType === "claude-api" ? ("claude" as const) : ("openai" as const)) : channel.apiFormat,
|
||||
interfaceType,
|
||||
channelId: channel.scope === "system" ? channel.id : "",
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeChannels(config: AiConfig, ensureDefault = true) {
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { create } from "zustand";
|
||||
|
||||
import { getDreaminaModelCatalogSnapshotWithSessionRecovery, type DreaminaLocalModel } from "@/services/local-dreamina-model-catalog";
|
||||
import { getLocalRuntimeSessionClient, useLocalRuntimeStore } from "@/stores/use-local-runtime-store";
|
||||
|
||||
type CatalogSnapshot = Awaited<ReturnType<typeof getDreaminaModelCatalogSnapshotWithSessionRecovery>>;
|
||||
type RuntimeCatalogState = { connection: string; modules: Array<{ id: string; scopes: string[] }> };
|
||||
type Dependencies = {
|
||||
getRuntimeState(): RuntimeCatalogState;
|
||||
getClient(): Parameters<typeof getDreaminaModelCatalogSnapshotWithSessionRecovery>[0];
|
||||
loadSnapshot(client: Parameters<typeof getDreaminaModelCatalogSnapshotWithSessionRecovery>[0], signal: AbortSignal): Promise<CatalogSnapshot>;
|
||||
requestTimeoutMs?: number;
|
||||
};
|
||||
type State = {
|
||||
state: "idle" | "loading" | "ready" | "error";
|
||||
models: DreaminaLocalModel[];
|
||||
cacheScope?: string;
|
||||
sync(signal?: AbortSignal): Promise<void>;
|
||||
ensureReady(signal?: AbortSignal): Promise<DreaminaLocalModel[]>;
|
||||
};
|
||||
|
||||
export const dreaminaModelCacheScopeKey = ({ accountBinding, sessionEpoch }: { accountBinding: string; sessionEpoch: string | number }) => `${accountBinding}:${sessionEpoch}`;
|
||||
|
||||
const DEFAULT_CATALOG_TIMEOUT_MS = 8_000;
|
||||
|
||||
export function createLocalDreaminaModelStore(dependencies: Dependencies) {
|
||||
let requestRevision = 0;
|
||||
let activeRequest: { revision: number; controller: AbortController; promise: Promise<DreaminaLocalModel[]> } | null = null;
|
||||
return create<State>((set, get) => {
|
||||
const load = (force: boolean, signal?: AbortSignal) => {
|
||||
const runtime = dependencies.getRuntimeState();
|
||||
const available = runtime.connection === "connected" && runtime.modules.some((module) => module.id === "dreamina" && module.scopes.includes("dreamina:models"));
|
||||
if (!available) {
|
||||
requestRevision++;
|
||||
activeRequest?.controller.abort();
|
||||
activeRequest = null;
|
||||
set({ state: "idle", models: [], cacheScope: undefined });
|
||||
return Promise.reject(new Error("即梦本机模型目录尚未就绪"));
|
||||
}
|
||||
const current = get();
|
||||
if (!force && current.state === "ready" && current.models.length) return Promise.resolve(current.models);
|
||||
if (force && activeRequest) {
|
||||
requestRevision++;
|
||||
activeRequest.controller.abort();
|
||||
activeRequest = null;
|
||||
}
|
||||
if (!activeRequest) {
|
||||
set({ state: "loading" });
|
||||
const revision = ++requestRevision;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), Math.max(1, dependencies.requestTimeoutMs ?? DEFAULT_CATALOG_TIMEOUT_MS));
|
||||
const promise = dependencies
|
||||
.loadSnapshot(dependencies.getClient(), controller.signal)
|
||||
.then((snapshot) => {
|
||||
if (revision === requestRevision) set({ state: "ready", models: snapshot.models, cacheScope: dreaminaModelCacheScopeKey(snapshot) });
|
||||
return snapshot.models;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (revision === requestRevision) set({ state: "error", models: [], cacheScope: undefined });
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(timer);
|
||||
if (activeRequest?.revision === revision) activeRequest = null;
|
||||
});
|
||||
activeRequest = { revision, controller, promise };
|
||||
}
|
||||
return waitForCatalog(activeRequest.promise, signal);
|
||||
};
|
||||
return {
|
||||
state: "idle",
|
||||
models: [],
|
||||
cacheScope: undefined,
|
||||
async sync(signal) {
|
||||
try {
|
||||
await load(true, signal);
|
||||
} catch {
|
||||
// 页面引导读取失败只投影 store 状态;生成入口的 ensureReady 会明确失败。
|
||||
}
|
||||
},
|
||||
ensureReady(signal) {
|
||||
return load(false, signal);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function waitForCatalog<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (!signal) return promise;
|
||||
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const abort = () => reject(new DOMException("Aborted", "AbortError"));
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
||||
});
|
||||
}
|
||||
|
||||
export const useLocalDreaminaModelStore = createLocalDreaminaModelStore({
|
||||
getRuntimeState: () => useLocalRuntimeStore.getState(),
|
||||
getClient: getLocalRuntimeSessionClient,
|
||||
loadSnapshot: (client, signal) => getDreaminaModelCatalogSnapshotWithSessionRecovery(client, signal),
|
||||
});
|
||||
|
||||
export function useLocalDreaminaModelBootstrap() {
|
||||
const connection = useLocalRuntimeStore((state) => state.connection);
|
||||
const modules = useLocalRuntimeStore((state) => state.modules);
|
||||
const sync = useLocalDreaminaModelStore((state) => state.sync);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void sync(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [connection, modules, sync]);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { create } from "zustand";
|
||||
|
||||
import { readLocalRuntimeStatus, type LocalRuntimeModuleDescriptor, type LocalRuntimeStatus, type LocalRuntimeTransport } from "@/services/local-runtime";
|
||||
import { LocalRuntimeClientError, LocalRuntimeSessionClient, type LocalRuntimeConnection } from "@/services/local-runtime-session";
|
||||
|
||||
export type LocalRuntimeConnectionState = "idle" | "connecting" | "connected" | "origin_not_trusted" | "unreachable" | "incompatible" | "runtime_error";
|
||||
|
||||
type LocalRuntimeSessionTransport = LocalRuntimeTransport & {
|
||||
connect(signal?: AbortSignal): Promise<LocalRuntimeConnection>;
|
||||
revokeLocalSession?(): void;
|
||||
};
|
||||
|
||||
type LocalRuntimeStore = {
|
||||
connection: LocalRuntimeConnectionState;
|
||||
connecting: boolean;
|
||||
runtime: LocalRuntimeStatus["runtime"] | null;
|
||||
modules: LocalRuntimeModuleDescriptor[];
|
||||
error: string;
|
||||
connect(signal?: AbortSignal): Promise<void>;
|
||||
};
|
||||
|
||||
type LocalRuntimeStoreDependencies = {
|
||||
client?: LocalRuntimeSessionTransport;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 8_000;
|
||||
let browserClient: LocalRuntimeSessionClient | undefined;
|
||||
|
||||
export function getLocalRuntimeSessionClient() {
|
||||
browserClient ??= new LocalRuntimeSessionClient();
|
||||
return browserClient;
|
||||
}
|
||||
|
||||
export function createLocalRuntimeStore(dependencies: LocalRuntimeStoreDependencies = {}) {
|
||||
const client = () => dependencies.client ?? getLocalRuntimeSessionClient();
|
||||
let revision = 0;
|
||||
let activeController: AbortController | undefined;
|
||||
|
||||
return create<LocalRuntimeStore>((set, get) => ({
|
||||
connection: "idle",
|
||||
connecting: false,
|
||||
runtime: null,
|
||||
modules: [],
|
||||
error: "",
|
||||
connect: async (signal) => {
|
||||
if (signal?.aborted) return;
|
||||
const requestRevision = ++revision;
|
||||
activeController?.abort();
|
||||
const controller = new AbortController();
|
||||
activeController = controller;
|
||||
let timedOut = false;
|
||||
const cancel = () => controller.abort();
|
||||
if (signal?.aborted) controller.abort();
|
||||
else signal?.addEventListener("abort", cancel, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
},
|
||||
Math.max(1, dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
const previous = get();
|
||||
set({
|
||||
connection: previous.connection === "connected" ? "connected" : "connecting",
|
||||
connecting: true,
|
||||
error: "",
|
||||
});
|
||||
try {
|
||||
const result = await client().connect(controller.signal);
|
||||
if (requestRevision !== revision) return;
|
||||
if (result.state === "origin_not_trusted") {
|
||||
set({
|
||||
connection: "origin_not_trusted",
|
||||
connecting: false,
|
||||
runtime: null,
|
||||
modules: [],
|
||||
error: "本机连接需要重新建立",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let status: LocalRuntimeStatus;
|
||||
try {
|
||||
status = await readLocalRuntimeStatus(client(), controller.signal);
|
||||
} catch (error) {
|
||||
if (!(error instanceof LocalRuntimeClientError) || (error.code !== "session_required" && error.code !== "scope_denied")) throw error;
|
||||
client().revokeLocalSession?.();
|
||||
const reconnected = await client().connect(controller.signal);
|
||||
if (reconnected.state !== "connected") throw error;
|
||||
status = await readLocalRuntimeStatus(client(), controller.signal);
|
||||
}
|
||||
if (requestRevision !== revision) return;
|
||||
set({
|
||||
connection: "connected",
|
||||
connecting: false,
|
||||
runtime: status.runtime,
|
||||
modules: status.modules,
|
||||
error: "",
|
||||
});
|
||||
} catch (error) {
|
||||
if (requestRevision !== revision) return;
|
||||
if (signal?.aborted || (controller.signal.aborted && !timedOut)) {
|
||||
set({
|
||||
connection: previous.connection,
|
||||
connecting: false,
|
||||
runtime: previous.runtime,
|
||||
modules: previous.modules,
|
||||
error: previous.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const failure = connectionFailure(error, timedOut);
|
||||
if (get().connection === "connected") {
|
||||
set({ connecting: false, error: failure.error });
|
||||
} else {
|
||||
set({
|
||||
connection: failure.connection,
|
||||
connecting: false,
|
||||
runtime: null,
|
||||
modules: [],
|
||||
error: failure.error,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", cancel);
|
||||
if (activeController === controller) activeController = undefined;
|
||||
}
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export const useLocalRuntimeStore = createLocalRuntimeStore();
|
||||
|
||||
export function startLocalRuntimeBootstrap(
|
||||
connect: (signal?: AbortSignal) => Promise<void>,
|
||||
schedule: (run: () => void) => () => void = (run) => {
|
||||
const timer = window.setTimeout(run, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
},
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
let started = false;
|
||||
const cancelScheduled = schedule(() => {
|
||||
if (started) return;
|
||||
started = true;
|
||||
void connect(controller.signal);
|
||||
});
|
||||
return () => {
|
||||
cancelScheduled();
|
||||
controller.abort();
|
||||
};
|
||||
}
|
||||
|
||||
export function useLocalRuntimeBootstrap(enabled = true) {
|
||||
const connect = useLocalRuntimeStore((state) => state.connect);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
return startLocalRuntimeBootstrap(connect);
|
||||
}, [connect, enabled]);
|
||||
}
|
||||
|
||||
function connectionFailure(error: unknown, timedOut: boolean) {
|
||||
if (timedOut) {
|
||||
return { connection: "unreachable" as const, error: "本机服务连接超时" };
|
||||
}
|
||||
if (error instanceof LocalRuntimeClientError) {
|
||||
if (error.code === "origin_not_trusted") {
|
||||
return { connection: "origin_not_trusted" as const, error: "本机连接需要重新建立" };
|
||||
}
|
||||
if (["browser_key_invalid", "challenge_invalid", "origin_invalid", "runtime_incompatible", "runtime_response_invalid", "signature_invalid", "webcrypto_unavailable"].includes(error.code)) {
|
||||
return { connection: "incompatible" as const, error: error.message };
|
||||
}
|
||||
return { connection: "runtime_error" as const, error: error.message };
|
||||
}
|
||||
return { connection: "unreachable" as const, error: "未发现本机服务,请确认本机服务已启用" };
|
||||
}
|
||||
@@ -21,7 +21,6 @@ export type LocalUser = {
|
||||
export type RuntimeLimits = {
|
||||
activeTaskLimit: number;
|
||||
resourceUploadMB: number;
|
||||
sessionUploadMB: number;
|
||||
recycleBinRetentionDays?: number;
|
||||
};
|
||||
|
||||
@@ -33,7 +32,6 @@ export type FeatureAvailability = {
|
||||
frontendModelsEnabled: boolean;
|
||||
pluginCenterEnabled: boolean;
|
||||
systemPluginsVisibleToUsers: boolean;
|
||||
desktopLocalChannelsEnabled: boolean;
|
||||
configured?: boolean;
|
||||
updatedBy?: string;
|
||||
updatedAt?: string;
|
||||
@@ -47,7 +45,6 @@ export const defaultFeatureAvailability: FeatureAvailability = {
|
||||
frontendModelsEnabled: false,
|
||||
pluginCenterEnabled: true,
|
||||
systemPluginsVisibleToUsers: true,
|
||||
desktopLocalChannelsEnabled: false,
|
||||
};
|
||||
|
||||
type UserStore = {
|
||||
@@ -67,13 +64,13 @@ type UserStore = {
|
||||
export const useUserStore = create<UserStore>()((set) => ({
|
||||
hydrated: false,
|
||||
user: null,
|
||||
runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32, recycleBinRetentionDays: 30 },
|
||||
runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, recycleBinRetentionDays: 30 },
|
||||
drawingEngine: { defaultEngine: DEFAULT_DRAWING_ENGINE },
|
||||
features: defaultFeatureAvailability,
|
||||
setUser: (user) => set({ user }),
|
||||
setRuntimeLimits: (runtimeLimits) => set({ runtimeLimits: runtimeLimits || { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32, recycleBinRetentionDays: 30 } }),
|
||||
setRuntimeLimits: (runtimeLimits) => set({ runtimeLimits: runtimeLimits || { activeTaskLimit: 5, resourceUploadMB: 50, recycleBinRetentionDays: 30 } }),
|
||||
setDrawingEngine: (drawingEngine) => set({ drawingEngine: drawingEngine || { defaultEngine: DEFAULT_DRAWING_ENGINE } }),
|
||||
setFeatures: (features) => set({ features: features ? { ...defaultFeatureAvailability, ...features } : defaultFeatureAvailability }),
|
||||
setHydrated: (hydrated) => set({ hydrated }),
|
||||
clearSession: () => set({ user: null, runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32, recycleBinRetentionDays: 30 }, drawingEngine: { defaultEngine: DEFAULT_DRAWING_ENGINE }, features: defaultFeatureAvailability }),
|
||||
clearSession: () => set({ user: null, runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, recycleBinRetentionDays: 30 }, drawingEngine: { defaultEngine: DEFAULT_DRAWING_ENGINE }, features: defaultFeatureAvailability }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user