import { Context, Schema } from "koishi"; import { ComfyUINode } from "./ComfyUINode"; import fs from "fs"; import path from "path"; import crypto from "crypto"; export const name = "comfyui-client"; export interface WorkflowPreset { name: string; description?: string; content: string; } export interface Config { serverEndpoint: string; isSecureConnection: boolean; httpProxy?: string; workflowDir: string; defaultWorkflowName: string; workflowPresets: WorkflowPreset[]; workflowJSON: string; /** * 图片上传方式: * - "base64-first"(默认):直接将图片内联为 base64,占位符 {{image}} 写成 data URI。 * - "image-first":优先上传到 ComfyUI,失败时再回退 base64。 */ imageUploadMode?: "base64-first" | "image-first"; uploadTimeoutMs?: number; uploadMaxAttempts?: number; logEnabled: boolean; debug: boolean; } type WorkflowResolveResult = { text: string; source: string; name: string; preset?: WorkflowPreset; }; const PROMPT_PLACEHOLDER = "{{prompt}}"; const ensureDir = async (dir: string) => { await fs.promises.mkdir(dir, { recursive: true }); }; const sanitizeName = (name: string) => name.replace(/[^a-zA-Z0-9._-]/g, ""); const md5 = (text: string | Buffer) => crypto.createHash("md5").update(text).digest("hex"); async function listWorkflows(dir: string) { await ensureDir(dir); const files = await fs.promises.readdir(dir); const list = [] as { name: string; updatedAt: number }[]; for (const file of files) { if (!file.endsWith(".json")) continue; const stat = await fs.promises.stat(path.join(dir, file)); list.push({ name: path.parse(file).name, updatedAt: stat.mtimeMs }); } return list; } async function readWorkflow(dir: string, name: string) { const safe = sanitizeName(name); const filePath = path.join(dir, `${safe}.json`); const content = await fs.promises.readFile(filePath, "utf-8"); return content; } function resolveWorkflowText(ctxConfig: Config, optionsWorkflow: string | undefined, logger: any, workflowDirAbs: string): WorkflowResolveResult { const desiredName = optionsWorkflow || ctxConfig.defaultWorkflowName; if (desiredName) { const preset = ctxConfig.workflowPresets?.find((p) => p.name === desiredName); if (preset) { logger?.info?.("使用工作流预设", { desiredName, source: "preset" }); return { text: preset.content, source: "preset", name: desiredName, preset }; } } if (desiredName) { try { const text = fs.readFileSync(path.join(workflowDirAbs, `${sanitizeName(desiredName)}.json`), "utf-8"); logger?.info?.("使用工作流文件", { desiredName, source: "file" }); return { text, source: "file", name: desiredName }; } catch (err) { logger?.warn?.("读取工作流文件失败,回退配置字段", { desiredName, error: (err as any)?.message }); } } return { text: ctxConfig.workflowJSON, source: "config", name: "inline-config" }; } function pickOutputNode(outputs: Record, workflowJson: any, logger?: any) { const hasImages = (node: any) => node && Array.isArray(node.images) && node.images.length > 0; const preferredIds = Object.keys(workflowJson || {}) .filter((id) => { const cls = (workflowJson as any)[id]?.class_type; return cls === "SaveImage" || cls === "ImagePreview"; }) .filter((id) => hasImages(outputs?.[id])); if (preferredIds.length) { const nodeId = preferredIds[0]; logger?.info?.("自动匹配到 SaveImage/ImagePreview 输出节点", { autoNodeId: nodeId, classType: (workflowJson as any)[nodeId]?.class_type, }); return { nodeId, node: outputs[nodeId], reason: "auto-save-image" }; } const anyNodeId = Object.keys(outputs || {}).find((id) => hasImages(outputs[id])); if (anyNodeId) { logger?.warn?.("未找到 SaveImage/ImagePreview 节点,使用第一个有图片的节点", { autoNodeId: anyNodeId }); return { nodeId: anyNodeId, node: outputs[anyNodeId], reason: "fallback-first-image" }; } return null; } function parseRatio(text?: string) { if (!text) return null; const parts = text.split(":").map((p) => Number(p)); if (parts.length !== 2 || parts.some((n) => Number.isNaN(n) || n <= 0)) return null; return { w: parts[0], h: parts[1] }; } function roundTo64(value: number) { return Math.max(64, Math.round(value / 64) * 64); } function applyOutputDimensions(promptJson: any, width?: number, height?: number, ratioText?: string, logger?: any) { const clone = JSON.parse(JSON.stringify(promptJson)); const findBaseSize = () => { for (const node of Object.values(clone)) { const w = (node as any)?.inputs?.width; const h = (node as any)?.inputs?.height; if (typeof w === "number" && typeof h === "number" && w > 0 && h > 0) return { w, h }; } return null; }; const base = findBaseSize() || { w: 832, h: 1216 }; const area = base.w * base.h; const ratio = parseRatio(ratioText); let targetW = typeof width === "number" && width > 0 ? width : undefined; let targetH = typeof height === "number" && height > 0 ? height : undefined; if (ratio) { const calcW = Math.sqrt((area * ratio.w) / ratio.h); const calcH = Math.sqrt((area * ratio.h) / ratio.w); targetW = targetW ?? roundTo64(calcW); targetH = targetH ?? roundTo64(calcH); } targetW = targetW ?? base.w; targetH = targetH ?? base.h; let touched = false; for (const [, node] of Object.entries(clone)) { const inputs = (node as any).inputs; if (inputs && typeof inputs === "object") { if (typeof inputs.width !== "undefined") { inputs.width = targetW; touched = true; } if (typeof inputs.height !== "undefined") { inputs.height = targetH; touched = true; } } } if (touched) { logger?.info?.("已应用输出尺寸", { width: targetW, height: targetH, ratio: ratioText }); } return clone; } function workflowExpectsPureBase64(promptJson: any) { const placeholderPattern = /\{\{\s*image\s*\}\}/i; const scanInputs = (inputs: any) => { if (!inputs || typeof inputs !== "object") return false; return Object.entries(inputs).some(([key, value]) => { if (typeof value === "string" && placeholderPattern.test(value)) { const keyLower = key.toLowerCase(); if (keyLower.includes("base64")) return true; } return false; }); }; return Object.values(promptJson || {}).some((node) => { if (!node || typeof node !== "object") return false; if (typeof node.class_type === "string" && node.class_type.toLowerCase().includes("base64")) { if (scanInputs(node.inputs)) return true; } return scanInputs(node.inputs); }); } function toBufferFromDataUri(uri: string) { const match = uri.match(/^data:(.+);base64,(.+)$/); if (!match) return null; try { return Buffer.from(match[2], "base64"); } catch { return null; } } function toBufferFromBase64Uri(text: string) { const match = text.match(/^base64:(?:\/\/)?(.+)$/i); if (!match) return null; try { return Buffer.from(match[1], "base64"); } catch { return null; } } function deriveFilenameFromUrl(input: string, fallback = "upload.png") { if (!input) return fallback; try { const url = new URL(input); const name = url.pathname.split("/").filter(Boolean).pop(); if (name) return name; } catch { /* ignore */ } const cleanTail = input.split(/[?#]/)[0]?.split("/").pop(); return cleanTail || fallback; } const truncate = (text: string, max = 160) => (text && text.length > max ? `${text.slice(0, max)}…` : text); /** * 当用户把选项写在文本后面时(例如:`comfy 重新照明 -w relit`),Koishi 的 `[userPrompt:text]` 会把后面的内容全部吞掉, * 造成选项解析失败。这里尝试从原始提示词中“救回”常用选项,并同时把它们从提示词里剔除,保持向后兼容。 */ function recoverInlineOptions(rawPrompt: string | undefined, parsedOptions: any) { if (!rawPrompt) return { prompt: "", recovered: {} as Record }; let prompt = rawPrompt; const recovered: Record = {}; type Rule = { key: keyof Config | "workflow" | "image" | "ratio" | "steps" | "vars" | "list" | "width" | "height"; regex: RegExp; value?: any; }; const rules: Rule[] = [ { key: "workflow", regex: /(?:^|\s)--workflow\s+([^\s]+)/i }, { key: "workflow", regex: /(?:^|\s)-w\s+([^\s]+)/i }, { key: "ratio", regex: /(?:^|\s)-r\s+([0-9]+:[0-9]+)/i }, { key: "steps", regex: /(?:^|\s)-s\s+([0-9]+)/i }, { key: "width", regex: /(?:^|\s)--width\s+([0-9]+)/i }, { key: "height", regex: /(?:^|\s)--height\s+([0-9]+)/i }, { key: "vars", regex: /(?:^|\s)--vars\s+(\{.*?\})(?=\s|$)/i }, { key: "image", regex: /(?:^|\s)-i\s+([^\s]+)/i }, { key: "list", regex: /(?:^|\s)--list(?=\s|$)/i, value: true }, ]; for (const rule of rules) { // 已通过 Koishi 解析出的选项不再重复解析 if (typeof (parsedOptions as any)?.[rule.key] !== "undefined") continue; const match = prompt.match(rule.regex); if (match && match.index !== undefined) { const val = rule.value === true ? true : match[1]; recovered[rule.key] = val; // 从提示词中移除该片段,避免污染真实提示 prompt = `${prompt.slice(0, match.index)} ${prompt.slice(match.index + match[0].length)}`.replace(/\s+/g, " ").trim(); } } return { prompt: prompt.trim(), recovered }; } function summarizeElements(list: any[] | undefined) { if (!Array.isArray(list)) return []; return list.slice(0, 5).map((el, idx) => ({ idx, type: (el as any)?.type || (el as any)?.attrs?.type, url: truncate( (el as any)?.attrs?.url || (el as any)?.attrs?.src || (el as any)?.attrs?.file || (el as any)?.src || "" ) || undefined, hasUrl: !!((el as any)?.attrs?.url || (el as any)?.attrs?.src || (el as any)?.attrs?.file || (el as any)?.src), hasBase64: typeof (el as any)?.attrs?.file === "string" && /^base64:/i.test((el as any).attrs.file || ""), base64Len: typeof (el as any)?.attrs?.file === "string" ? (el as any).attrs.file.length : 0, keys: Object.keys(el || {}).slice(0, 4), })); } function findImageElement( session: any, logger?: (level: "debug" | "info" | "warn", message: string, meta?: Record) => void ): { url: string; name?: string } | null { const listCandidates: any[] = []; const maybeLists = [ session?.message?.elements, session?.elements, (session as any)?.event?.message?.elements, (session as any)?.event?.elements, ]; for (const list of maybeLists) { if (Array.isArray(list)) listCandidates.push(...list); } // 一些适配器(如 QQ 频道)将图片放在 attachments 里 const attachments = (session as any)?.event?.message?.attachments || (session as any)?.message?.attachments || (session as any)?.attachments; if (Array.isArray(attachments)) { for (const att of attachments) { listCandidates.push({ type: "image", attrs: att }); } } logger?.("debug", "收到的潜在图片元素", { candidateCount: listCandidates.length, samples: summarizeElements(listCandidates), urls: listCandidates .map((el) => (el as any)?.attrs?.url || (el as any)?.attrs?.src || (el as any)?.attrs?.file || (el as any)?.src) .filter(Boolean) .slice(0, 10) .map((u) => truncate(String(u), 180)), }); const image = listCandidates.find((el: any) => { const t = (el?.type || el?.attrs?.type || "").toString().toLowerCase(); const hasUrl = (el as any)?.attrs?.url || (el as any)?.attrs?.src || (el as any)?.attrs?.file || (el as any)?.src; return hasUrl && (t === "image" || t === "img" || t === "imageMessage" || t === "image_message" || !t); }); if (!image) return null; const url = (image as any).attrs?.url || (image as any).attrs?.src || (image as any).attrs?.file || (image as any).src; const name = (image as any).attrs?.title || (image as any).attrs?.filename || (image as any).attrs?.name || (image as any).name; return url ? { url, name } : null; } async function resolveIncomingImage( ctx: Context, session: any, optionImage: string | undefined, cacheDir: string | undefined, logFn?: (level: "debug" | "info" | "warn" | "error", message: string, meta?: Record) => void ): Promise<{ buffer: Buffer; filename: string } | null> { let source = optionImage?.trim(); let hintedName: string | undefined; const foundCandidate = findImageElement(session, (level, message, meta) => logFn?.(level, message, meta)); if (!source && foundCandidate) { source = foundCandidate.url; hintedName = foundCandidate.name; logFn?.("info", "使用会话中的图片元素", { url: source, name: hintedName }); } if (source && optionImage) { logFn?.("debug", "使用指令参数提供的图片", { url: truncate(optionImage, 200) }); } if (!source && foundCandidate === null) { logFn?.("debug", "未解析到图片URL,但收到的候选元素已记录"); } if (!source) return null; const filename = deriveFilenameFromUrl(source, hintedName || "upload.png"); const safeFilename = filename.includes(".") ? filename : `${filename}.png`; const ensureCacheDir = async () => { if (!cacheDir) return; await ensureDir(cacheDir); }; const cacheKey = md5(source); const cachePath = cacheDir ? path.join(cacheDir, `${cacheKey}-${safeFilename}`) : null; const tryReadCache = async () => { if (!cachePath) return null; try { await ensureCacheDir(); const buf = await fs.promises.readFile(cachePath); logFn?.("info", "命中图片缓存", { cachePath, filename: safeFilename }); return buf; } catch { return null; } }; const writeCache = async (buf: Buffer) => { if (!cachePath) return; try { await ensureCacheDir(); await fs.promises.writeFile(cachePath, buf); logFn?.("debug", "写入图片缓存成功", { cachePath, size: buf.length }); } catch (err) { logFn?.("warn", "写入图片缓存失败,已忽略", { cachePath, error: (err as any)?.message || err }); } }; const cached = await tryReadCache(); if (cached) { return { buffer: cached, filename: safeFilename }; } try { if (source.startsWith("data:")) { const buffer = toBufferFromDataUri(source); if (!buffer) throw new Error("无法解析 data URI 图片"); logFn?.("info", "解析 data URI 图片成功", { filename: safeFilename }); await writeCache(buffer); return { buffer, filename: safeFilename }; } if (/^base64:(?:\/\/)?/i.test(source)) { const buffer = toBufferFromBase64Uri(source); if (!buffer) throw new Error("无法解析 base64 图片"); logFn?.("info", "解析 base64 图片成功", { filename: safeFilename }); await writeCache(buffer); return { buffer, filename: safeFilename }; } if (source.startsWith("file://")) { const filePath = decodeURIComponent(source.replace("file://", "")); const buffer = await fs.promises.readFile(filePath); logFn?.("info", "读取本地文件图片成功", { filePath }); const resolvedName = path.basename(filePath) || safeFilename; return { buffer, filename: resolvedName.includes(".") ? resolvedName : `${resolvedName}.png` }; } const response = await ctx.http.get(source, { responseType: "arraybuffer" }); const buffer = Buffer.isBuffer(response) ? response : Buffer.from(response as ArrayBuffer); logFn?.("info", "下载网络图片成功", { url: source, filename: safeFilename }); await writeCache(buffer); return { buffer, filename: safeFilename }; } catch (error) { logFn?.("warn", "下载图片失败,已忽略图片输入", { url: source, error: (error as any)?.message || error }); return null; } } export const Config: Schema = Schema.object({ serverEndpoint: Schema.string().default("127.0.0.1:8188").description("ComfyUI服务器,格式:域名/IP:端口,默认:127.0.0.1:8188"), isSecureConnection: Schema.boolean().default(false).description("是否使用HTTPS连接,默认:false"), httpProxy: Schema.string().default("").description("上传/请求走指定 HTTP(S) 代理(留空则跟随环境变量 http_proxy/https_proxy)"), imageUploadMode: Schema.union([ Schema.const("base64-first" as const).description("优先使用 base64 内联(默认)"), Schema.const("image-first" as const).description("优先上传图片到 ComfyUI,失败回退 base64"), ]) .default("base64-first") .description("图片上传方式:base64 优先或 image 优先"), uploadTimeoutMs: Schema.number().default(15000).description("上传图片单次超时时间(毫秒)。默认 15000,便于长链路下快速回退到 base64。"), uploadMaxAttempts: Schema.number().default(1).description("上传图片最大重试次数。默认 1(失败立即走 base64 回退);设为 >1 可在弱网下多试几次。"), logEnabled: Schema.boolean().default(true).description("是否记录与 ComfyUI 的详细日志"), debug: Schema.boolean().default(false).description("调试模式:打印 debug 级别的中间件日志"), workflowDir: Schema.string().default("data/comfyworkflow").description("工作流存储目录(相对 Koishi 运行目录),默认:data/comfyworkflow"), defaultWorkflowName: Schema.string().default("").description("默认使用的工作流名(可匹配预设或文件,不含扩展名)。为空则使用下方 workflowJSON 字符串"), workflowPresets: Schema.array( Schema.object({ name: Schema.string().description("工作流名称"), description: Schema.string().default("").description("说明(可选)"), content: Schema.string().role("textarea").description(`工作流 JSON 内容,含 ${PROMPT_PLACEHOLDER} 等占位符`), }) ) .default([]) .description("工作流预设列表(Koishi 控制台维护,无内置 WebUI)"), workflowJSON: Schema.string() .role("textarea") .default("") .description(`工作流JSON字符串;在正面提示节点使用占位符 ${PROMPT_PLACEHOLDER} 插入用户输入,可参考示例文件。`), }); export function apply(ctx: Context, config: Config) { const pluginLogger = ctx.logger("comfyui-client"); const COMFYUI_SERVER = config.serverEndpoint; const IS_SECURE_CONNECTION = config.isSecureConnection; const HTTP_PROXY = config.httpProxy?.trim() || undefined; const UPLOAD_TIMEOUT_MS = typeof config.uploadTimeoutMs === "number" && config.uploadTimeoutMs > 0 ? config.uploadTimeoutMs : 15000; const UPLOAD_MAX_ATTEMPTS = typeof config.uploadMaxAttempts === "number" && config.uploadMaxAttempts > 0 ? config.uploadMaxAttempts : 1; const LOG_ENABLED = config.logEnabled; const DEBUG_ENABLED = config.debug; const IMAGE_UPLOAD_MODE = config.imageUploadMode === "image-first" ? "image-first" : "base64-first"; const WORKFLOW_DIR = path.isAbsolute(config.workflowDir) ? config.workflowDir : path.join(ctx.baseDir || process.cwd(), config.workflowDir); const IMAGE_CACHE_DIR = path.join(WORKFLOW_DIR, "imgcache"); const log = (level: "info" | "warn" | "error" | "debug", message: string, meta?: Record) => { if (!LOG_ENABLED) return; const metaText = meta ? JSON.stringify(meta) : ""; const text = metaText ? `${message} ${metaText}` : message; // 1) 仍然调用 Koishi logger(若等级被过滤则可能看不到) if (pluginLogger) { if (level !== "debug" || DEBUG_ENABLED) { (pluginLogger as any)[level]?.(text); } // 当 debug 开启时,额外用 info 级别镜像一份,绕过 Koishi 等级过滤 if (level === "debug" && DEBUG_ENABLED) { (pluginLogger as any)["info"]?.(`[debug] ${text}`); } } // 2) 直接输出到 stdout,绕过 Koishi 等级过滤(仅 debug 时启用) if (level === "debug" && DEBUG_ENABLED) { const prefix = "[comfyui-client][debug]"; console.log(`${prefix} ${text}`); } }; ensureDir(WORKFLOW_DIR).catch((err) => { pluginLogger?.error?.("创建工作流目录失败", { dir: WORKFLOW_DIR, error: err?.message || err }); }); ensureDir(IMAGE_CACHE_DIR).catch((err) => { pluginLogger?.error?.("创建图片缓存目录失败", { dir: IMAGE_CACHE_DIR, error: err?.message || err }); }); ctx .command("comfy [userPrompt:text] ComfyUI绘图/改图") .option("workflow", "-w 指定工作流名(可为预设或文件名)") .option("ratio", "-r 指定输出比例,占位符 {{ratio}} 可在工作流中使用") .option("vars", "--vars 以 JSON 提供额外占位符,如 {\"ratio\":\"16:9\",\"style\":\"film\"}") .option("width", "--width 指定输出宽度(优先级高于比例)") .option("height", "--height 指定输出高度(优先级高于比例)") .option("steps", "-s 指定采样步数,占位符 {{steps}} 可在工作流中使用") .option("image", "-i 指定图像URL,或直接在消息中附带图片") .option("list", "--list 显示可用工作流(预设+文件)") .action(async ({ session, options }, userPrompt) => { const comfyNode = new ComfyUINode( ctx, COMFYUI_SERVER, IS_SECURE_CONNECTION, pluginLogger, LOG_ENABLED, DEBUG_ENABLED, null, HTTP_PROXY ); try { // 兼容把选项写在文本末尾的写法:comfy 提示词 -w xxx const { prompt: normalizedPrompt, recovered } = recoverInlineOptions(userPrompt, options); const mergedOptions = { ...options, ...recovered }; log("debug", "中间件输入调试", { optionImage: mergedOptions?.image ?? null, elements: summarizeElements((session as any)?.message?.elements || (session as any)?.elements), attachments: summarizeElements( (session as any)?.event?.message?.attachments || (session as any)?.message?.attachments || (session as any)?.attachments ), recoveredOptions: recovered, }); if (mergedOptions?.list) { const presets = config.workflowPresets || []; const files = await listWorkflows(WORKFLOW_DIR); const presetNames = presets.map((p) => p.name).join(", ") || "(无)"; const fileNames = files.map((f) => f.name).join(", ") || "(无)"; return `工作流预设:${presetNames}\n工作流文件:${fileNames}`; } const workflowResolve = resolveWorkflowText(config, mergedOptions?.workflow, pluginLogger, WORKFLOW_DIR); const workflowText = workflowResolve.text; const promptJson = JSON.parse(workflowText); const hasPromptPlaceholder = workflowText.includes(PROMPT_PLACEHOLDER); const hasImagePlaceholder = /\{\{\s*image\s*\}\}/i.test(workflowText); const prefersBase64Image = workflowExpectsPureBase64(promptJson); log("info", "读取工作流 JSON 成功", { hasPlaceholder: hasPromptPlaceholder, hasImagePlaceholder, prefersBase64Image, source: workflowResolve.source, name: workflowResolve.name, }); const finalUserPrompt = normalizedPrompt ?? ""; const widthOpt = mergedOptions?.width ? Number(mergedOptions.width) : undefined; const heightOpt = mergedOptions?.height ? Number(mergedOptions.height) : undefined; const promptWithDims = applyOutputDimensions(promptJson, widthOpt, heightOpt, mergedOptions?.ratio, pluginLogger); log("info", "开始执行工作流", { server: COMFYUI_SERVER, secure: IS_SECURE_CONNECTION }); const extraPlaceholders: Record = {}; if (mergedOptions?.ratio) extraPlaceholders["ratio"] = mergedOptions.ratio; if (typeof mergedOptions?.steps === "number") extraPlaceholders["steps"] = mergedOptions.steps; if (mergedOptions?.vars) { try { const parsed = JSON.parse(mergedOptions.vars); if (parsed && typeof parsed === "object") { Object.assign(extraPlaceholders, parsed); } } catch (parseErr) { log("warn", "vars 解析失败,已忽略", { vars: mergedOptions.vars, error: (parseErr as any)?.message }); } } const incomingImage = await resolveIncomingImage(ctx, session, mergedOptions?.image, IMAGE_CACHE_DIR, log); if (incomingImage) { const base64Raw = incomingImage.buffer.toString("base64"); const padLen = (4 - (base64Raw.length % 4)) % 4; const base64 = padLen ? base64Raw + "=".repeat(padLen) : base64Raw; const dataUri = `data:image/png;base64,${base64}`; const imageValue = prefersBase64Image ? base64 : dataUri; const fillBase64Placeholders = () => { extraPlaceholders["image"] = imageValue; // 根据工作流需求决定是否带 data URI extraPlaceholders["imageBase64"] = base64; extraPlaceholders["image_base64"] = base64; extraPlaceholders["imageDataUri"] = dataUri; extraPlaceholders["image_data_uri"] = dataUri; extraPlaceholders["imageName"] = incomingImage.filename; extraPlaceholders["imageSubfolder"] = extraPlaceholders["imageSubfolder"] ?? ""; extraPlaceholders["imageType"] = extraPlaceholders["imageType"] ?? "input"; }; const tryUploadFirst = IMAGE_UPLOAD_MODE === "image-first" && !prefersBase64Image; if (tryUploadFirst) { const upload = await comfyNode.uploadImage(incomingImage.buffer, incomingImage.filename, true, { timeoutMs: UPLOAD_TIMEOUT_MS, maxAttempts: UPLOAD_MAX_ATTEMPTS, }); if (upload.success) { const uploadPayload = (upload.data as any) ?? {}; const imageName = uploadPayload.name || uploadPayload.filename || incomingImage.filename; const imageSubfolder = uploadPayload.subfolder ?? ""; const imageType = uploadPayload.type ?? "input"; extraPlaceholders["image"] = imageName; extraPlaceholders["imageName"] = imageName; extraPlaceholders["imageSubfolder"] = imageSubfolder; extraPlaceholders["imageType"] = imageType; log("info", "图片已上传并写入占位符", { imageName, imageSubfolder, imageType }); } else { const errMsg = (upload.error as any)?.message || upload.message || upload.error || "request timeout"; fillBase64Placeholders(); log("warn", "图片上传超时/失败,已回退为 base64 内联", { filename: incomingImage.filename, errMsg: truncate(String(errMsg), 180), base64Length: base64.length, }); } } else { fillBase64Placeholders(); log("info", "已按配置使用 base64 内联图片", { filename: incomingImage.filename, mode: IMAGE_UPLOAD_MODE, prefersBase64Image, base64Length: base64.length, head: base64.slice(0, 48), }); } } else if (hasImagePlaceholder) { log("warn", "工作流包含 {{image}} 占位符但未接收到图片输入", { workflow: workflowResolve.name }); return "当前工作流需要图片输入(包含占位符 {{image}})。请附带图片或使用 `-i ` 再试一次。"; } if (!finalUserPrompt && !incomingImage) { return "请提供提示词或图片中的任意一种输入"; } const result: any = await comfyNode.executePromptWorkflow(promptWithDims, finalUserPrompt, { placeholders: extraPlaceholders }); if (result.success) { const picked = pickOutputNode(result.outputs || {}, promptJson, pluginLogger); if (!picked) { const available = Object.keys(result.outputs || {}); const statusError = (result.history as any)?.status?.error || (result.history as any)?.error; log("error", "未找到任何图片输出节点", { available, statusError }); const hint = available.length ? `实际输出节点:${available.join(", ")}` : "执行结果中没有图片输出节点"; const extra = statusError ? `可能的执行错误:${statusError}` : ""; return `执行失败:未找到包含图片的输出节点。${hint}${extra ? "。" + extra : ""}`; } const outputNode = picked.node; const finalResult = outputNode.images.map((item: any) => ({ filename: item.filename, buffer: item.buffer, })); const imageBuffer = finalResult[0].buffer as Buffer; const base64Image = imageBuffer.toString("base64"); const dataUri = `data:image/png;base64,${base64Image}`; log("info", "工作流执行成功", { promptId: result.prompt_id }); return ; } else { const errMsg = typeof result.error === "string" ? result.error : JSON.stringify(result.error); log("error", "工作流执行失败", { error: errMsg }); return `执行失败:${errMsg || "未知错误"}`; } } catch (error) { const message = (error as any)?.message || String(error); log("error", "执行流程异常", { message, stack: (error as any)?.stack }); return `执行失败:${message}`; } }); }