feat(canvas): 画布插件与视觉 - 合并 PR #289 并保留任务计费边界

This commit is contained in:
ddCat
2026-08-22 10:01:41 +08:00
45 changed files with 5649 additions and 240 deletions

View File

@@ -0,0 +1,85 @@
import { apiBaseURL, apiClient, request } from "@/services/api/request";
export type EagleFolder = {
id: string;
name: string;
parentId?: string;
};
export type EagleLibrary = {
applicationVersion: string;
libraryName: string;
folders: EagleFolder[];
};
export type EagleItem = {
id: string;
name: string;
size: number;
extension: string;
tags: string[];
folderIds: string[];
url: string;
annotation: string;
modificationTime: number;
width?: number;
height?: number;
deleted: boolean;
};
export type EagleAddItemInput = {
url: string;
name: string;
folderId?: string;
tags?: string[];
annotation?: string;
website?: string;
modificationTime?: number;
};
export async function getEagleLibrary(baseUrl: string) {
return request<{ library: EagleLibrary }>(apiClient.get("/plugins/eagle/library", { params: { baseUrl } }));
}
export async function listEagleItems(input: { baseUrl: string; folderId?: string; keyword?: string; limit?: number; offset?: number }) {
return request<{ items: EagleItem[] }>(apiClient.get("/plugins/eagle/items", {
params: {
baseUrl: input.baseUrl,
folderId: input.folderId || undefined,
keyword: input.keyword || undefined,
limit: input.limit,
offset: input.offset,
},
}));
}
export function eagleItemThumbnailUrl(itemId: string, baseUrl: string) {
return `${String(apiBaseURL).replace(/\/+$/, "")}/plugins/eagle/items/${encodeURIComponent(itemId)}/thumbnail?baseUrl=${encodeURIComponent(baseUrl)}`;
}
export function eagleItemFileUrl(itemId: string, baseUrl: string) {
return String(apiBaseURL).replace(/\/+$/, "") + "/plugins/eagle/items/" + encodeURIComponent(itemId) + "/file?baseUrl=" + encodeURIComponent(baseUrl);
}
export async function downloadEagleItem(itemId: string, baseUrl: string, signal?: AbortSignal) {
const response = await fetch(`${String(apiBaseURL).replace(/\/+$/, "")}/plugins/eagle/items/${encodeURIComponent(itemId)}/file?baseUrl=${encodeURIComponent(baseUrl)}`, { credentials: "include", signal });
if (!response.ok) {
let message = "读取 Eagle 素材失败";
try {
const body = await response.json() as { msg?: string };
message = body.msg || message;
} catch {
// 保留通用错误,避免把 HTML 或服务器内部信息展示给用户。
}
throw new Error(message);
}
return response.blob();
}
export async function addEagleItem(baseUrl: string, input: EagleAddItemInput) {
return request<{ item: { id?: string } }>(apiClient.post(`/plugins/eagle/items?baseUrl=${encodeURIComponent(baseUrl)}`, input));
}
export async function createEagleFolder(baseUrl: string, input: { name: string; parentId?: string }) {
return request<{ created: boolean }>(apiClient.post(`/plugins/eagle/folders?baseUrl=${encodeURIComponent(baseUrl)}`, input));
}

View File

