Files
ttstd-infinite-canvas/web/src/lib/plugins/builtin/eagle.ts
QAyong cf6c78d540 feat: 完善创作工作台与用户诊断支持 (#295)
* feat(canvas): 参考素材 - 增加移除按钮并删除对应连线

* feat(canvas): 完善节点面板交互与拖拽反馈

* fix(canvas): 画布交互与小地图 - 统一浮层层级并完善节点识别

* feat(prompt-optimizer): 增加 AI 提示词优化器及宿主接入

* feat(canvas): 画布 - 增加自动整理节点按钮并补充文档

* chore(web): 前端 - 修正提示词优化器格式

* chore(backend): 后端 - 修正 Go 格式检查

* feat(workspace): 完善诊断导出与本地排障支持

* chore(web): 格式化诊断导出文件

* fix(web-test): 兼容 Bun 测试运行时

* fix(web-test): 对齐模型目录协议断言
2026-08-25 13:49:15 +08:00

366 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { nanoid } from "nanoid";
import { addEagleItem, createEagleFolder, downloadEagleItem, eagleItemFileUrl, eagleItemThumbnailUrl, getEagleLibrary, listEagleItems, type EagleFolder, type EagleItem } from "@/services/api/eagle";
import { getMediaBlob, uploadMediaFile } from "@/services/file-storage";
import { imageToDataUrl, uploadImage } from "@/services/image-storage";
import type { Asset } from "@/stores/use-asset-store";
import { registerPlugin } from "../plugin-registry";
import type { AssetSourceProvider, ExternalAssetItem, PluginHostContext, RegisteredPlugin } from "../plugin-types";
export const EAGLE_PLUGIN_ID = "eagle-asset-connector";
export const EAGLE_DEFAULT_BASE_URL = "http://127.0.0.1:41595";
const eaglePluginDocumentation = `# Eagle 素材库
Eagle 插件把桌面 Eagle 资料库接入影策素材面板。浏览器不会直接访问 Eagle:所有读取、搜索、下载和写入请求先到影策后端,再由后端访问管理员配置的 Eagle Local API 地址。
## 使用前准备
1. 安装并启动 Eagle 桌面客户端,打开要使用的资料库。
2. 确认 Eagle Local API 可用;默认地址为 \`${EAGLE_DEFAULT_BASE_URL}\`。
3. 以管理员身份进入插件设置,填写 Base URL 并读取文件夹。
4. 保存配置后,在素材库选择 Eagle 来源,即可浏览和导入素材。
> Eagle 必须保持运行。关闭客户端、切换资料库或修改本地 API 端口后,影策会收到连接失败,不能用空列表掩盖。
## 影策后端接口
| 操作 | 影策接口 | 用途 |
| --- | --- | --- |
| 读取资料库 | \`GET /api/plugins/eagle/library?baseUrl=...\` | 获取 Eagle 版本、资料库名称和文件夹树 |
| 列出素材 | \`GET /api/plugins/eagle/items\` | 按文件夹、关键词和分页读取素材 |
| 缩略图 | \`GET /api/plugins/eagle/items/{id}/thumbnail\` | 代理 Eagle 缩略图 |
| 原文件 | \`GET /api/plugins/eagle/items/{id}/file\` | 下载并导入影策资源存储 |
| 写入素材 | \`POST /api/plugins/eagle/items?baseUrl=...\` | 把图片、视频或音频写回 Eagle |
| 创建文件夹 | \`POST /api/plugins/eagle/folders?baseUrl=...\` | 在 Eagle 中创建目录 |
这些是影策内部登录态 API,使用 Cookie 鉴权;不要把它们当作 Eagle 官方接口直接调用。
## 浏览与搜索
插件读取 Eagle 文件夹树并生成完整层级路径。素材列表支持 \`folderId\`、\`keyword\`、\`limit\`、\`offset\`。文件夹列表在当前插件实例内缓存;创建文件夹后会失效并重新读取。切换 Eagle 资料库时应重新打开插件设置确认目录。
## 导入到影策
导入时,后端代理下载 Eagle 原文件,前端再写入影策自己的图片或媒体存储。图片保留可识别的宽高和 MIME;视频、音频和其他模型文件保留字节数、扩展名、Eagle item ID、文件夹和来源元数据。
| Eagle 内容 | 影策结果 | 当前限制 |
| --- | --- | --- |
| 图片 | 图片素材 | 使用原图,不把缩略图当成品 |
| 视频 | 视频素材 | 元数据缺宽高时使用 1280×720 展示兜底,不改写原文件 |
| 音频 | 音频素材 | 时长以浏览器媒体探测结果为准 |
| 其他文件 | 模型素材 | 仅作为文件资源保存 |
| 文本/实体 | 不支持 | 导入会明确报错 |
## 写回 Eagle
插件可把影策图片、视频或音频转换为 data URL,再调用 Eagle 添加素材。已有本地 Blob 优先直接读取;只有没有本地副本时才下载远程生成地址。默认单个手动上传文件不超过 96 MB,且 MIME 必须以 \`image/\`、\`video/\` 或 \`audio/\` 开头。
启用“自动上传生成结果”后,生成资产可以写入配置的目标文件夹。自动写回失败必须显示为写回失败,不应把“影策已保存”误报为“Eagle 已保存”。
## 配置字段
| 字段 | 说明 | 默认值 |
| --- | --- | --- |
| \`baseUrl\` | Eagle Local API 地址,只允许明确的 HTTP(S) 地址 | \`${EAGLE_DEFAULT_BASE_URL}\` |
| \`autoUploadGenerated\` | 是否把支持的生成结果自动写回 Eagle | 开启 |
| \`generatedFolderId\` | 自动写回目标文件夹 ID | 空,使用 Eagle 默认位置 |
## 安全边界
- Eagle 通常运行在本机。后端必须继续执行私网目标校验,只允许管理员明确配置的本机服务,不能把该代理变成任意 URL 抓取器。
- \`baseUrl\` 只保存服务地址,不包含 Token、Cookie 或查询密钥。
- 原文件和 data URL 不进入日志、localStorage 或错误上报。
- 多用户部署中,服务器的 \`127.0.0.1\` 指服务器自身,不是访问者电脑;这种部署需要本地 Agent/隧道方案,不能假装直连可用。
## 常见问题
| 现象 | 检查项 |
| --- | --- |
| 无法读取资料库 | Eagle 是否运行、Base URL/端口、后端能否访问该主机 |
| 文件夹为空 | Eagle 当前打开的资料库、筛选条件、文件夹层级 |
| 有缩略图但导入失败 | 原文件是否仍存在、文件权限、后端响应状态 |
| 写回失败 | 文件大小/MIME、目标文件夹是否存在、远程生成链接是否过期 |
| 部署服务器连接不到本机 Eagle | 网络拓扑不成立,需要本地桥接而不是更换前端 URL |
## 官方资料
- [Eagle API Documentation](https://api.eagle.cool/)
- [Eagle 产品与下载](https://eagle.cool/)
`;
export const eagleAssetPlugin: RegisteredPlugin = {
manifest: {
id: EAGLE_PLUGIN_ID,
name: "Eagle 素材库",
version: "0.3.0",
publishedAt: "2026-08-21",
updatedAt: "2026-08-22",
apiVersion: "1",
category: "asset-source",
description: "把 Eagle 作为影策的外部素材来源,直接浏览原始文件夹并读写 Eagle 文件。",
documentation: eaglePluginDocumentation,
author: "影策社区",
surfaces: ["asset-source"],
permissions: ["asset.read", "asset.search", "asset.upload", "external.open"],
trusted: true,
configuration: { fields: ["baseUrl", "autoUploadGenerated", "generatedFolderId"] },
},
createAssetSource: ({ config }: PluginHostContext) => {
const configuredBaseUrl = config.baseUrl;
return createEagleAssetSource(typeof configuredBaseUrl === "string" && configuredBaseUrl.trim() ? configuredBaseUrl.trim() : undefined);
},
};
export function createEagleAssetSource(baseUrl = EAGLE_DEFAULT_BASE_URL): AssetSourceProvider {
let folderCache: EagleFolder[] | null = null;
const getFolders = async (signal?: AbortSignal) => {
if (signal?.aborted) throw new DOMException("请求已取消", "AbortError");
if (!folderCache) folderCache = (await getEagleLibrary(baseUrl)).library.folders;
return folderCache;
};
return {
listFolders: async (signal) => (await getFolders(signal)).map((folder) => ({ id: folder.id, name: folder.name, parentId: folder.parentId })),
list: async (query) => {
const [result, folders] = await Promise.all([listEagleItems({ baseUrl, folderId: query.folderId, keyword: query.keyword, limit: query.limit, offset: query.offset }), getFolders(query.signal)]);
const pathMap = buildFolderPathMap(folders);
return result.items.map((item) => toExternalItem(item, baseUrl, pathMap));
},
importAsset: (item, signal) => importEagleAsset(item, baseUrl, signal),
uploadAsset: (asset, signal) => uploadEagleAsset(asset, baseUrl, undefined, signal),
uploadAssetToFolder: (asset, folderId, signal) => uploadEagleAsset(asset, baseUrl, folderId, signal),
uploadFile: (file, folderId, signal) => uploadEagleFile(file, baseUrl, folderId, signal),
createFolder: async (name, parentId) => {
await createEagleFolder(baseUrl, { name, parentId });
folderCache = null;
},
};
}
async function importEagleAsset(item: ExternalAssetItem, baseUrl: string, signal?: AbortSignal): Promise<Asset> {
const kind = item.kind;
if (kind === "text" || kind === "entity") throw new Error("当前 Eagle 导入暂不支持文本或实体素材");
const blob = await downloadEagleItem(item.id, baseUrl, signal);
const now = new Date().toISOString();
const metadata = {
source: "eagle",
eagle: {
itemId: item.id,
baseUrl,
folderId: item.folderId,
folderIds: item.folderIds || [],
folderPath: item.folderPath || [],
extension: item.metadata?.extension,
},
};
if (kind === "image") {
const uploaded = await uploadImage(blob);
return {
id: nanoid(), kind, title: item.title, coverUrl: uploaded.url, tags: item.tags || [], source: "Eagle 素材库", createdAt: now, updatedAt: now, metadata,
data: { dataUrl: uploaded.url, storageKey: uploaded.storageKey, width: item.width || uploaded.width, height: item.height || uploaded.height, bytes: uploaded.bytes, mimeType: uploaded.mimeType },
};
}
const uploaded = await uploadMediaFile(blob, `eagle-${kind}`);
if (kind === "video") {
return {
id: nanoid(), kind, title: item.title, coverUrl: "", tags: item.tags || [], source: "Eagle 素材库", createdAt: now, updatedAt: now, metadata,
data: { url: uploaded.url, storageKey: uploaded.storageKey, width: item.width || uploaded.width || 1280, height: item.height || uploaded.height || 720, bytes: uploaded.bytes, mimeType: uploaded.mimeType },
};
}
if (kind === "audio") {
return {
id: nanoid(), kind, title: item.title, coverUrl: "", tags: item.tags || [], source: "Eagle 素材库", createdAt: now, updatedAt: now, metadata,
data: { url: uploaded.url, storageKey: uploaded.storageKey, durationMs: uploaded.durationMs, bytes: uploaded.bytes, mimeType: uploaded.mimeType },
};
}
return {
id: nanoid(), kind: "model", title: item.title, coverUrl: "", tags: item.tags || [], source: "Eagle 素材库", createdAt: now, updatedAt: now, metadata,
data: { url: uploaded.url, storageKey: uploaded.storageKey, bytes: uploaded.bytes, mimeType: uploaded.mimeType, fileName: item.title },
};
}
type EagleWritableAsset = Extract<Asset, { kind: "image" | "video" | "audio" }>;
async function uploadEagleAsset(asset: Asset, baseUrl: string, folderId?: string, signal?: AbortSignal): Promise<ExternalAssetItem> {
if (!isEagleWritableAsset(asset)) throw new Error("当前仅支持将图片、视频或音频写回 Eagle");
const dataUrl = await assetToDataUrl(asset, signal);
const result = await addEagleItem(baseUrl, {
url: dataUrl,
name: eagleAssetName(asset),
folderId,
tags: asset.tags,
annotation: asset.note,
website: typeof asset.metadata?.eagle === "object" && asset.metadata.eagle && "url" in asset.metadata.eagle ? String(asset.metadata.eagle.url || "") : undefined,
});
return { id: result.item.id || "eagle:" + asset.id, title: eagleAssetName(asset), kind: asset.kind, tags: asset.tags, mimeType: asset.data.mimeType };
}
function isEagleWritableAsset(asset: Asset): asset is EagleWritableAsset {
return asset.kind === "image" || asset.kind === "video" || asset.kind === "audio";
}
async function assetToDataUrl(asset: EagleWritableAsset, signal?: AbortSignal) {
if (asset.kind === "image") {
return imageToDataUrl({ dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, url: asset.data.dataUrl, name: asset.title, mimeType: asset.data.mimeType });
}
const stored = asset.data.storageKey ? await getMediaBlob(asset.data.storageKey) : null;
if (stored) return fileToDataUrl(withMimeType(stored, asset.data.mimeType), signal);
const url = asset.data.url;
if (!url) throw new Error("生成结果没有可写入 Eagle 的媒体地址");
if (url.startsWith("data:")) return url;
const response = await fetch(url, { credentials: "include", signal });
if (!response.ok) throw new Error("读取生成媒体失败,无法写入 Eagle");
return fileToDataUrl(withMimeType(await response.blob(), asset.data.mimeType), signal);
}
function eagleAssetName(asset: EagleWritableAsset) {
const title = asset.title.trim() || (asset.kind === "image" ? "生成图片" : asset.kind === "video" ? "生成视频" : "生成音频");
if (/\.[a-z0-9]{2,8}$/i.test(title)) return title;
const extension = asset.kind === "image" ? extensionFromMime(asset.data.mimeType, "png") : asset.kind === "video" ? extensionFromMime(asset.data.mimeType, "mp4") : extensionFromMime(asset.data.mimeType, "mp3");
return title + "." + extension;
}
function extensionFromMime(mimeType: string, fallback: string) {
const value = mimeType.toLowerCase();
if (value.includes("png")) return "png";
if (value.includes("jpeg") || value.includes("jpg")) return "jpg";
if (value.includes("webm")) return "webm";
if (value.includes("quicktime")) return "mov";
if (value.includes("wav")) return "wav";
if (value.includes("ogg")) return "ogg";
if (value.includes("mp4")) return "mp4";
if (value.includes("mpeg") || value.includes("mp3")) return "mp3";
return fallback;
}
function withMimeType(blob: Blob, mimeType: string) {
return mimeType && blob.type !== mimeType ? blob.slice(0, blob.size, mimeType) : blob;
}
async function uploadEagleFile(file: File, baseUrl: string, folderId?: string, signal?: AbortSignal): Promise<ExternalAssetItem> {
if (file.size > 96 * 1024 * 1024) throw new Error("单个文件不能超过 96 MB");
if (!isEagleMediaType(file.type)) throw new Error("Eagle 写入目前支持图片、视频和音频文件");
const dataUrl = await fileToDataUrl(file, signal);
const result = await addEagleItem(baseUrl, {
url: dataUrl,
name: file.name,
folderId,
});
const itemId = result.item.id || "eagle:" + nanoid();
const extension = file.name.includes(".") ? file.name.split(".").pop() || "" : "";
return {
id: itemId,
title: file.name,
kind: kindFromExtension(extension),
thumbnailUrl: result.item.id ? eagleItemThumbnailUrl(result.item.id, baseUrl) : undefined,
fileUrl: result.item.id ? eagleItemFileUrl(result.item.id, baseUrl) : undefined,
mimeType: file.type || mimeTypeFromExtension(extension),
bytes: file.size,
folderId,
folderIds: folderId ? [folderId] : [],
metadata: { extension },
};
}
function isEagleMediaType(mimeType: string) {
const value = mimeType.toLowerCase();
return value.startsWith("image/") || value.startsWith("video/") || value.startsWith("audio/");
}
function fileToDataUrl(file: Blob, signal?: AbortSignal): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
const cleanup = () => signal?.removeEventListener("abort", abort);
const abort = () => {
reader.abort();
cleanup();
reject(new DOMException("请求已取消", "AbortError"));
};
reader.onload = () => {
cleanup();
if (typeof reader.result !== "string") {
reject(new Error("无法读取待写入的文件"));
return;
}
resolve(reader.result);
};
reader.onerror = () => {
cleanup();
reject(new Error("无法读取待写入的文件"));
};
if (signal?.aborted) {
abort();
return;
}
signal?.addEventListener("abort", abort, { once: true });
reader.readAsDataURL(file);
});
}
function toExternalItem(item: EagleItem, baseUrl: string, pathMap: Map<string, string[]>) {
const kind = kindFromExtension(item.extension);
const folderIds = item.folderIds || [];
const tags = item.tags || [];
return {
id: item.id,
title: item.name,
kind,
thumbnailUrl: eagleItemThumbnailUrl(item.id, baseUrl),
fileUrl: eagleItemFileUrl(item.id, baseUrl),
mimeType: mimeTypeFromExtension(item.extension),
width: item.width,
height: item.height,
bytes: item.size,
tags,
folderId: folderIds[0],
folderIds,
folderPath: folderIds[0] ? pathMap.get(folderIds[0]) : [],
description: item.annotation,
metadata: { extension: item.extension, modificationTime: item.modificationTime, url: item.url, deleted: item.deleted },
} satisfies ExternalAssetItem;
}
function buildFolderPathMap(folders: EagleFolder[]) {
const byId = new Map(folders.map((folder) => [folder.id, folder]));
const result = new Map<string, string[]>();
for (const folder of folders) {
const path: string[] = [];
const seen = new Set<string>();
let current: EagleFolder | undefined = folder;
while (current && !seen.has(current.id)) {
seen.add(current.id);
path.unshift(current.name);
current = current.parentId ? byId.get(current.parentId) : undefined;
}
result.set(folder.id, path);
}
return result;
}
function kindFromExtension(extension: string): Asset["kind"] {
const value = extension.toLowerCase();
if (["png", "jpg", "jpeg", "gif", "webp", "bmp", "avif", "svg"].includes(value)) return "image";
if (["mp4", "mov", "webm", "mkv", "avi"].includes(value)) return "video";
if (["mp3", "wav", "m4a", "aac", "flac", "ogg"].includes(value)) return "audio";
if (["glb", "gltf", "obj", "fbx", "usdz", "blend"].includes(value)) return "model";
return "model";
}
function mimeTypeFromExtension(extension: string) {
const value = extension.toLowerCase();
if (["jpg", "jpeg"].includes(value)) return "image/jpeg";
if (value === "png") return "image/png";
if (value === "webp") return "image/webp";
if (value === "gif") return "image/gif";
if (value === "svg") return "image/svg+xml";
if (value === "mp4") return "video/mp4";
if (value === "webm") return "video/webm";
if (value === "mp3") return "audio/mpeg";
if (value === "wav") return "audio/wav";
if (value === "glb") return "model/gltf-binary";
return "application/octet-stream";
}
registerPlugin(eagleAssetPlugin);