@@ -0,0 +1,124 @@
import "@/lib/plugins/builtin";
import { getRegisteredPlugin } from "@/lib/plugins/plugin-registry";
import { pluginStorageFor } from "@/lib/plugins/plugin-storage";
import type { Asset } from "@/stores/use-asset-store";
import { usePluginStore } from "@/stores/use-plugin-store";
import type { AssetSourceProvider, ExternalAssetItem, PluginInstallation } from "@/lib/plugins/plugin-types";
export type HostedAssetSource = {
id: string;
name: string;
provider: AssetSourceProvider;
};
export type ExternalAssetSyncRecord = {
sourceId: string;
sourceName: string;
status: "synced" | "failed";
externalId?: string;
folderId?: string;
error?: string;
updatedAt: string;
};
export type ExternalAssetSyncResult = {
records: ExternalAssetSyncRecord[];
uploaded: ExternalAssetItem[];
failures: Array<{ sourceId: string; sourceName: string; error: string }>;
};
export function createHostedAssetSources(installations: PluginInstallation[]): HostedAssetSource[] {
return installations.flatMap((installation) => {
if (!installation.enabled) return [];
const plugin = getRegisteredPlugin(installation.manifest.id);
if (!plugin?.createAssetSource) return [];
try {
return [
{
id: plugin.manifest.id,
name: plugin.manifest.name,
provider: plugin.createAssetSource({
manifest: plugin.manifest,
permissions: new Set(plugin.manifest.permissions),
storage: pluginStorageFor(plugin.manifest.id),
config: installation.config,
}),
},
];
} catch {
return [];
}
});
}
export async function uploadGeneratedAssetToConfiguredSources(asset: Asset, signal?: AbortSignal): Promise<ExternalAssetSyncResult> {
const installations = usePluginStore.getState().installations;
const autoUploadInstallations = installations.filter((installation) => installation.enabled && isAutoUploadEnabled(installation.config.autoUploadGenerated));
if (!autoUploadInstallations.length) return { records: readSyncRecords(asset), uploaded: [], failures: [] };
const sources = createHostedAssetSources(installations);
const records = readSyncRecords(asset);
const uploaded: ExternalAssetItem[] = [];
const failures: ExternalAssetSyncResult["failures"] = [];
for (const installation of autoUploadInstallations) {
if (signal?.aborted) throw new DOMException("请求已取消", "AbortError");
const source = sources.find((candidate) => candidate.id === installation.manifest.id);
const folderId = stringConfig(installation.config.generatedFolderId);
const existing = records.find((record) => record.sourceId === installation.manifest.id);
if (existing?.status === "synced" && existing.folderId === folderId) continue;
if (!source) {
const error = "插件未能加载素材来源";
replaceSyncRecord(records, { sourceId: installation.manifest.id, sourceName: installation.manifest.name, status: "failed", folderId, error, updatedAt: new Date().toISOString() });
failures.push({ sourceId: installation.manifest.id, sourceName: installation.manifest.name, error });
continue;
}
if (!source.provider.uploadAsset && !source.provider.uploadAssetToFolder) {
const error = "当前插件不支持自动写入生成结果";
replaceSyncRecord(records, { sourceId: source.id, sourceName: source.name, status: "failed", folderId, error, updatedAt: new Date().toISOString() });
failures.push({ sourceId: source.id, sourceName: source.name, error });
continue;
}
try {
const item = source.provider.uploadAssetToFolder ? await source.provider.uploadAssetToFolder(asset, folderId, signal) : await source.provider.uploadAsset!(asset, signal);
uploaded.push(item);
replaceSyncRecord(records, { sourceId: source.id, sourceName: source.name, status: "synced", externalId: item.id, folderId, updatedAt: new Date().toISOString() });
} catch (reason) {
if (reason instanceof DOMException && reason.name === "AbortError") throw reason;
const error = reason instanceof Error ? reason.message : "插件写入失败";
replaceSyncRecord(records, { sourceId: source.id, sourceName: source.name, status: "failed", folderId, error, updatedAt: new Date().toISOString() });
failures.push({ sourceId: source.id, sourceName: source.name, error });
}
}
return { records, uploaded, failures };
}
export function externalAssetSyncFailures(asset: Asset) {
return readSyncRecords(asset).filter((record) => record.status === "failed");
}
function isAutoUploadEnabled(value: unknown) {
return value !== false && value !== "false";
}
function stringConfig(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readSyncRecords(asset: Asset): ExternalAssetSyncRecord[] {
const raw = asset.metadata?.externalSync;
if (!Array.isArray(raw)) return [];
return raw.filter((item): item is ExternalAssetSyncRecord => {
if (!item || typeof item !== "object") return false;
const candidate = item as Partial<ExternalAssetSyncRecord>;
return typeof candidate.sourceId === "string" && typeof candidate.sourceName === "string" && (candidate.status === "synced" || candidate.status === "failed") && typeof candidate.updatedAt === "string";
});
}
function replaceSyncRecord(records: ExternalAssetSyncRecord[], next: ExternalAssetSyncRecord) {
const index = records.findIndex((record) => record.sourceId === next.sourceId);
if (index === -1) records.push(next);
else records[index] = next;
}

View File

@@ -6,6 +6,7 @@ import type { GenerationTask, GenerationTaskOutput } from "@/services/api/task-c
import { getMediaBlob, resolveMediaUrl, setMediaBlob } from "@/services/file-storage";
import { createGenerationTaskMaterializer, createIdempotentMaterializeOutput, type MaterializeGenerationTaskOutput } from "@/services/generation-task-materializer";
import { withGenerationArtifactCommitLock } from "@/services/generation-asset-repository";
import { uploadGeneratedAssetToConfiguredSources } from "@/services/external-asset-sources";
import { getImageBlob, resolveImageUrl, setImageBlob } from "@/services/image-storage";
import { generationArtifactStorageKey, loadOrStoreGenerationArtifact } from "@/services/generation-artifact-sink";
import { createLocalDreaminaTaskEffectStore } from "@/services/local-dreamina-generation";
@@ -351,18 +352,30 @@ function generationTaskEffectStore() {
const materializeGenerationOutput: MaterializeGenerationTaskOutput = createIdempotentMaterializeOutput({
async insertOrReturnAsset(input) {
const scope = getActiveUserScope();
return withGenerationArtifactCommitLock(
const assetId = await withGenerationArtifactCommitLock(
scope,
async () => {
throwIfAborted(input.signal);
const asset = await generationOutputAsset(input, scope);
throwIfAborted(input.signal);
const assetId = await useAssetStore.getState().addGenerationAsset(input.effectKey, asset, input.signal);
const createdAssetId = await useAssetStore.getState().addGenerationAsset(input.effectKey, asset, input.signal);
throwIfAborted(input.signal);
return assetId;
return createdAssetId;
},
{ requireCrossRealmLock: true },
);
const storedAsset = useAssetStore.getState().assets.find((candidate) => candidate.id === assetId);
if (storedAsset) {
const syncResult = await uploadGeneratedAssetToConfiguredSources(storedAsset, input.signal);
throwIfAborted(input.signal);
if (syncResult.records.length) {
const currentAsset = useAssetStore.getState().assets.find((candidate) => candidate.id === assetId) || storedAsset;
useAssetStore.getState().updateAsset(assetId, {
metadata: { ...currentAsset.metadata, externalSync: syncResult.records },
});
}
}
return assetId;
},
});