koishi-plugin-aka-comfy-agent 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/dist/index.d.mts +50 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +1781 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1753 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +46 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1781 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name2 in all)
|
|
8
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
Config: () => Config,
|
|
24
|
+
apply: () => apply,
|
|
25
|
+
inject: () => inject,
|
|
26
|
+
name: () => name
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(index_exports);
|
|
29
|
+
var import_koishi3 = require("koishi");
|
|
30
|
+
|
|
31
|
+
// src/config.ts
|
|
32
|
+
var import_koishi = require("koishi");
|
|
33
|
+
|
|
34
|
+
// src/llm/prompts.ts
|
|
35
|
+
var MAIN_SYSTEM_PROMPT = `You are the edit-intent author for an image re-render pipeline. The pipeline prepends fixed identity, backdrop and lighting anchors before your output. Describe only the user's requested look (material or optical adjustments; a background change or light override when explicitly asked) as one short natural English clause. Placeholder default; the full prompt is restored via config override from the private design doc.`;
|
|
36
|
+
var GLM_PROBE_PROMPT = `You are an asset extractor for a character image. From the screenshot, list every visual detail that a re-render could drift, and output a structured English JSON inventory (species, per-part colors, accessories, body surface, tail, pose, background). Do not invent details; use "none" when absent. Placeholder default; full prompt (with exact schema) is in the private design doc.`;
|
|
37
|
+
var VISION_USER_TEXT = "分析这张游戏角色截图,输出资产清单 JSON。";
|
|
38
|
+
var VISION_CROSSCHECK_TEXT = `这是对同一张源图的【资产清单对抗复核】。
|
|
39
|
+
|
|
40
|
+
【上一轮 GLM 输出】
|
|
41
|
+
{prev}
|
|
42
|
+
|
|
43
|
+
请基于源图逐项对抗验证上一轮的每一项字段,明确指出错误/遗漏/脑补,修正会导致重绘跑偏的项,保持英文枚举与同一 schema 不变;不确定时写 "none"。只输出修正后的权威 JSON。`;
|
|
44
|
+
var VISION_REPORT_LABEL = "资产清单(GLM 修正后)";
|
|
45
|
+
var IDENTITY_ANCHOR = `Preserve the subject, its pose, outfit, accessories and colors of the source image exactly, keeping the same composition and framing, at high resolution, with no UI and no watermark. (Generic placeholder; the full locked wording is in the private design doc.)`;
|
|
46
|
+
var LIGHTFIELD_ANCHOR = `Evenly light the character's front with gentle volume, keep the shadows soft and the face readable, and add a subtle rim so the silhouette lifts off the backdrop with a soft contact shadow. (Generic placeholder; the full locked wording is in the private design doc.)`;
|
|
47
|
+
function buildBackdropClause(color) {
|
|
48
|
+
const c = (color || "yellow").trim().toLowerCase();
|
|
49
|
+
return `Place the character on a plain solid ${c} seamless studio backdrop that fully replaces the source background. (Generic placeholder; full wording in private doc.)`;
|
|
50
|
+
}
|
|
51
|
+
function buildBackdropFromOverride(desc) {
|
|
52
|
+
const d = (desc || "").trim().replace(/\s+/g, " ");
|
|
53
|
+
return `Place the character against ${d}, fully replacing the source background. (Generic placeholder; full wording in private doc.)`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/config.ts
|
|
57
|
+
var llmSchema = (modelsKey, systemPromptDefault, baseUrlDefault) => import_koishi.Schema.object({
|
|
58
|
+
enabled: import_koishi.Schema.boolean().default(true).description("启用"),
|
|
59
|
+
baseUrl: import_koishi.Schema.string().default(baseUrlDefault).description("接口地址"),
|
|
60
|
+
apiKey: import_koishi.Schema.string().description("ApiKey(可空)"),
|
|
61
|
+
model: import_koishi.Schema.dynamic(modelsKey).description("模型"),
|
|
62
|
+
systemPrompt: import_koishi.Schema.string().role("textarea").default(systemPromptDefault).description("模型专属系统提示(角色/规则)"),
|
|
63
|
+
userText: import_koishi.Schema.string().role("textarea").default(VISION_USER_TEXT).description("请求正文模板(vision 用)")
|
|
64
|
+
});
|
|
65
|
+
var Config = import_koishi.Schema.object({
|
|
66
|
+
mainLLM: llmSchema("aka-comfy-agent.models.main", MAIN_SYSTEM_PROMPT, "http://127.0.0.1:8000/v1").description("主模型(DS:判定+选择)"),
|
|
67
|
+
visionLLM: llmSchema("aka-comfy-agent.models.vision", GLM_PROBE_PROMPT, "http://127.0.0.1:8001/v1").description("视觉模型(GLM:资产清单 probe)"),
|
|
68
|
+
comfy: import_koishi.Schema.object({
|
|
69
|
+
baseUrl: import_koishi.Schema.string().default("http://127.0.0.1:8188").description("ComfyUI 地址"),
|
|
70
|
+
timeoutMs: import_koishi.Schema.number().min(3e4).max(6e5).default(18e4).description("超时(ms)")
|
|
71
|
+
}).description("ComfyUI"),
|
|
72
|
+
defaultBackgroundColor: import_koishi.Schema.string().default("yellow").description("背景色兜底(DS 未给或非法时使用;英文颜色词)"),
|
|
73
|
+
session: import_koishi.Schema.object({
|
|
74
|
+
maxTurns: import_koishi.Schema.number().min(1).max(50).default(10).description("最大轮次"),
|
|
75
|
+
idleTimeoutMs: import_koishi.Schema.number().min(6e4).max(24 * 60 * 60 * 1e3).default(10 * 60 * 1e3).description("空闲销毁(ms)"),
|
|
76
|
+
perUserInGroup: import_koishi.Schema.boolean().default(false).description("群内按人隔离"),
|
|
77
|
+
maxDiagnoseRetries: import_koishi.Schema.number().min(0).max(5).default(2).description("报错重试上限")
|
|
78
|
+
}).description("会话规则"),
|
|
79
|
+
confirmBeforeGenerate: import_koishi.Schema.object({
|
|
80
|
+
command: import_koishi.Schema.boolean().default(true).description("comfy 命令路径:生成前先发送 GLM 资产清单,待用户确认/修正后才提交生成"),
|
|
81
|
+
chatluna: import_koishi.Schema.boolean().default(false).description("ChatLuna 工具路径:开启确认闸(工具无交互确认能力,开启后工具生成前会卡住,一般保持关闭)")
|
|
82
|
+
}).description("生成前确认闸(仅对视觉分析轮生效)"),
|
|
83
|
+
visionCrosscheckRounds: import_koishi.Schema.number().min(1).max(5).default(2).description("视觉对抗自校验轮数:1=仅一次分析;2=分析后做1次对抗复核(推荐);更多=多次复核更稳但更慢更贵"),
|
|
84
|
+
chatlunaEnabled: import_koishi.Schema.boolean().default(false).description("接入 ChatLuna 工具(未装则静默禁用)"),
|
|
85
|
+
chatlunaRoomLastGenerated: import_koishi.Schema.boolean().default(true).description("记录房间最近生成图,供 last_generated 引用")
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// src/llm/client.ts
|
|
89
|
+
var LlmClient = class {
|
|
90
|
+
constructor(ctx, config, model, timeoutMs) {
|
|
91
|
+
this.ctx = ctx;
|
|
92
|
+
this.config = config;
|
|
93
|
+
this.model = model;
|
|
94
|
+
this.timeoutMs = timeoutMs;
|
|
95
|
+
}
|
|
96
|
+
get enabled() {
|
|
97
|
+
return this.config.enabled && !!this.config.baseUrl && !!this.model;
|
|
98
|
+
}
|
|
99
|
+
async chat(messages, opts) {
|
|
100
|
+
if (!this.enabled) throw new Error(`LLM 未启用或未解析 model(baseUrl=${this.config.baseUrl})`);
|
|
101
|
+
const url = joinUrl(this.config.baseUrl, "/chat/completions");
|
|
102
|
+
const body = {
|
|
103
|
+
model: this.model,
|
|
104
|
+
messages,
|
|
105
|
+
temperature: opts?.temperature ?? 0.4
|
|
106
|
+
};
|
|
107
|
+
if (opts?.jsonMode) body["response_format"] = { type: "json_object" };
|
|
108
|
+
const headers = { "Content-Type": "application/json" };
|
|
109
|
+
if (this.config.apiKey) headers["Authorization"] = `Bearer ${this.config.apiKey}`;
|
|
110
|
+
const res = await this.ctx.http(url, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
data: body,
|
|
113
|
+
headers,
|
|
114
|
+
timeout: this.timeoutMs
|
|
115
|
+
});
|
|
116
|
+
const data = extractResponseData(res);
|
|
117
|
+
const content = data?.choices?.[0]?.message?.content;
|
|
118
|
+
if (typeof content !== "string" || !content) {
|
|
119
|
+
throw new Error(`LLM 响应无 content:${JSON.stringify(data).slice(0, 200)}`);
|
|
120
|
+
}
|
|
121
|
+
return content;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
function extractResponseData(res) {
|
|
125
|
+
if (res && typeof res === "object" && "data" in res && res.headers) return res.data;
|
|
126
|
+
return res;
|
|
127
|
+
}
|
|
128
|
+
function joinUrl(base, path) {
|
|
129
|
+
const b = base.replace(/\/+$/, "");
|
|
130
|
+
const p = path.startsWith("/") ? path : `/${path}`;
|
|
131
|
+
return `${b}${p}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/llm/models.ts
|
|
135
|
+
async function listModels(ctx, cfg, timeoutMs) {
|
|
136
|
+
const url = joinUrl(cfg.baseUrl, "/models");
|
|
137
|
+
const headers = {};
|
|
138
|
+
if (cfg.apiKey) headers["Authorization"] = `Bearer ${cfg.apiKey}`;
|
|
139
|
+
const res = await ctx.http.get(url, { timeout: timeoutMs, headers });
|
|
140
|
+
const payload = res && typeof res === "object" && "data" in res && Array.isArray(res.data) ? res : { data: [] };
|
|
141
|
+
const ids = [];
|
|
142
|
+
for (const item of payload.data) {
|
|
143
|
+
if (item && typeof item.id === "string") ids.push(item.id);
|
|
144
|
+
}
|
|
145
|
+
return ids;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// src/comfy/client.ts
|
|
149
|
+
var ComfyClient = class {
|
|
150
|
+
constructor(ctx, baseUrl, timeoutMs) {
|
|
151
|
+
this.ctx = ctx;
|
|
152
|
+
this.baseUrl = baseUrl;
|
|
153
|
+
this.timeoutMs = timeoutMs;
|
|
154
|
+
}
|
|
155
|
+
/** POST /upload/image (multipart, field name = image)。返回 ComfyUI 用于 LoadImage.image 的 name。 */
|
|
156
|
+
async uploadImage(buffer, filename, mimeType = "image/png") {
|
|
157
|
+
const form = new FormData();
|
|
158
|
+
form.append("image", new Blob([new Uint8Array(buffer)], { type: mimeType }), filename);
|
|
159
|
+
form.append("overwrite", "true");
|
|
160
|
+
const res = await this.ctx.http(this.url("/upload/image"), {
|
|
161
|
+
method: "POST",
|
|
162
|
+
data: form,
|
|
163
|
+
timeout: this.timeoutMs
|
|
164
|
+
});
|
|
165
|
+
const data = unwrap(res);
|
|
166
|
+
if (!data || typeof data.name !== "string") {
|
|
167
|
+
throw new Error(`ComfyUI /upload/image 未返回 name:${JSON.stringify(data).slice(0, 200)}`);
|
|
168
|
+
}
|
|
169
|
+
return data.name;
|
|
170
|
+
}
|
|
171
|
+
/** POST /prompt。body { prompt, client_id }。返回 prompt_id。 */
|
|
172
|
+
async submitPrompt(workflow, clientId) {
|
|
173
|
+
const res = await this.ctx.http(this.url("/prompt"), {
|
|
174
|
+
method: "POST",
|
|
175
|
+
data: { prompt: workflow, client_id: clientId },
|
|
176
|
+
headers: { "Content-Type": "application/json" },
|
|
177
|
+
timeout: this.timeoutMs
|
|
178
|
+
});
|
|
179
|
+
const data = unwrap(res);
|
|
180
|
+
if (!data || typeof data.prompt_id !== "string") {
|
|
181
|
+
const err = data?.error ?? data?.node_errors ?? data;
|
|
182
|
+
throw new Error(`ComfyUI /prompt 拒绝:${safeStringify(err).slice(0, 500)}`);
|
|
183
|
+
}
|
|
184
|
+
return data.prompt_id;
|
|
185
|
+
}
|
|
186
|
+
async getHistory(promptId) {
|
|
187
|
+
const res = await this.ctx.http.get(this.url(`/history/${encodeURIComponent(promptId)}`), {
|
|
188
|
+
timeout: this.timeoutMs
|
|
189
|
+
});
|
|
190
|
+
const data = unwrap(res);
|
|
191
|
+
if (!data || typeof data !== "object") return null;
|
|
192
|
+
const entry = data[promptId];
|
|
193
|
+
return entry ?? null;
|
|
194
|
+
}
|
|
195
|
+
/** GET /view?filename&subfolder&type,返回 image buffer。 */
|
|
196
|
+
async view(filename, subfolder = "", type = "output") {
|
|
197
|
+
const url = this.url(`/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder)}&type=${type}`);
|
|
198
|
+
const ab = await this.ctx.http.get(url, { responseType: "arraybuffer", timeout: this.timeoutMs });
|
|
199
|
+
return Buffer.from(ab);
|
|
200
|
+
}
|
|
201
|
+
async queue() {
|
|
202
|
+
const res = await this.ctx.http.get(this.url("/queue"), { timeout: this.timeoutMs });
|
|
203
|
+
const data = unwrap(res) ?? {};
|
|
204
|
+
const running = Array.isArray(data.queue_running) ? data.queue_running.length : 0;
|
|
205
|
+
const pending = Array.isArray(data.queue_pending) ? data.queue_pending.length : 0;
|
|
206
|
+
return { running, pending };
|
|
207
|
+
}
|
|
208
|
+
/** 轮询 history 到 success/error,或超时(timeoutMs 独立传入以覆盖首次模型加载) */
|
|
209
|
+
async pollUntilDone(promptId, opts) {
|
|
210
|
+
const interval = opts.intervalMs ?? 1500;
|
|
211
|
+
const start = Date.now();
|
|
212
|
+
let last = null;
|
|
213
|
+
while (Date.now() - start < opts.timeoutMs) {
|
|
214
|
+
const entry = await this.getHistory(promptId).catch(() => null);
|
|
215
|
+
if (entry) {
|
|
216
|
+
last = entry;
|
|
217
|
+
const st = entry.status;
|
|
218
|
+
if (st?.completed === true) return entry;
|
|
219
|
+
if (st?.status_str === "error") return entry;
|
|
220
|
+
if (Array.isArray(st?.messages)) {
|
|
221
|
+
for (const m of st.messages) {
|
|
222
|
+
if (Array.isArray(m) && m[0] === "execution_error") return entry;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (opts.onTick) opts.onTick(Date.now() - start);
|
|
227
|
+
await sleep(interval);
|
|
228
|
+
}
|
|
229
|
+
if (last) return last;
|
|
230
|
+
throw new Error(`ComfyUI 轮询超时(${opts.timeoutMs}ms),prompt_id=${promptId}`);
|
|
231
|
+
}
|
|
232
|
+
url(p) {
|
|
233
|
+
const base = this.baseUrl.replace(/\/+$/, "");
|
|
234
|
+
return `${base}${p.startsWith("/") ? p : `/${p}`}`;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
function unwrap(res) {
|
|
238
|
+
if (res && typeof res === "object" && "data" in res && "headers" in res) return res.data;
|
|
239
|
+
return res;
|
|
240
|
+
}
|
|
241
|
+
function safeStringify(v) {
|
|
242
|
+
try {
|
|
243
|
+
return JSON.stringify(v);
|
|
244
|
+
} catch {
|
|
245
|
+
return String(v);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function sleep(ms) {
|
|
249
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/comfy/workflow.ts
|
|
253
|
+
function buildFlux2Workflow(input) {
|
|
254
|
+
const { imageName, text, seed } = input;
|
|
255
|
+
return {
|
|
256
|
+
unet: {
|
|
257
|
+
class_type: "UNETLoader",
|
|
258
|
+
inputs: { unet_name: "flux2_dev_fp8mixed.safetensors", weight_dtype: "default" }
|
|
259
|
+
},
|
|
260
|
+
clip: {
|
|
261
|
+
class_type: "CLIPLoader",
|
|
262
|
+
inputs: { clip_name: "mistral_3_small_flux2_bf16.safetensors", type: "flux2" }
|
|
263
|
+
},
|
|
264
|
+
vae: {
|
|
265
|
+
class_type: "VAELoader",
|
|
266
|
+
inputs: { vae_name: "flux2-vae.safetensors" }
|
|
267
|
+
},
|
|
268
|
+
loadimg: {
|
|
269
|
+
class_type: "LoadImage",
|
|
270
|
+
inputs: { image: imageName }
|
|
271
|
+
},
|
|
272
|
+
enc_img: {
|
|
273
|
+
class_type: "VAEEncode",
|
|
274
|
+
inputs: { pixels: ["loadimg", 0], vae: ["vae", 0] }
|
|
275
|
+
},
|
|
276
|
+
sched: {
|
|
277
|
+
class_type: "Flux2Scheduler",
|
|
278
|
+
inputs: { steps: 20, width: 1024, height: 1024 }
|
|
279
|
+
},
|
|
280
|
+
sel: {
|
|
281
|
+
class_type: "KSamplerSelect",
|
|
282
|
+
inputs: { sampler_name: "euler" }
|
|
283
|
+
},
|
|
284
|
+
noise: {
|
|
285
|
+
class_type: "RandomNoise",
|
|
286
|
+
inputs: { noise_seed: seed }
|
|
287
|
+
},
|
|
288
|
+
enc: {
|
|
289
|
+
class_type: "CLIPTextEncode",
|
|
290
|
+
inputs: { text, clip: ["clip", 0] }
|
|
291
|
+
},
|
|
292
|
+
guid: {
|
|
293
|
+
class_type: "FluxGuidance",
|
|
294
|
+
inputs: { conditioning: ["enc", 0], guidance: 4 }
|
|
295
|
+
},
|
|
296
|
+
reflat: {
|
|
297
|
+
class_type: "ReferenceLatent",
|
|
298
|
+
// 注意:本机 ReferenceLatent 节点仅收 conditioning+latent,无 strength 参数(node 定义经 /object_info 核实)
|
|
299
|
+
inputs: { conditioning: ["guid", 0], latent: ["enc_img", 0] }
|
|
300
|
+
},
|
|
301
|
+
guider: {
|
|
302
|
+
class_type: "BasicGuider",
|
|
303
|
+
inputs: { model: ["unet", 0], conditioning: ["reflat", 0] }
|
|
304
|
+
},
|
|
305
|
+
empty: {
|
|
306
|
+
class_type: "EmptyFlux2LatentImage",
|
|
307
|
+
inputs: { width: 1024, height: 1024, batch_size: 1 }
|
|
308
|
+
},
|
|
309
|
+
sample: {
|
|
310
|
+
class_type: "SamplerCustomAdvanced",
|
|
311
|
+
inputs: {
|
|
312
|
+
noise: ["noise", 0],
|
|
313
|
+
guider: ["guider", 0],
|
|
314
|
+
sampler: ["sel", 0],
|
|
315
|
+
sigmas: ["sched", 0],
|
|
316
|
+
latent_image: ["empty", 0]
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
dec: {
|
|
320
|
+
class_type: "VAEDecode",
|
|
321
|
+
inputs: { samples: ["sample", 0], vae: ["vae", 0] }
|
|
322
|
+
},
|
|
323
|
+
save: {
|
|
324
|
+
class_type: "SaveImage",
|
|
325
|
+
inputs: { images: ["dec", 0], filename_prefix: "flux2_render" }
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function randomSeed() {
|
|
330
|
+
return Math.floor(Math.random() * 4294967295) >>> 0;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// src/agent/diagnose.ts
|
|
334
|
+
function diagnose(rawError) {
|
|
335
|
+
const msg = rawError.toLowerCase();
|
|
336
|
+
if (msg.includes("vram") && msg.includes("grow")) {
|
|
337
|
+
return {
|
|
338
|
+
message: "ComfyUI 报 VRAM grow failed(GPU 可能被占或模型加载失败)。等一下再试,别切卡。",
|
|
339
|
+
retryable: false
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
if (msg.includes("out of memory") || msg.includes("cuda oom") || msg.includes("oom")) {
|
|
343
|
+
return {
|
|
344
|
+
message: "显存不足,稍后重试。",
|
|
345
|
+
retryable: true
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
if (msg.includes("no module named matplotlib")) {
|
|
349
|
+
return {
|
|
350
|
+
message: "comfyui_controlnet_aux 依赖警告(matplotlib 缺失),不影响本流程,忽略。",
|
|
351
|
+
retryable: true
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
if (msg.includes("timeout") || msg.includes("running") || msg.includes("queue")) {
|
|
355
|
+
return {
|
|
356
|
+
message: "任务仍在 running(可能首次加载模型慢),等一下即可。",
|
|
357
|
+
retryable: true
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
if (msg.includes("validation") || msg.includes("node_errors") || msg.includes("invalid")) {
|
|
361
|
+
return {
|
|
362
|
+
message: "workflow 校验失败:参数已固定,换 seed 重试一次。",
|
|
363
|
+
retryable: true
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
message: `未知报错:${rawError.slice(0, 200)}`,
|
|
368
|
+
retryable: false
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/agent/brain.ts
|
|
373
|
+
async function runTurn(deps, session, userFeedback, opts) {
|
|
374
|
+
const { vision, logger } = deps;
|
|
375
|
+
if (opts.runVision && vision.enabled && opts.sourceDataUrl) {
|
|
376
|
+
try {
|
|
377
|
+
const analysis = await runAnalysisOnly(deps, opts.sourceDataUrl);
|
|
378
|
+
session.materialAnalysis = analysis;
|
|
379
|
+
logger.info("vision analysis ok len=%d preview=%s", analysis.length, analysis.slice(0, 60).replace(/\s+/g, " "));
|
|
380
|
+
} catch (err) {
|
|
381
|
+
logger.warn("vision analysis failed: %s", errMsg(err));
|
|
382
|
+
session.materialAnalysis = session.materialAnalysis || "(vision 分析失败)";
|
|
383
|
+
}
|
|
384
|
+
} else if (!vision.enabled && !session.materialAnalysis) {
|
|
385
|
+
session.materialAnalysis = "(vision 未启用)";
|
|
386
|
+
}
|
|
387
|
+
return runGenerateOnly(deps, session, userFeedback);
|
|
388
|
+
}
|
|
389
|
+
async function runAnalysisOnly(deps, dataUrl) {
|
|
390
|
+
const { vision } = deps;
|
|
391
|
+
if (!vision.enabled) return "(vision 未启用)";
|
|
392
|
+
let report = await runVisionAnalysis(vision, dataUrl, deps.visionSystemPrompt, deps.visionUserText);
|
|
393
|
+
const rounds = Math.max(1, Math.floor(deps.visionCrosscheckRounds) || 1);
|
|
394
|
+
for (let i = 1; i < rounds; i++) {
|
|
395
|
+
const crossText = (deps.visionCrosscheckText || "").replace("{prev}", report);
|
|
396
|
+
report = await runVisionAnalysis(vision, dataUrl, deps.visionSystemPrompt, crossText);
|
|
397
|
+
deps.logger.info(
|
|
398
|
+
"vision crosscheck round=%d/%d len=%d preview=%s",
|
|
399
|
+
i + 1,
|
|
400
|
+
rounds,
|
|
401
|
+
report.length,
|
|
402
|
+
report.slice(0, 60).replace(/\s+/g, " ")
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
return report;
|
|
406
|
+
}
|
|
407
|
+
async function runVisionAnalysis(vision, dataUrl, systemPrompt, userText) {
|
|
408
|
+
const messages = [
|
|
409
|
+
{ role: "system", content: systemPrompt },
|
|
410
|
+
{
|
|
411
|
+
role: "user",
|
|
412
|
+
content: [
|
|
413
|
+
{ type: "text", text: userText },
|
|
414
|
+
{ type: "image_url", image_url: { url: dataUrl } }
|
|
415
|
+
]
|
|
416
|
+
}
|
|
417
|
+
];
|
|
418
|
+
return (await vision.chat(messages, { temperature: 0.1, jsonMode: true })).trim();
|
|
419
|
+
}
|
|
420
|
+
async function runGenerateOnly(deps, session, userFeedback) {
|
|
421
|
+
const { main, comfy, lock, logger } = deps;
|
|
422
|
+
const decision = await runMainLlm(main, session, userFeedback, deps.mainSystemPrompt);
|
|
423
|
+
const backdrop = decision.background ? buildBackdropFromOverride(decision.background) : buildBackdropClause(deps.defaultBackgroundColor);
|
|
424
|
+
const promptText = `${IDENTITY_ANCHOR} ${backdrop} ${LIGHTFIELD_ANCHOR} ${decision.prompt}`;
|
|
425
|
+
logger.info("brain prompt len=%d ds=%d backdrop=%s lightfield=%d preview=%s", promptText.length, decision.prompt.length, decision.background?.slice(0, 40) || deps.defaultBackgroundColor || "yellow", LIGHTFIELD_ANCHOR.length, promptText.slice(0, 160).replace(/\s+/g, " "));
|
|
426
|
+
logger.info("brain note=%s", decision.note ?? "");
|
|
427
|
+
let seed = session.params.seed;
|
|
428
|
+
if (!Number.isFinite(seed) || seed < 0) seed = randomSeed();
|
|
429
|
+
let attempt = 0;
|
|
430
|
+
const maxAttempts = 1 + Math.max(0, deps.maxDiagnoseRetries);
|
|
431
|
+
let lastError = "";
|
|
432
|
+
while (attempt < maxAttempts) {
|
|
433
|
+
attempt++;
|
|
434
|
+
try {
|
|
435
|
+
const result = await lock.run(async () => {
|
|
436
|
+
const workflow = buildFlux2Workflow({
|
|
437
|
+
imageName: session.source.name,
|
|
438
|
+
text: promptText,
|
|
439
|
+
seed
|
|
440
|
+
});
|
|
441
|
+
const promptId = await comfy.submitPrompt(workflow, deps.clientId);
|
|
442
|
+
logger.info("comfy submit ok prompt_id=%s attempt=%d seed=%d", promptId, attempt, seed);
|
|
443
|
+
const history = await comfy.pollUntilDone(promptId, {
|
|
444
|
+
timeoutMs: deps.comfyTimeoutMs,
|
|
445
|
+
onTick: (t) => {
|
|
446
|
+
deps.onGenerateProgress?.(t);
|
|
447
|
+
if (t % 15e3 < 1600) logger.info("poll elapsed=%dms prompt_id=%s", t, promptId);
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
return { promptId, history };
|
|
451
|
+
});
|
|
452
|
+
const outputImage = pickOutputImage(result.history);
|
|
453
|
+
if (!outputImage) {
|
|
454
|
+
const errMsgText = extractHistoryError(result.history) || "未生成输出图(未知)";
|
|
455
|
+
throw new Error(errMsgText);
|
|
456
|
+
}
|
|
457
|
+
const buf = await comfy.view(outputImage.filename, outputImage.subfolder ?? "", "output");
|
|
458
|
+
session.params = { seed };
|
|
459
|
+
return {
|
|
460
|
+
imageBuffer: buf,
|
|
461
|
+
filename: outputImage.filename,
|
|
462
|
+
params: session.params,
|
|
463
|
+
promptId: result.promptId,
|
|
464
|
+
note: decision.note,
|
|
465
|
+
material: decision.material,
|
|
466
|
+
lighting: decision.lighting,
|
|
467
|
+
background: decision.background
|
|
468
|
+
};
|
|
469
|
+
} catch (err) {
|
|
470
|
+
lastError = errMsg(err);
|
|
471
|
+
session.errorHistory.push({ at: Date.now(), message: lastError });
|
|
472
|
+
const hint = diagnose(lastError);
|
|
473
|
+
logger.warn(
|
|
474
|
+
"brain attempt=%d failed: %s → hint: %s retryable=%s",
|
|
475
|
+
attempt,
|
|
476
|
+
lastError.slice(0, 200),
|
|
477
|
+
hint.message,
|
|
478
|
+
hint.retryable
|
|
479
|
+
);
|
|
480
|
+
session.errorHistory[session.errorHistory.length - 1].hint = hint.message;
|
|
481
|
+
if (!hint.retryable || attempt >= maxAttempts) {
|
|
482
|
+
throw new Error(`${lastError}
|
|
483
|
+
诊断:${hint.message}`);
|
|
484
|
+
}
|
|
485
|
+
seed = randomSeed();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
throw new Error(`brain 重试耗尽(${maxAttempts} 次):${lastError}`);
|
|
489
|
+
}
|
|
490
|
+
async function runMainLlm(main, session, feedback, systemPrompt) {
|
|
491
|
+
const contextMessages = [];
|
|
492
|
+
contextMessages.push({ role: "system", content: systemPrompt });
|
|
493
|
+
for (const m of session.llmContext) contextMessages.push(m);
|
|
494
|
+
const userText = [
|
|
495
|
+
`[本轮反馈]
|
|
496
|
+
${feedback || "(无新反馈,请按当前状态生成或微调一次)"}`,
|
|
497
|
+
`[当前 params 快照]
|
|
498
|
+
${JSON.stringify(session.params)}`,
|
|
499
|
+
`[${VISION_REPORT_LABEL}]
|
|
500
|
+
${session.materialAnalysis}`,
|
|
501
|
+
session.errorHistory.length ? `[错误历史(最近3条)]
|
|
502
|
+
${session.errorHistory.slice(-3).map((e) => `- ${e.message}${e.hint ? `|诊断:${e.hint}` : ""}`).join("\n")}` : "",
|
|
503
|
+
"请直接输出最终的 FLUX.2 prompt(英文自然语言一段),如需可另起一行以 NOTE: 开头给一句中文备注。"
|
|
504
|
+
].filter(Boolean).join("\n\n");
|
|
505
|
+
const userMsg = { role: "user", content: userText };
|
|
506
|
+
contextMessages.push(userMsg);
|
|
507
|
+
const raw = await main.chat(contextMessages, { temperature: 0.7 });
|
|
508
|
+
const parsed = parsePrompt(raw);
|
|
509
|
+
session.llmContext.push(userMsg);
|
|
510
|
+
session.llmContext.push({ role: "assistant", content: parsed.prompt });
|
|
511
|
+
trimContext(session);
|
|
512
|
+
return parsed;
|
|
513
|
+
}
|
|
514
|
+
function parsePrompt(raw) {
|
|
515
|
+
let text = raw.trim().replace(/^```(?:text|prompt|en)?\s*/i, "").replace(/```$/i, "").trim();
|
|
516
|
+
if (!text) throw new Error("主 LLM 输出为空");
|
|
517
|
+
let note;
|
|
518
|
+
const noteLine = text.match(/\n?:?\s*NOTE:\s*([\s\S]*)$/i);
|
|
519
|
+
if (noteLine && noteLine.index !== void 0 && noteLine[1]) {
|
|
520
|
+
note = noteLine[1].trim();
|
|
521
|
+
text = text.slice(0, noteLine.index).trim();
|
|
522
|
+
}
|
|
523
|
+
let background;
|
|
524
|
+
const bgLine = text.match(/^BACKGROUND:\s*(.+)$/im);
|
|
525
|
+
if (bgLine && bgLine[1]) {
|
|
526
|
+
background = bgLine[1].trim();
|
|
527
|
+
text = text.replace(/^BACKGROUND:[^\n]*\n?/im, "").trim();
|
|
528
|
+
}
|
|
529
|
+
const prompt = text || (background ? "" : raw.trim());
|
|
530
|
+
return { prompt, note, background };
|
|
531
|
+
}
|
|
532
|
+
function pickOutputImage(history) {
|
|
533
|
+
const outs = history?.outputs;
|
|
534
|
+
if (!outs) return null;
|
|
535
|
+
const preferred = outs["save"];
|
|
536
|
+
const fromPreferred = preferred?.images?.[0];
|
|
537
|
+
if (fromPreferred) return fromPreferred;
|
|
538
|
+
for (const key of Object.keys(outs)) {
|
|
539
|
+
const img = outs[key]?.images?.[0];
|
|
540
|
+
if (img) return img;
|
|
541
|
+
}
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
function extractHistoryError(history) {
|
|
545
|
+
const msgs = history?.status?.messages;
|
|
546
|
+
if (!Array.isArray(msgs)) return "";
|
|
547
|
+
const parts = [];
|
|
548
|
+
for (const m of msgs) {
|
|
549
|
+
if (!Array.isArray(m)) continue;
|
|
550
|
+
if (m[0] === "execution_error" || m[0] === "execution_interrupted" || m[0] === "execution_cached") {
|
|
551
|
+
parts.push(JSON.stringify(m).slice(0, 300));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return parts.join(" | ");
|
|
555
|
+
}
|
|
556
|
+
function trimContext(session, maxMessages = 20) {
|
|
557
|
+
if (session.llmContext.length > maxMessages) {
|
|
558
|
+
session.llmContext.splice(0, session.llmContext.length - maxMessages);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
function errMsg(err) {
|
|
562
|
+
return err instanceof Error ? err.message : String(err);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// src/agent/session.ts
|
|
566
|
+
var SessionStore = class {
|
|
567
|
+
constructor(idleTimeoutMs) {
|
|
568
|
+
this.idleTimeoutMs = idleTimeoutMs;
|
|
569
|
+
}
|
|
570
|
+
map = /* @__PURE__ */ new Map();
|
|
571
|
+
sweepTimer = null;
|
|
572
|
+
/** 记录最近被空闲清除的会话 key → 清除时刻 ms(供“会话已过期”提示,区分“从未有会话”) */
|
|
573
|
+
recentlyExpired = /* @__PURE__ */ new Map();
|
|
574
|
+
key(input) {
|
|
575
|
+
if (!input.guildId) return `dm:${input.userId}`;
|
|
576
|
+
if (input.perUserInGroup) return `g:${input.channelId}:${input.userId}`;
|
|
577
|
+
return `g:${input.channelId}`;
|
|
578
|
+
}
|
|
579
|
+
get(key) {
|
|
580
|
+
const s = this.map.get(key);
|
|
581
|
+
if (!s) return void 0;
|
|
582
|
+
if (Date.now() - s.lastUsedAt > this.idleTimeoutMs) {
|
|
583
|
+
this.map.delete(key);
|
|
584
|
+
this.recentlyExpired.set(key, Date.now());
|
|
585
|
+
return void 0;
|
|
586
|
+
}
|
|
587
|
+
return s;
|
|
588
|
+
}
|
|
589
|
+
/** 该 key 是否有“刚过期”的会话(用于会话已过期提示;expiryWindowMs 内有效) */
|
|
590
|
+
wasRecentlyExpired(key, expiryWindowMs) {
|
|
591
|
+
const t = this.recentlyExpired.get(key);
|
|
592
|
+
if (t === void 0) return false;
|
|
593
|
+
if (Date.now() - t > expiryWindowMs) {
|
|
594
|
+
this.recentlyExpired.delete(key);
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
/** 销毁后显式记录过期(用户 + 空清空 / 换图重建时),保证后续提示一致 */
|
|
600
|
+
markExpired(key) {
|
|
601
|
+
this.recentlyExpired.set(key, Date.now());
|
|
602
|
+
}
|
|
603
|
+
set(key, session) {
|
|
604
|
+
this.map.set(key, session);
|
|
605
|
+
}
|
|
606
|
+
delete(key) {
|
|
607
|
+
return this.map.delete(key);
|
|
608
|
+
}
|
|
609
|
+
size() {
|
|
610
|
+
return this.map.size;
|
|
611
|
+
}
|
|
612
|
+
touch(session) {
|
|
613
|
+
session.lastUsedAt = Date.now();
|
|
614
|
+
}
|
|
615
|
+
startSweep(intervalMs = 5 * 60 * 1e3) {
|
|
616
|
+
if (this.sweepTimer) return;
|
|
617
|
+
this.sweepTimer = setInterval(() => {
|
|
618
|
+
const now = Date.now();
|
|
619
|
+
for (const [k, s] of this.map) {
|
|
620
|
+
if (now - s.lastUsedAt > this.idleTimeoutMs) {
|
|
621
|
+
this.map.delete(k);
|
|
622
|
+
this.recentlyExpired.set(k, now);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}, intervalMs);
|
|
626
|
+
if (typeof this.sweepTimer === "object" && this.sweepTimer && "unref" in this.sweepTimer) {
|
|
627
|
+
;
|
|
628
|
+
this.sweepTimer.unref();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
stopSweep() {
|
|
632
|
+
if (this.sweepTimer) {
|
|
633
|
+
clearInterval(this.sweepTimer);
|
|
634
|
+
this.sweepTimer = null;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
var SerialLock = class {
|
|
639
|
+
chain = Promise.resolve();
|
|
640
|
+
run(task) {
|
|
641
|
+
const next = this.chain.then(task, task);
|
|
642
|
+
this.chain = next.then(
|
|
643
|
+
() => void 0,
|
|
644
|
+
() => void 0
|
|
645
|
+
);
|
|
646
|
+
return next;
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
function initialParams(seed) {
|
|
650
|
+
return { seed };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// src/agent/render-facade.ts
|
|
654
|
+
function createRenderFacade(deps) {
|
|
655
|
+
return {
|
|
656
|
+
async renderOneShot(input) {
|
|
657
|
+
const main = deps.getMainClient();
|
|
658
|
+
if (!main.enabled) {
|
|
659
|
+
throw new Error("主 LLM 未启用或未解析到 model,请在配置里补 baseUrl/apiKey/model 再试。");
|
|
660
|
+
}
|
|
661
|
+
const downloaded = await fetchImage(deps.ctx, input.imageUrl, deps.comfyTimeoutMs);
|
|
662
|
+
const filename = `input_${Date.now()}.${extForMime(downloaded.mimeType)}`;
|
|
663
|
+
const name2 = await deps.comfy.uploadImage(downloaded.buffer, filename, downloaded.mimeType);
|
|
664
|
+
const uploaded = { name: name2, mimeType: downloaded.mimeType, bytes: downloaded.buffer.length };
|
|
665
|
+
deps.logger.info("facade upload ok name=%s bytes=%d", name2, downloaded.buffer.length);
|
|
666
|
+
const baseParams = initialParams(randomSeed());
|
|
667
|
+
const merged = { ...baseParams, ...input.paramsOverride ?? {} };
|
|
668
|
+
if (!Number.isFinite(merged.seed) || merged.seed < 0) merged.seed = randomSeed();
|
|
669
|
+
if (input.confirmBrake) {
|
|
670
|
+
let analysis = "";
|
|
671
|
+
try {
|
|
672
|
+
const vision = deps.getVisionClient();
|
|
673
|
+
if (vision.enabled) {
|
|
674
|
+
analysis = await runAnalysisOnly(
|
|
675
|
+
{
|
|
676
|
+
main,
|
|
677
|
+
vision,
|
|
678
|
+
comfy: deps.comfy,
|
|
679
|
+
lock: deps.lock,
|
|
680
|
+
logger: deps.logger,
|
|
681
|
+
clientId: deps.clientId,
|
|
682
|
+
comfyTimeoutMs: deps.comfyTimeoutMs,
|
|
683
|
+
maxDiagnoseRetries: deps.maxDiagnoseRetries,
|
|
684
|
+
mainSystemPrompt: deps.mainSystemPrompt,
|
|
685
|
+
visionSystemPrompt: deps.visionSystemPrompt,
|
|
686
|
+
visionUserText: deps.visionUserText,
|
|
687
|
+
visionCrosscheckRounds: deps.visionCrosscheckRounds,
|
|
688
|
+
visionCrosscheckText: deps.visionCrosscheckText,
|
|
689
|
+
defaultBackgroundColor: deps.defaultBackgroundColor
|
|
690
|
+
},
|
|
691
|
+
downloaded.dataUrl
|
|
692
|
+
);
|
|
693
|
+
} else {
|
|
694
|
+
analysis = "(vision 未启用)";
|
|
695
|
+
}
|
|
696
|
+
deps.logger.info("facade confirmBrake analysis len=%d preview=%s", analysis.length, analysis.slice(0, 60).replace(/\s+/g, " "));
|
|
697
|
+
} catch (err) {
|
|
698
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
699
|
+
throw new Error(`视觉分析失败:${msg}`);
|
|
700
|
+
}
|
|
701
|
+
return {
|
|
702
|
+
buffer: Buffer.alloc(0),
|
|
703
|
+
filename: "",
|
|
704
|
+
params: merged,
|
|
705
|
+
comfyViewUrl: "",
|
|
706
|
+
awaitingAnalysis: analysis
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
const session = {
|
|
710
|
+
key: `chatluna:${input.userId}:${Date.now()}`,
|
|
711
|
+
channelId: input.channelId ?? "",
|
|
712
|
+
userId: input.userId,
|
|
713
|
+
guildId: input.guildId,
|
|
714
|
+
source: uploaded,
|
|
715
|
+
materialAnalysis: "",
|
|
716
|
+
turns: 0,
|
|
717
|
+
params: merged,
|
|
718
|
+
llmContext: [],
|
|
719
|
+
errorHistory: [],
|
|
720
|
+
createdAt: Date.now(),
|
|
721
|
+
lastUsedAt: Date.now(),
|
|
722
|
+
busy: false
|
|
723
|
+
};
|
|
724
|
+
const result = await runTurn(
|
|
725
|
+
{
|
|
726
|
+
main,
|
|
727
|
+
vision: deps.getVisionClient(),
|
|
728
|
+
comfy: deps.comfy,
|
|
729
|
+
lock: deps.lock,
|
|
730
|
+
logger: deps.logger,
|
|
731
|
+
clientId: deps.clientId,
|
|
732
|
+
comfyTimeoutMs: deps.comfyTimeoutMs,
|
|
733
|
+
maxDiagnoseRetries: deps.maxDiagnoseRetries,
|
|
734
|
+
mainSystemPrompt: deps.mainSystemPrompt,
|
|
735
|
+
visionSystemPrompt: deps.visionSystemPrompt,
|
|
736
|
+
visionUserText: deps.visionUserText,
|
|
737
|
+
visionCrosscheckRounds: deps.visionCrosscheckRounds,
|
|
738
|
+
visionCrosscheckText: deps.visionCrosscheckText,
|
|
739
|
+
defaultBackgroundColor: deps.defaultBackgroundColor
|
|
740
|
+
},
|
|
741
|
+
session,
|
|
742
|
+
input.instruction,
|
|
743
|
+
{ runVision: true, sourceDataUrl: downloaded.dataUrl }
|
|
744
|
+
);
|
|
745
|
+
const comfyViewUrl = buildComfyViewUrl(deps.comfy.baseUrl, result.filename);
|
|
746
|
+
return {
|
|
747
|
+
buffer: result.imageBuffer,
|
|
748
|
+
filename: result.filename,
|
|
749
|
+
note: result.note,
|
|
750
|
+
params: result.params,
|
|
751
|
+
comfyViewUrl,
|
|
752
|
+
material: result.material,
|
|
753
|
+
lighting: result.lighting,
|
|
754
|
+
background: result.background
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
async function fetchImage(ctx, url, timeoutMs) {
|
|
760
|
+
let buffer;
|
|
761
|
+
let mimeType = "image/png";
|
|
762
|
+
if (url.startsWith("internal:") || url.startsWith("file:") || url.startsWith("data:")) {
|
|
763
|
+
const file = await ctx.http.file(url, { timeout: timeoutMs });
|
|
764
|
+
buffer = Buffer.from(file.data);
|
|
765
|
+
if (typeof file.type === "string" && file.type) mimeType = file.type;
|
|
766
|
+
} else {
|
|
767
|
+
const ab = await ctx.http.get(url, { responseType: "arraybuffer", timeout: timeoutMs });
|
|
768
|
+
buffer = Buffer.from(ab);
|
|
769
|
+
mimeType = sniffMime(buffer) || mimeType;
|
|
770
|
+
}
|
|
771
|
+
const dataUrl = `data:${mimeType};base64,${buffer.toString("base64")}`;
|
|
772
|
+
return { buffer, mimeType, dataUrl };
|
|
773
|
+
}
|
|
774
|
+
function sniffMime(buffer) {
|
|
775
|
+
if (buffer.length < 4) return null;
|
|
776
|
+
if (buffer[0] === 137 && buffer[1] === 80 && buffer[2] === 78 && buffer[3] === 71) return "image/png";
|
|
777
|
+
if (buffer[0] === 255 && buffer[1] === 216 && buffer[2] === 255) return "image/jpeg";
|
|
778
|
+
if (buffer[0] === 71 && buffer[1] === 73 && buffer[2] === 70) return "image/gif";
|
|
779
|
+
if (buffer[0] === 82 && buffer[1] === 73 && buffer[2] === 70 && buffer[3] === 70) return "image/webp";
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
function extForMime(mime) {
|
|
783
|
+
if (mime === "image/png") return "png";
|
|
784
|
+
if (mime === "image/jpeg") return "jpg";
|
|
785
|
+
if (mime === "image/gif") return "gif";
|
|
786
|
+
if (mime === "image/webp") return "webp";
|
|
787
|
+
return "png";
|
|
788
|
+
}
|
|
789
|
+
function buildComfyViewUrl(baseUrl, filename, subfolder = "", type = "output") {
|
|
790
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
791
|
+
return `${base}/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(subfolder)}&type=${type}`;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// src/bridge/chatluna/runtime.ts
|
|
795
|
+
var import_node_module = require("module");
|
|
796
|
+
var runtimeRequire = (0, import_node_module.createRequire)(`${process.cwd()}/package.json`);
|
|
797
|
+
async function loadChatLunaRuntime() {
|
|
798
|
+
const [chatlunaModule, langchainToolsModule] = await Promise.all([
|
|
799
|
+
loadRuntimeModule("koishi-plugin-chatluna/services/chat"),
|
|
800
|
+
loadRuntimeModule("@langchain/core/tools")
|
|
801
|
+
]);
|
|
802
|
+
const ChatLunaPlugin = chatlunaModule.ChatLunaPlugin;
|
|
803
|
+
const StructuredTool = langchainToolsModule.StructuredTool;
|
|
804
|
+
if (!ChatLunaPlugin) {
|
|
805
|
+
throw new Error("ChatLunaPlugin export not found from koishi-plugin-chatluna/services/chat.");
|
|
806
|
+
}
|
|
807
|
+
if (!StructuredTool) {
|
|
808
|
+
throw new Error("StructuredTool export not found from @langchain/core/tools.");
|
|
809
|
+
}
|
|
810
|
+
return { ChatLunaPlugin, StructuredTool };
|
|
811
|
+
}
|
|
812
|
+
async function loadRuntimeModule(specifier) {
|
|
813
|
+
try {
|
|
814
|
+
return runtimeRequire(specifier);
|
|
815
|
+
} catch (requireError) {
|
|
816
|
+
const dynamicImport = new Function("s", "return import(s)");
|
|
817
|
+
return dynamicImport(specifier).catch((importError) => {
|
|
818
|
+
const requireMessage = requireError instanceof Error ? requireError.message : String(requireError);
|
|
819
|
+
const importMessage = importError instanceof Error ? importError.message : String(importError);
|
|
820
|
+
throw new Error(`failed to load runtime module "${specifier}"; require: ${requireMessage}; import: ${importMessage}`);
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// src/bridge/chatluna/tool-runtime.ts
|
|
826
|
+
var import_koishi2 = require("koishi");
|
|
827
|
+
var TOOL_NAME = "comfy_render";
|
|
828
|
+
var TOOL_DESCRIPTION = "Run one ComfyUI FLUX.2 image-to-image render through the aka-comfy-agent internal agent (GLM asset probe + DS material/lighting/background judgment + fixed snippet assembly + workflow submit/poll/fetch). Prefer this tool when the user wants to modify or continue iterating an existing image with natural language.";
|
|
829
|
+
var TOOL_USAGE = "Provide the user instruction plus a referenceMode telling the tool where to load the source image from. The tool sends the rendered image back into the current session and returns { ok, imageUrl, note?, params? } as JSON.";
|
|
830
|
+
var COMFY_USAGE_GUIDE = '使用准则(务必遵守):\n1. 源图获取(referenceMode):优先让用户"引用(quote)"要修改的那张图片,然后用 quoted_message 模式自动取图;若引用本会话前一张图或"上次生成的图",用 last_generated;若用户给了图片 URL,用 explicit。仅在"当前这条用户消息本身直接带有图片"时才用 current_message——在 ChatLuna 里 current_message 通常拿不到上一条消息中的图。\n2. 拿不到源图时:不要长篇技术解释,也不许自己编一张图。直接引导用户:引用那张图片再发一次,或提供图片链接,或用文字描述画面。\n3. instruction 必须忠实转述用户的原话意图,禁止自行扩充或改写生成目标。\n4. 若工具的返回带 awaitingConfirmation / awaitingAnalysis,表示已进入确认闸:把图像分析报告转述给用户并等待其确认/修正,确认后再调用本工具真正生成,不要跳过确认直接出图。';
|
|
831
|
+
var TOOL_INPUT_SCHEMA = {
|
|
832
|
+
type: "object",
|
|
833
|
+
properties: {
|
|
834
|
+
instruction: {
|
|
835
|
+
type: "string",
|
|
836
|
+
description: "Natural-language instruction describing the desired modification / generation intent."
|
|
837
|
+
},
|
|
838
|
+
referenceMode: {
|
|
839
|
+
type: "string",
|
|
840
|
+
enum: ["current_message", "quoted_message", "explicit", "last_generated"],
|
|
841
|
+
description: "Where to load the source image from. Defaults to current_message."
|
|
842
|
+
},
|
|
843
|
+
imageUrls: {
|
|
844
|
+
type: "array",
|
|
845
|
+
items: { type: "string" },
|
|
846
|
+
description: "Explicit image URLs when referenceMode is explicit."
|
|
847
|
+
},
|
|
848
|
+
params: {
|
|
849
|
+
type: "object",
|
|
850
|
+
description: "Optional overrides for FLUX.2 render parameters (only seed is configurable; all other workflow params are fixed).",
|
|
851
|
+
properties: {
|
|
852
|
+
seed: { type: "number" }
|
|
853
|
+
},
|
|
854
|
+
additionalProperties: false
|
|
855
|
+
}
|
|
856
|
+
},
|
|
857
|
+
required: ["instruction"],
|
|
858
|
+
additionalProperties: false
|
|
859
|
+
};
|
|
860
|
+
function createComfyRenderToolInstance(StructuredTool, deps) {
|
|
861
|
+
return new class extends StructuredTool {
|
|
862
|
+
name = TOOL_NAME;
|
|
863
|
+
description = [
|
|
864
|
+
TOOL_DESCRIPTION,
|
|
865
|
+
`Usage: ${TOOL_USAGE}`,
|
|
866
|
+
COMFY_USAGE_GUIDE,
|
|
867
|
+
`Input JSON schema: ${JSON.stringify(TOOL_INPUT_SCHEMA)}`
|
|
868
|
+
].join("\n");
|
|
869
|
+
schema = TOOL_INPUT_SCHEMA;
|
|
870
|
+
constructor() {
|
|
871
|
+
super({ verboseParsingErrors: true });
|
|
872
|
+
}
|
|
873
|
+
async _call(input, _runManager, runtimeConfig) {
|
|
874
|
+
const session = runtimeConfig?.configurable?.session;
|
|
875
|
+
if (!session?.userId) {
|
|
876
|
+
return formatToolError("会话无效,无法识别用户。");
|
|
877
|
+
}
|
|
878
|
+
try {
|
|
879
|
+
const cfg = runtimeConfig?.configurable ?? {};
|
|
880
|
+
const ac = cfg.agentContext;
|
|
881
|
+
const safe = (v, n = 120) => {
|
|
882
|
+
const s = typeof v === "string" ? v : JSON.stringify(v);
|
|
883
|
+
return !s ? "" : s.length > n ? s.slice(0, n) + "…" : s;
|
|
884
|
+
};
|
|
885
|
+
deps.logger?.info("[comfy_render] diag-config keys=%j", Object.keys(cfg));
|
|
886
|
+
deps.logger?.info(
|
|
887
|
+
"[comfy_render] diag-agentContext keys=%j conversationId=%s messages=%s",
|
|
888
|
+
ac ? Object.keys(ac) : "(none)",
|
|
889
|
+
ac?.conversationId ?? ac?.conversation_id ?? ac?.roomId ?? ac?.room_id ?? "",
|
|
890
|
+
Array.isArray(ac?.messages) ? `array(${ac.messages.length})` : typeof ac?.messages
|
|
891
|
+
);
|
|
892
|
+
if (Array.isArray(ac?.messages)) {
|
|
893
|
+
const lastImg = [...ac.messages].reverse().find((m) => {
|
|
894
|
+
const c = m?.content;
|
|
895
|
+
const s = typeof c === "string" ? c : JSON.stringify(c ?? "");
|
|
896
|
+
return /<img|image_url|data:image|internal:|img src/i.test(s);
|
|
897
|
+
});
|
|
898
|
+
deps.logger?.info(
|
|
899
|
+
"[comfy_render] diag-agentContext lastImageMsg role=%s contentHead=%s",
|
|
900
|
+
lastImg?.role ?? "",
|
|
901
|
+
safe(typeof lastImg?.content === "string" ? lastImg.content : JSON.stringify(lastImg?.content ?? ""), 200)
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
deps.logger?.info(
|
|
905
|
+
"[comfy_render] diag-session2 elements=%s eventKeys=%j",
|
|
906
|
+
Array.isArray(session.elements) ? safe(session.elements.map((e) => {
|
|
907
|
+
try {
|
|
908
|
+
return e && typeof e.toString === "function" ? e.toString() : String(e);
|
|
909
|
+
} catch {
|
|
910
|
+
return String(e);
|
|
911
|
+
}
|
|
912
|
+
}).join(" | ").slice(0, 200)) : typeof session.elements,
|
|
913
|
+
session.event ? Object.keys(session.event) : "(none)"
|
|
914
|
+
);
|
|
915
|
+
return await runComfyRender(input, session, deps);
|
|
916
|
+
} catch (error) {
|
|
917
|
+
return formatToolError(error instanceof Error ? error.message : String(error));
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}();
|
|
921
|
+
}
|
|
922
|
+
async function runComfyRender(input, session, deps) {
|
|
923
|
+
const userId = session.userId;
|
|
924
|
+
if (deps.userTaskLocks.has(userId)) {
|
|
925
|
+
return formatToolError("您有一个 comfy 渲染任务正在进行中,请等待完成。");
|
|
926
|
+
}
|
|
927
|
+
deps.userTaskLocks.add(userId);
|
|
928
|
+
try {
|
|
929
|
+
const instruction = typeof input.instruction === "string" ? input.instruction.trim() : "";
|
|
930
|
+
if (!instruction) return formatToolError("instruction 必填。");
|
|
931
|
+
const referenceMode = typeof input.referenceMode === "string" ? input.referenceMode : "current_message";
|
|
932
|
+
const imageUrls = resolveReferenceImages(referenceMode, input, session, deps.lastGenerated);
|
|
933
|
+
const sourceUrl = imageUrls[0];
|
|
934
|
+
if (!sourceUrl) {
|
|
935
|
+
return formatToolError(
|
|
936
|
+
`未能解析到源图(referenceMode=${referenceMode})。请按下面任一方式引导用户把源图提供给我,不要长篇技术解释,也不许自创一张图:(a) 让用户【引用(quote)】那张要修改的图片后再发一次 → 用 referenceMode=quoted_message;(b) 若是引用本会话刚生成/前一张图 → 用 referenceMode=last_generated;(c) 若用户能提供图片 URL → 用 referenceMode=explicit 并填 imageUrls。`
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
const paramsOverride = normalizeParamsOverride(input.params);
|
|
940
|
+
const confirmBrake = deps.getConfig().confirmBeforeGenerate?.chatluna === true;
|
|
941
|
+
const result = await deps.render.renderOneShot({
|
|
942
|
+
imageUrl: sourceUrl,
|
|
943
|
+
instruction,
|
|
944
|
+
paramsOverride,
|
|
945
|
+
userId,
|
|
946
|
+
channelId: session.channelId,
|
|
947
|
+
guildId: session.guildId,
|
|
948
|
+
confirmBrake
|
|
949
|
+
});
|
|
950
|
+
if (result.awaitingAnalysis) {
|
|
951
|
+
return formatToolJson({
|
|
952
|
+
ok: true,
|
|
953
|
+
awaitingConfirmation: true,
|
|
954
|
+
analysis: result.awaitingAnalysis,
|
|
955
|
+
instruction,
|
|
956
|
+
hint: "已将图像分析报告返回给模型。模型应向用户展示报告并征询确认/修正;用户确认后再次调用本工具生成(可附上修正后的 instruction)。"
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
if (typeof session.send === "function") {
|
|
960
|
+
try {
|
|
961
|
+
await Promise.resolve(session.send(import_koishi2.h.image(result.comfyViewUrl)));
|
|
962
|
+
} catch {
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
const config = deps.getConfig();
|
|
966
|
+
if (config.chatlunaRoomLastGenerated) {
|
|
967
|
+
const conversationId = resolveConversationId(session);
|
|
968
|
+
if (conversationId) {
|
|
969
|
+
deps.lastGenerated.set(conversationId, { imageUrl: result.comfyViewUrl, at: Date.now() });
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
return formatToolJson({
|
|
973
|
+
ok: true,
|
|
974
|
+
imageUrl: result.comfyViewUrl,
|
|
975
|
+
note: result.note,
|
|
976
|
+
params: result.params
|
|
977
|
+
});
|
|
978
|
+
} finally {
|
|
979
|
+
deps.userTaskLocks.delete(userId);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
function resolveReferenceImages(referenceMode, input, session, lastGenerated) {
|
|
983
|
+
if (referenceMode === "explicit") {
|
|
984
|
+
return normalizeImageUrls(Array.isArray(input.imageUrls) ? input.imageUrls : []);
|
|
985
|
+
}
|
|
986
|
+
if (referenceMode === "last_generated") {
|
|
987
|
+
const conversationId = resolveConversationId(session);
|
|
988
|
+
if (!conversationId) return [];
|
|
989
|
+
const rec = lastGenerated.get(conversationId);
|
|
990
|
+
return rec ? [rec.imageUrl] : [];
|
|
991
|
+
}
|
|
992
|
+
if (referenceMode === "current_message") {
|
|
993
|
+
return parseImagesFromMessageContent(session.content);
|
|
994
|
+
}
|
|
995
|
+
if (referenceMode === "quoted_message") {
|
|
996
|
+
return normalizeImageUrls([
|
|
997
|
+
...parseImagesFromMessageContent(session.quote?.content),
|
|
998
|
+
...Array.isArray(session.quote?.elements) ? import_koishi2.h.select(session.quote.elements, "img").map((img) => img.attrs?.src) : []
|
|
999
|
+
]);
|
|
1000
|
+
}
|
|
1001
|
+
return [];
|
|
1002
|
+
}
|
|
1003
|
+
var IMAGE_PLACEHOLDER_RE = /\[image:([^\]]+)\]/gi;
|
|
1004
|
+
function extractImagePlaceholders(text) {
|
|
1005
|
+
const urls = [];
|
|
1006
|
+
const re = new RegExp(IMAGE_PLACEHOLDER_RE.source, IMAGE_PLACEHOLDER_RE.flags);
|
|
1007
|
+
let m;
|
|
1008
|
+
while ((m = re.exec(text)) !== null) {
|
|
1009
|
+
const u = (m[1] ?? "").trim();
|
|
1010
|
+
if (u) urls.push(u);
|
|
1011
|
+
}
|
|
1012
|
+
return urls;
|
|
1013
|
+
}
|
|
1014
|
+
function parseImagesFromMessageContent(content) {
|
|
1015
|
+
if (typeof content === "string" && content.trim()) {
|
|
1016
|
+
return normalizeImageUrls([
|
|
1017
|
+
...import_koishi2.h.select(import_koishi2.h.parse(content), "img").map((img) => img.attrs?.src),
|
|
1018
|
+
...extractImagePlaceholders(content)
|
|
1019
|
+
]);
|
|
1020
|
+
}
|
|
1021
|
+
if (Array.isArray(content)) {
|
|
1022
|
+
const urls = [];
|
|
1023
|
+
for (const el of content) {
|
|
1024
|
+
if (!el || typeof el !== "object") continue;
|
|
1025
|
+
const type = el.type;
|
|
1026
|
+
if (type === "img" && el.attrs?.src) {
|
|
1027
|
+
urls.push(el.attrs.src);
|
|
1028
|
+
} else if (type === "text" && el.attrs?.content) {
|
|
1029
|
+
urls.push(...extractImagePlaceholders(String(el.attrs.content)));
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return normalizeImageUrls(urls);
|
|
1033
|
+
}
|
|
1034
|
+
return [];
|
|
1035
|
+
}
|
|
1036
|
+
function normalizeImageUrls(items) {
|
|
1037
|
+
return items.filter((item) => typeof item === "string" && item.trim().length > 0).map((item) => item.trim());
|
|
1038
|
+
}
|
|
1039
|
+
function resolveConversationId(session) {
|
|
1040
|
+
const cid = session.conversationId ?? session.conversation_id ?? session.roomId ?? session.room_id;
|
|
1041
|
+
if (typeof cid === "string" && cid.trim()) return cid.trim();
|
|
1042
|
+
return void 0;
|
|
1043
|
+
}
|
|
1044
|
+
function normalizeParamsOverride(v) {
|
|
1045
|
+
if (!v || typeof v !== "object") return void 0;
|
|
1046
|
+
const obj = v;
|
|
1047
|
+
const patch = {};
|
|
1048
|
+
const raw = obj["seed"];
|
|
1049
|
+
if (typeof raw === "number" && Number.isFinite(raw)) patch.seed = raw;
|
|
1050
|
+
else if (typeof raw === "string" && raw.trim() && Number.isFinite(Number(raw))) patch.seed = Number(raw);
|
|
1051
|
+
return Object.keys(patch).length ? patch : void 0;
|
|
1052
|
+
}
|
|
1053
|
+
function formatToolError(message) {
|
|
1054
|
+
return formatToolJson({ ok: false, error: message });
|
|
1055
|
+
}
|
|
1056
|
+
function formatToolJson(value) {
|
|
1057
|
+
return JSON.stringify(value, null, 2);
|
|
1058
|
+
}
|
|
1059
|
+
var TOOL_TRIGGER_RE = /comfy|v2|改图|重绘|渲染|换成|生成图像|把.{0,10}?(背景|颜色|风格|材质)|背景|配色|材质|毛绒|编辑.{0,4}图/i;
|
|
1060
|
+
function extractMessageText(content) {
|
|
1061
|
+
if (typeof content === "string") return content;
|
|
1062
|
+
if (Array.isArray(content)) {
|
|
1063
|
+
return content.map((block) => {
|
|
1064
|
+
if (typeof block === "string") return block;
|
|
1065
|
+
if (block && typeof block === "object") {
|
|
1066
|
+
const anyBlock = block;
|
|
1067
|
+
return [anyBlock.text, anyBlock.content_url, anyBlock.image_url, anyBlock.url].filter((v) => typeof v === "string").join(" ");
|
|
1068
|
+
}
|
|
1069
|
+
return "";
|
|
1070
|
+
}).filter(Boolean).join(" ");
|
|
1071
|
+
}
|
|
1072
|
+
return "";
|
|
1073
|
+
}
|
|
1074
|
+
function shouldActivateComfyTool(messages) {
|
|
1075
|
+
const arr = Array.isArray(messages) ? messages : [];
|
|
1076
|
+
const last = arr[arr.length - 1];
|
|
1077
|
+
if (!last) return false;
|
|
1078
|
+
const text = extractMessageText(last.content).trim();
|
|
1079
|
+
if (!text) return Boolean(last.images || last.elements);
|
|
1080
|
+
if (/\[image:/i.test(text)) return true;
|
|
1081
|
+
return TOOL_TRIGGER_RE.test(text) || /<img|!\[[^\]]*\]\(https?/.test(text);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// src/bridge/chatluna/manager.ts
|
|
1085
|
+
var CHATLUNA_BRIDGE_PLATFORM_NAME = "aka-comfy-agent-chatluna-bridge";
|
|
1086
|
+
var COMFY_RENDER_TOOL_NAME = "comfy_render";
|
|
1087
|
+
var ChatLunaBridgeManager = class {
|
|
1088
|
+
constructor(ctx, deps, config, logger) {
|
|
1089
|
+
this.ctx = ctx;
|
|
1090
|
+
this.deps = deps;
|
|
1091
|
+
this.config = config;
|
|
1092
|
+
this.logger = logger;
|
|
1093
|
+
}
|
|
1094
|
+
chatLunaPlugin;
|
|
1095
|
+
warnedUnavailable = false;
|
|
1096
|
+
syncQueue = Promise.resolve();
|
|
1097
|
+
getConfig = () => this.config;
|
|
1098
|
+
updateConfig(config) {
|
|
1099
|
+
this.config = config;
|
|
1100
|
+
}
|
|
1101
|
+
sync(enabled) {
|
|
1102
|
+
this.syncQueue = this.syncQueue.catch(() => {
|
|
1103
|
+
}).then(async () => {
|
|
1104
|
+
if (enabled) await this.enable();
|
|
1105
|
+
else await this.disable();
|
|
1106
|
+
});
|
|
1107
|
+
return this.syncQueue;
|
|
1108
|
+
}
|
|
1109
|
+
async dispose() {
|
|
1110
|
+
await this.disable();
|
|
1111
|
+
}
|
|
1112
|
+
async enable() {
|
|
1113
|
+
const chatlunaService = this.getChatLunaService();
|
|
1114
|
+
if (!chatlunaService) {
|
|
1115
|
+
if (!this.warnedUnavailable) {
|
|
1116
|
+
this.logger.warn("ChatLuna bridge enabled, but ChatLuna service is not available. Install and enable ChatLuna first.");
|
|
1117
|
+
this.warnedUnavailable = true;
|
|
1118
|
+
}
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
if (this.chatLunaPlugin) {
|
|
1122
|
+
this.warnedUnavailable = false;
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
const runtime2 = await loadChatLunaRuntime().catch((error) => {
|
|
1126
|
+
if (!this.warnedUnavailable) {
|
|
1127
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1128
|
+
this.logger.warn("ChatLuna bridge enabled but runtime modules are unavailable: %s", message);
|
|
1129
|
+
this.warnedUnavailable = true;
|
|
1130
|
+
}
|
|
1131
|
+
return null;
|
|
1132
|
+
});
|
|
1133
|
+
if (!runtime2) return;
|
|
1134
|
+
const existing = chatlunaService.getPlugin?.(CHATLUNA_BRIDGE_PLATFORM_NAME);
|
|
1135
|
+
if (existing) {
|
|
1136
|
+
this.logger.warn('ChatLuna plugin name "%s" is already in use, skip built-in bridge registration.', CHATLUNA_BRIDGE_PLATFORM_NAME);
|
|
1137
|
+
this.warnedUnavailable = true;
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
const plugin = new runtime2.ChatLunaPlugin(this.ctx, {}, CHATLUNA_BRIDGE_PLATFORM_NAME, false);
|
|
1141
|
+
this.registerTools(plugin, runtime2.StructuredTool);
|
|
1142
|
+
if (!chatlunaService.getPlugin?.(CHATLUNA_BRIDGE_PLATFORM_NAME)) {
|
|
1143
|
+
await Promise.resolve(chatlunaService.installPlugin(plugin));
|
|
1144
|
+
}
|
|
1145
|
+
this.chatLunaPlugin = plugin;
|
|
1146
|
+
this.warnedUnavailable = false;
|
|
1147
|
+
this.logger.info("ChatLuna bridge enabled (tool: %s).", COMFY_RENDER_TOOL_NAME);
|
|
1148
|
+
}
|
|
1149
|
+
async disable() {
|
|
1150
|
+
if (!this.chatLunaPlugin) return;
|
|
1151
|
+
const plugin = this.chatLunaPlugin;
|
|
1152
|
+
this.chatLunaPlugin = void 0;
|
|
1153
|
+
const chatlunaService = this.getChatLunaService();
|
|
1154
|
+
try {
|
|
1155
|
+
chatlunaService?.uninstallPlugin(plugin);
|
|
1156
|
+
} catch (error) {
|
|
1157
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1158
|
+
this.logger.warn("failed to uninstall ChatLuna bridge plugin: %s", message);
|
|
1159
|
+
}
|
|
1160
|
+
try {
|
|
1161
|
+
await Promise.resolve(plugin.dispose?.());
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1164
|
+
this.logger.warn("failed to dispose ChatLuna bridge plugin: %s", message);
|
|
1165
|
+
}
|
|
1166
|
+
this.logger.info("ChatLuna bridge disabled.");
|
|
1167
|
+
}
|
|
1168
|
+
registerTools(plugin, StructuredTool) {
|
|
1169
|
+
const deps = this.deps;
|
|
1170
|
+
const getConfig = this.getConfig;
|
|
1171
|
+
const logger = this.logger;
|
|
1172
|
+
plugin.registerTool(COMFY_RENDER_TOOL_NAME, {
|
|
1173
|
+
selector: (messages) => {
|
|
1174
|
+
const arr = Array.isArray(messages) ? messages : [];
|
|
1175
|
+
const last = arr[arr.length - 1];
|
|
1176
|
+
const name2 = last && last.name || last && last._name || "";
|
|
1177
|
+
const active = shouldActivateComfyTool(messages);
|
|
1178
|
+
logger.info(
|
|
1179
|
+
"[comfy_render] selector active=%s nmsg=%d lastRole=%s lastName=%s content=%j (head300=%s)",
|
|
1180
|
+
active,
|
|
1181
|
+
arr.length,
|
|
1182
|
+
last?.role ?? "",
|
|
1183
|
+
name2,
|
|
1184
|
+
arr.length ? JSON.stringify(last?.content).slice(0, 600) : "(no msg)",
|
|
1185
|
+
typeof last?.content === "string" ? last.content.slice(0, 300) : "(non-string)"
|
|
1186
|
+
);
|
|
1187
|
+
return active;
|
|
1188
|
+
},
|
|
1189
|
+
authorization(session) {
|
|
1190
|
+
if (session?.userId) {
|
|
1191
|
+
try {
|
|
1192
|
+
logger.info(
|
|
1193
|
+
"[comfy_render] diag-session keys=%j contentType=%s contentHead=%s elements=%s quote=%s msg=%s",
|
|
1194
|
+
Object.keys(session),
|
|
1195
|
+
typeof session.content,
|
|
1196
|
+
typeof session.content === "string" ? "<" + session.content.slice(0, 220).replace(/data:[^,]{0,40}/g, "data:[DATA]") + ">" : "(non-string)",
|
|
1197
|
+
Array.isArray(session.elements) ? `array(${session.elements.length})` : typeof session.elements,
|
|
1198
|
+
session.quote ? JSON.stringify(Object.keys(session.quote)) : "no",
|
|
1199
|
+
typeof session.message === "object" ? JSON.stringify(Object.keys(session.message)) : typeof session.message
|
|
1200
|
+
);
|
|
1201
|
+
} catch (e) {
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
return Boolean(session?.userId);
|
|
1205
|
+
},
|
|
1206
|
+
createTool() {
|
|
1207
|
+
return createComfyRenderToolInstance(StructuredTool, {
|
|
1208
|
+
render: deps.render,
|
|
1209
|
+
getConfig,
|
|
1210
|
+
lastGenerated: deps.lastGenerated,
|
|
1211
|
+
userTaskLocks: deps.userTaskLocks,
|
|
1212
|
+
logger
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
getChatLunaService() {
|
|
1218
|
+
const service = this.ctx.chatluna;
|
|
1219
|
+
if (!service) return null;
|
|
1220
|
+
if (typeof service.installPlugin !== "function" || typeof service.uninstallPlugin !== "function") return null;
|
|
1221
|
+
return service;
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
|
|
1225
|
+
// src/index.ts
|
|
1226
|
+
var name = "aka-comfy-agent";
|
|
1227
|
+
var inject = { optional: ["chatluna"] };
|
|
1228
|
+
var runtime = {
|
|
1229
|
+
store: null,
|
|
1230
|
+
lock: new SerialLock(),
|
|
1231
|
+
lastGenByConv: /* @__PURE__ */ new Map(),
|
|
1232
|
+
userTaskLocks: /* @__PURE__ */ new Set(),
|
|
1233
|
+
awaitingImage: /* @__PURE__ */ new Map()
|
|
1234
|
+
};
|
|
1235
|
+
function apply(ctx, config) {
|
|
1236
|
+
const logger = ctx.logger(name);
|
|
1237
|
+
if (!runtime.store || runtime.store.__idle !== config.session.idleTimeoutMs) {
|
|
1238
|
+
if (runtime.store) runtime.store.stopSweep();
|
|
1239
|
+
runtime.store = new SessionStore(config.session.idleTimeoutMs);
|
|
1240
|
+
runtime.store.__idle = config.session.idleTimeoutMs;
|
|
1241
|
+
runtime.store.startSweep();
|
|
1242
|
+
}
|
|
1243
|
+
const store = runtime.store;
|
|
1244
|
+
const lock = runtime.lock;
|
|
1245
|
+
const clientId = `koishi-aka-comfy-${Math.floor(Math.random() * 1e9)}`;
|
|
1246
|
+
const awaitingImage = runtime.awaitingImage;
|
|
1247
|
+
const AWAIT_TIMEOUT_MS = config.session.idleTimeoutMs;
|
|
1248
|
+
const awaitSweeper = setInterval(() => {
|
|
1249
|
+
const now = Date.now();
|
|
1250
|
+
for (const [k, p] of awaitingImage) {
|
|
1251
|
+
if (now - p.createdAt > AWAIT_TIMEOUT_MS) awaitingImage.delete(k);
|
|
1252
|
+
}
|
|
1253
|
+
}, 6e4);
|
|
1254
|
+
awaitSweeper.unref?.();
|
|
1255
|
+
const comfy = new ComfyClient(ctx, config.comfy.baseUrl, config.comfy.timeoutMs);
|
|
1256
|
+
const brainDeps = () => ({
|
|
1257
|
+
main: mainClient,
|
|
1258
|
+
vision: visionClient,
|
|
1259
|
+
comfy,
|
|
1260
|
+
lock,
|
|
1261
|
+
logger,
|
|
1262
|
+
clientId,
|
|
1263
|
+
comfyTimeoutMs: config.comfy.timeoutMs,
|
|
1264
|
+
maxDiagnoseRetries: config.session.maxDiagnoseRetries,
|
|
1265
|
+
mainSystemPrompt: config.mainLLM.systemPrompt?.trim() || MAIN_SYSTEM_PROMPT,
|
|
1266
|
+
visionSystemPrompt: config.visionLLM.systemPrompt?.trim() || GLM_PROBE_PROMPT,
|
|
1267
|
+
visionUserText: config.visionLLM.userText?.trim() || VISION_USER_TEXT,
|
|
1268
|
+
visionCrosscheckRounds: config.visionCrosscheckRounds,
|
|
1269
|
+
visionCrosscheckText: VISION_CROSSCHECK_TEXT,
|
|
1270
|
+
defaultBackgroundColor: config.defaultBackgroundColor,
|
|
1271
|
+
// Comfy 生成轮询期间编辑「正在生成」进度消息(约每 15s 一次,由 brain onTick 触发)
|
|
1272
|
+
onGenerateProgress: (elapsedMs) => {
|
|
1273
|
+
const g = genProgress;
|
|
1274
|
+
if (!g) return;
|
|
1275
|
+
const sec = Math.floor(elapsedMs / 1e3);
|
|
1276
|
+
void reportProgress(g.session, g.handle, `正在生成,已等待 ${sec}s…`).then((h3) => {
|
|
1277
|
+
if (genProgress === g) genProgress.handle = h3;
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
});
|
|
1281
|
+
let mainClient = new LlmClient(ctx, config.mainLLM, config.mainLLM.model, config.comfy.timeoutMs);
|
|
1282
|
+
let visionClient = new LlmClient(ctx, config.visionLLM, config.visionLLM.model, config.comfy.timeoutMs);
|
|
1283
|
+
const schemaScope = ctx.schema;
|
|
1284
|
+
const seedDynamic = (label, cfg, key) => {
|
|
1285
|
+
if (!schemaScope) return;
|
|
1286
|
+
const cur = cfg.model?.trim();
|
|
1287
|
+
if (cur) {
|
|
1288
|
+
schemaScope.set(key, import_koishi3.Schema.union([import_koishi3.Schema.const(cur).description(cur)]));
|
|
1289
|
+
} else {
|
|
1290
|
+
schemaScope.set(key, import_koishi3.Schema.string().description("模型(探测中,完成后可选)"));
|
|
1291
|
+
}
|
|
1292
|
+
logger.info("%s: 下拉占位已注册 key=%s cur=%s", label, key, cur || "(空)");
|
|
1293
|
+
};
|
|
1294
|
+
seedDynamic("main", config.mainLLM, "aka-comfy-agent.models.main");
|
|
1295
|
+
seedDynamic("vision", config.visionLLM, "aka-comfy-agent.models.vision");
|
|
1296
|
+
async function probeOptions(label, cfg) {
|
|
1297
|
+
const name2 = label === "vision" ? "aka-comfy-agent.models.vision" : "aka-comfy-agent.models.main";
|
|
1298
|
+
try {
|
|
1299
|
+
const ids = await listModels(ctx, cfg, 15e3);
|
|
1300
|
+
if (schemaScope) {
|
|
1301
|
+
schemaScope.set(
|
|
1302
|
+
name2,
|
|
1303
|
+
import_koishi3.Schema.union(ids.map((id) => import_koishi3.Schema.const(id).description(id)))
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
logger.info("%s: 模型下拉 %d 个选项", label, ids.length);
|
|
1307
|
+
return ids;
|
|
1308
|
+
} catch (err) {
|
|
1309
|
+
if (schemaScope) schemaScope.set(name2, import_koishi3.Schema.string().description("模型(无法获取列表,手动填写)"));
|
|
1310
|
+
logger.warn("%s: 模型列表探测失败:%s", label, err instanceof Error ? err.message : String(err));
|
|
1311
|
+
return [];
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
void (async () => {
|
|
1315
|
+
const [mainIds, visionIds] = await Promise.all([
|
|
1316
|
+
probeOptions("main", config.mainLLM),
|
|
1317
|
+
probeOptions("vision", config.visionLLM)
|
|
1318
|
+
]);
|
|
1319
|
+
const mainModel = config.mainLLM.model?.trim() || (config.mainLLM.enabled ? mainIds[0] ?? "" : "");
|
|
1320
|
+
const visionModel = config.visionLLM.model?.trim() || (config.visionLLM.enabled ? visionIds[0] ?? "" : "");
|
|
1321
|
+
if (mainModel) mainClient = new LlmClient(ctx, config.mainLLM, mainModel, config.comfy.timeoutMs);
|
|
1322
|
+
if (visionModel) visionClient = new LlmClient(ctx, config.visionLLM, visionModel, config.comfy.timeoutMs);
|
|
1323
|
+
const snapMain = config.mainLLM.enabled && !config.mainLLM.model?.trim() && mainIds.length > 0;
|
|
1324
|
+
const snapVision = config.visionLLM.enabled && !config.visionLLM.model?.trim() && visionIds.length > 0;
|
|
1325
|
+
if ((snapMain || snapVision) && ctx.scope?.update) {
|
|
1326
|
+
try {
|
|
1327
|
+
await ctx.scope.update({
|
|
1328
|
+
...config,
|
|
1329
|
+
mainLLM: { ...config.mainLLM, model: snapMain ? mainIds[0] : config.mainLLM.model },
|
|
1330
|
+
visionLLM: { ...config.visionLLM, model: snapVision ? visionIds[0] : config.visionLLM.model }
|
|
1331
|
+
});
|
|
1332
|
+
logger.info("已自动切到第一个模型:main=%s vision=%s", snapMain ? mainIds[0] : "-", snapVision ? visionIds[0] : "-");
|
|
1333
|
+
} catch (err) {
|
|
1334
|
+
logger.warn("自动切换模型持久化失败:%s", err instanceof Error ? err.message : String(err));
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
logger.info("models resolved main=%s vision=%s", mainClient.model || "(disabled)", visionClient.model || "(disabled)");
|
|
1338
|
+
})();
|
|
1339
|
+
logger.info("aka-comfy-agent apply comfy=%s clientId=%s", config.comfy.baseUrl, clientId);
|
|
1340
|
+
const renderFacade = createRenderFacade({
|
|
1341
|
+
ctx,
|
|
1342
|
+
logger,
|
|
1343
|
+
comfy,
|
|
1344
|
+
getMainClient: () => mainClient,
|
|
1345
|
+
getVisionClient: () => visionClient,
|
|
1346
|
+
lock,
|
|
1347
|
+
clientId,
|
|
1348
|
+
comfyTimeoutMs: config.comfy.timeoutMs,
|
|
1349
|
+
maxDiagnoseRetries: config.session.maxDiagnoseRetries,
|
|
1350
|
+
mainSystemPrompt: config.mainLLM.systemPrompt?.trim() || MAIN_SYSTEM_PROMPT,
|
|
1351
|
+
visionSystemPrompt: config.visionLLM.systemPrompt?.trim() || GLM_PROBE_PROMPT,
|
|
1352
|
+
visionUserText: config.visionLLM.userText?.trim() || VISION_USER_TEXT,
|
|
1353
|
+
visionCrosscheckRounds: config.visionCrosscheckRounds,
|
|
1354
|
+
visionCrosscheckText: VISION_CROSSCHECK_TEXT,
|
|
1355
|
+
defaultBackgroundColor: config.defaultBackgroundColor
|
|
1356
|
+
});
|
|
1357
|
+
const lastGeneratedStore = {
|
|
1358
|
+
get: (id) => runtime.lastGenByConv.get(id),
|
|
1359
|
+
set: (id, rec) => {
|
|
1360
|
+
runtime.lastGenByConv.set(id, rec);
|
|
1361
|
+
}
|
|
1362
|
+
};
|
|
1363
|
+
const chatLunaBridge = new ChatLunaBridgeManager(
|
|
1364
|
+
ctx,
|
|
1365
|
+
{ render: renderFacade, lastGenerated: lastGeneratedStore, userTaskLocks: runtime.userTaskLocks },
|
|
1366
|
+
config,
|
|
1367
|
+
logger
|
|
1368
|
+
);
|
|
1369
|
+
ctx.inject?.(["chatluna"], async () => {
|
|
1370
|
+
await chatLunaBridge.sync(Boolean(config.chatlunaEnabled));
|
|
1371
|
+
});
|
|
1372
|
+
ctx.accept?.(
|
|
1373
|
+
["chatlunaEnabled", "chatlunaRoomLastGenerated"],
|
|
1374
|
+
(nextConfig) => {
|
|
1375
|
+
chatLunaBridge.updateConfig(nextConfig);
|
|
1376
|
+
if (ctx.chatluna) {
|
|
1377
|
+
void chatLunaBridge.sync(Boolean(nextConfig.chatlunaEnabled));
|
|
1378
|
+
}
|
|
1379
|
+
},
|
|
1380
|
+
{ immediate: true }
|
|
1381
|
+
);
|
|
1382
|
+
ctx.on("dispose", async () => {
|
|
1383
|
+
clearInterval(awaitSweeper);
|
|
1384
|
+
await chatLunaBridge.dispose();
|
|
1385
|
+
});
|
|
1386
|
+
function pickImageUrl(elements) {
|
|
1387
|
+
if (!elements?.length) return null;
|
|
1388
|
+
const imgs = import_koishi3.h.select(elements, "img");
|
|
1389
|
+
for (const el of imgs) {
|
|
1390
|
+
const src = el.attrs?.src;
|
|
1391
|
+
if (typeof src === "string" && src) return src;
|
|
1392
|
+
}
|
|
1393
|
+
return null;
|
|
1394
|
+
}
|
|
1395
|
+
let genProgress;
|
|
1396
|
+
async function reportProgress(session, handle, text) {
|
|
1397
|
+
if (handle?.mid) {
|
|
1398
|
+
try {
|
|
1399
|
+
await session.bot.editMessage(session.channelId ?? "", handle.mid, text);
|
|
1400
|
+
return handle;
|
|
1401
|
+
} catch (err) {
|
|
1402
|
+
logger.warn("progress edit failed (will fall back to new message): %s", errMsg2(err));
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
const mid = await session.send(text);
|
|
1406
|
+
return { mid: Array.isArray(mid) ? mid[0] : String(mid ?? "") };
|
|
1407
|
+
}
|
|
1408
|
+
function pickText(elements) {
|
|
1409
|
+
if (!elements?.length) return "";
|
|
1410
|
+
let out = "";
|
|
1411
|
+
const walk = (arr) => {
|
|
1412
|
+
for (const el of arr) {
|
|
1413
|
+
if (el.type === "text" && typeof el.attrs?.content === "string") out += el.attrs.content;
|
|
1414
|
+
if (el.children?.length) walk(el.children);
|
|
1415
|
+
}
|
|
1416
|
+
};
|
|
1417
|
+
walk(elements);
|
|
1418
|
+
return out.trim();
|
|
1419
|
+
}
|
|
1420
|
+
async function fetchImage2(url) {
|
|
1421
|
+
let buffer;
|
|
1422
|
+
let mimeType = "image/png";
|
|
1423
|
+
if (url.startsWith("internal:") || url.startsWith("file:") || url.startsWith("data:")) {
|
|
1424
|
+
const file = await ctx.http.file(url, { timeout: config.comfy.timeoutMs });
|
|
1425
|
+
buffer = Buffer.from(file.data);
|
|
1426
|
+
if (typeof file.type === "string" && file.type) mimeType = file.type;
|
|
1427
|
+
} else {
|
|
1428
|
+
const ab = await ctx.http.get(url, { responseType: "arraybuffer", timeout: config.comfy.timeoutMs });
|
|
1429
|
+
buffer = Buffer.from(ab);
|
|
1430
|
+
mimeType = sniffMime2(buffer) || mimeType;
|
|
1431
|
+
}
|
|
1432
|
+
const dataUrl = `data:${mimeType};base64,${buffer.toString("base64")}`;
|
|
1433
|
+
return { buffer, mimeType, dataUrl };
|
|
1434
|
+
}
|
|
1435
|
+
function keyFor(session) {
|
|
1436
|
+
if (session.isDirect) return `dm:${session.userId ?? "unknown"}`;
|
|
1437
|
+
const channelId = session.channelId ?? session.userId ?? "unknown";
|
|
1438
|
+
if (config.session.perUserInGroup) return `g:${channelId}:${session.userId ?? "unknown"}`;
|
|
1439
|
+
return `g:${channelId}`;
|
|
1440
|
+
}
|
|
1441
|
+
async function startOrRestartSession(session, imageUrl, intent) {
|
|
1442
|
+
if (!mainClient.enabled) {
|
|
1443
|
+
return "主 LLM 未启用或未解析到 model:先在配置里补 baseUrl/apiKey/model(或让 /v1/models 可用),再试。";
|
|
1444
|
+
}
|
|
1445
|
+
let downloaded;
|
|
1446
|
+
try {
|
|
1447
|
+
downloaded = await fetchImage2(imageUrl);
|
|
1448
|
+
} catch (err) {
|
|
1449
|
+
logger.warn("fetchImage failed: %s", errMsg2(err));
|
|
1450
|
+
return `图片下载失败:${errMsg2(err)}`;
|
|
1451
|
+
}
|
|
1452
|
+
let uploaded;
|
|
1453
|
+
try {
|
|
1454
|
+
const filename = `input_${Date.now()}.${extForMime2(downloaded.mimeType)}`;
|
|
1455
|
+
const name2 = await comfy.uploadImage(downloaded.buffer, filename, downloaded.mimeType);
|
|
1456
|
+
uploaded = { name: name2, mimeType: downloaded.mimeType, bytes: downloaded.buffer.length };
|
|
1457
|
+
logger.info("uploaded to comfy name=%s bytes=%d", name2, downloaded.buffer.length);
|
|
1458
|
+
} catch (err) {
|
|
1459
|
+
logger.warn("comfy upload failed: %s", errMsg2(err));
|
|
1460
|
+
return `上传 ComfyUI 失败:${errMsg2(err)}`;
|
|
1461
|
+
}
|
|
1462
|
+
const params = initialParams(randomSeed());
|
|
1463
|
+
const newSession = {
|
|
1464
|
+
key: keyFor(session),
|
|
1465
|
+
channelId: session.channelId ?? "",
|
|
1466
|
+
userId: session.userId ?? "",
|
|
1467
|
+
guildId: session.guildId,
|
|
1468
|
+
source: uploaded,
|
|
1469
|
+
materialAnalysis: "",
|
|
1470
|
+
turns: 0,
|
|
1471
|
+
params,
|
|
1472
|
+
llmContext: [],
|
|
1473
|
+
errorHistory: [],
|
|
1474
|
+
createdAt: Date.now(),
|
|
1475
|
+
lastUsedAt: Date.now(),
|
|
1476
|
+
busy: false
|
|
1477
|
+
};
|
|
1478
|
+
store.set(newSession.key, newSession);
|
|
1479
|
+
if (config.confirmBeforeGenerate.command && downloaded.dataUrl) {
|
|
1480
|
+
try {
|
|
1481
|
+
await reportProgress(session, void 0, `已收到图片(${(downloaded.buffer.length / 1048576).toFixed(1)} MB)✓ 正在解析角色资产清单…`);
|
|
1482
|
+
const analysis = await runAnalysisOnly(brainDeps(), downloaded.dataUrl);
|
|
1483
|
+
newSession.materialAnalysis = analysis;
|
|
1484
|
+
newSession.pendingConfirm = { analysis, intent };
|
|
1485
|
+
store.set(newSession.key, newSession);
|
|
1486
|
+
logger.info("confirm gate armed key=%s analysis_len=%d", newSession.key, analysis.length);
|
|
1487
|
+
return formatConfirmPrompt(analysis, intent);
|
|
1488
|
+
} catch (err) {
|
|
1489
|
+
logger.warn("confirm gate vision analysis failed, fall back to direct generation: %s", errMsg2(err));
|
|
1490
|
+
return await performTurn(session, newSession, intent, { runVision: true, sourceDataUrl: downloaded.dataUrl });
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
return await performTurn(session, newSession, intent, { runVision: true, sourceDataUrl: downloaded.dataUrl });
|
|
1494
|
+
}
|
|
1495
|
+
async function performTurn(session, convo, feedbackIn, opts) {
|
|
1496
|
+
if (convo.busy) return "本会话上一轮还在跑,等它结束再发。";
|
|
1497
|
+
if (convo.turns >= config.session.maxTurns) {
|
|
1498
|
+
return `会话已达上限(${config.session.maxTurns} 轮)。发「退出」重开。`;
|
|
1499
|
+
}
|
|
1500
|
+
if (!mainClient.enabled) return "主 LLM 未启用/未解析到 model。";
|
|
1501
|
+
convo.busy = true;
|
|
1502
|
+
convo.turns += 1;
|
|
1503
|
+
store.touch(convo);
|
|
1504
|
+
let feedback = feedbackIn;
|
|
1505
|
+
if (convo.pendingConfirm) {
|
|
1506
|
+
convo.materialAnalysis = convo.pendingConfirm.analysis;
|
|
1507
|
+
const intent = convo.pendingConfirm.intent;
|
|
1508
|
+
const corrections = convo.pendingConfirm.corrections ?? [];
|
|
1509
|
+
convo.pendingConfirm = void 0;
|
|
1510
|
+
feedback = [intent, ...corrections, feedbackIn ? `[用户确认] ${feedbackIn}` : ""].filter(Boolean).join("\n");
|
|
1511
|
+
store.touch(convo);
|
|
1512
|
+
}
|
|
1513
|
+
try {
|
|
1514
|
+
const mid = await reportProgress(session, void 0, "正在生成…");
|
|
1515
|
+
genProgress = { session, handle: mid };
|
|
1516
|
+
const result = await runTurn(
|
|
1517
|
+
brainDeps(),
|
|
1518
|
+
convo,
|
|
1519
|
+
feedback,
|
|
1520
|
+
opts
|
|
1521
|
+
);
|
|
1522
|
+
genProgress = void 0;
|
|
1523
|
+
await reportProgress(session, mid, "生成完毕 ✓");
|
|
1524
|
+
const parts = [];
|
|
1525
|
+
parts.push(import_koishi3.h.image(result.imageBuffer, mimeFromFilename(result.filename)));
|
|
1526
|
+
const noteLine = result.note ? `做法:${result.note}
|
|
1527
|
+
` : "";
|
|
1528
|
+
const meta = `seed=${result.params.seed}` + (result.material ? ` · 材质=${result.material}` : "") + (result.lighting ? ` · 灯光=${result.lighting}` : "") + (result.background ? ` · 背景=${result.background}` : "");
|
|
1529
|
+
parts.push((0, import_koishi3.h)("text", { content: `
|
|
1530
|
+
${noteLine}${meta}
|
|
1531
|
+
${statusBar(convo, config.session.maxTurns, "可改图")}` }));
|
|
1532
|
+
return parts;
|
|
1533
|
+
} catch (err) {
|
|
1534
|
+
logger.warn("performTurn failed key=%s turn=%d: %s", convo.key, convo.turns, errMsg2(err));
|
|
1535
|
+
if (genProgress) {
|
|
1536
|
+
const g = genProgress;
|
|
1537
|
+
genProgress = void 0;
|
|
1538
|
+
await reportProgress(g.session, g.handle, `生成失败:${errMsg2(err)}`);
|
|
1539
|
+
}
|
|
1540
|
+
return `本轮失败:${errMsg2(err)}`;
|
|
1541
|
+
} finally {
|
|
1542
|
+
convo.busy = false;
|
|
1543
|
+
store.touch(convo);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
ctx.command("comfy [...content:text]", "ComfyUI FLUX.2 图生图 + agent 迭代(支持分步接图:先 comfy 记意图/等图,再单独发图开始)").alias("v2").example("comfy 把背景换成黄色").example("comfy <图> 把背景换成草原,加强毛绒质感").action(async ({ session }, ...contentArgs) => {
|
|
1547
|
+
if (!session) return;
|
|
1548
|
+
const text = (contentArgs.join(" ") || "").trim();
|
|
1549
|
+
const elements = session.elements;
|
|
1550
|
+
const imageUrl = pickImageUrl(elements);
|
|
1551
|
+
const key = keyFor(session);
|
|
1552
|
+
const existing = store.get(key);
|
|
1553
|
+
if (!imageUrl && isExitIntent(text)) {
|
|
1554
|
+
return destroyComfy(key);
|
|
1555
|
+
}
|
|
1556
|
+
if (!existing) {
|
|
1557
|
+
if (imageUrl) {
|
|
1558
|
+
awaitingImage.delete(key);
|
|
1559
|
+
return await startOrRestartSession(session, imageUrl, text || "(用户未给具体意图,请按材质分层做保守增强)");
|
|
1560
|
+
}
|
|
1561
|
+
awaitingImage.set(key, { text, createdAt: Date.now() });
|
|
1562
|
+
return text ? `已记录意图「${text}」。请发送要处理的图片(一张),收到后立即开始生成。` : "请发送要处理的图片(一张),收到后立即开始生成。";
|
|
1563
|
+
}
|
|
1564
|
+
if (imageUrl) {
|
|
1565
|
+
awaitingImage.delete(key);
|
|
1566
|
+
store.delete(key);
|
|
1567
|
+
return await startOrRestartSession(session, imageUrl, text || "(用户换了源图,请重新分析并给出保守首轮)");
|
|
1568
|
+
}
|
|
1569
|
+
if (!text) return "会话已在进行中:附上文字反馈来触发下一轮;或发「退出」重开。";
|
|
1570
|
+
return await performTurn(session, existing, text, { runVision: false });
|
|
1571
|
+
});
|
|
1572
|
+
ctx.command("comfy.help", "查看 comfy 使用说明", { authority: 1 }).alias("帮助").action(async () => helpText());
|
|
1573
|
+
ctx.command("comfy.status", "查看当前 comfy 会话参数与错误史", { authority: 1 }).alias("状态").action(async ({ session }) => {
|
|
1574
|
+
if (!session) return;
|
|
1575
|
+
return statusText(store.get(keyFor(session)));
|
|
1576
|
+
});
|
|
1577
|
+
ctx.command("comfy.clear", "销毁当前会话,离开循环回到聊天", { authority: 1 }).alias("退出").action(async ({ session }) => {
|
|
1578
|
+
if (!session) return;
|
|
1579
|
+
const key = keyFor(session);
|
|
1580
|
+
awaitingImage.delete(key);
|
|
1581
|
+
const removed = store.delete(key);
|
|
1582
|
+
return removed ? "已销毁本会话,已离开循环。" : "当前没有会话。";
|
|
1583
|
+
});
|
|
1584
|
+
function destroyComfy(key) {
|
|
1585
|
+
const hadAwait = awaitingImage.delete(key);
|
|
1586
|
+
const removed = store.delete(key);
|
|
1587
|
+
if (removed) return "已销毁本会话,已离开循环。";
|
|
1588
|
+
if (hadAwait) return "已离开循环(清空了等待中的会话)。";
|
|
1589
|
+
return "当前没有进行中的 comfy 会话。";
|
|
1590
|
+
}
|
|
1591
|
+
ctx.middleware(async (session, next) => {
|
|
1592
|
+
if (!session.channelId && !session.userId) return next();
|
|
1593
|
+
const key = keyFor(session);
|
|
1594
|
+
const elements = session.elements;
|
|
1595
|
+
const imageUrl = pickImageUrl(elements);
|
|
1596
|
+
if (imageUrl) {
|
|
1597
|
+
const pendingImg = awaitingImage.get(key);
|
|
1598
|
+
if (!pendingImg) return next();
|
|
1599
|
+
if (Date.now() - pendingImg.createdAt > AWAIT_TIMEOUT_MS) {
|
|
1600
|
+
awaitingImage.delete(key);
|
|
1601
|
+
return "等图状态已超时(超过空闲时限)。请重新发 comfy,再发图开始。";
|
|
1602
|
+
}
|
|
1603
|
+
awaitingImage.delete(key);
|
|
1604
|
+
const text = (pickText(elements) || pendingImg.text || "").trim();
|
|
1605
|
+
return await startOrRestartSession(
|
|
1606
|
+
session,
|
|
1607
|
+
imageUrl,
|
|
1608
|
+
text || "(用户未给具体意图,请按材质分层做保守增强)"
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
const rawContent = pickText(elements) || (session.content ?? "").toString().trim();
|
|
1612
|
+
if (!rawContent) return next();
|
|
1613
|
+
if (/^\s*[./!\\]?\s*(comfy|v2)\b/i.test(rawContent)) return next();
|
|
1614
|
+
if (isExitIntent(rawContent) && (awaitingImage.get(key) || store.get(key))) {
|
|
1615
|
+
logger.info("cmt: bare exit key=%s text=%s", key, rawContent.slice(0, 30));
|
|
1616
|
+
return destroyComfy(key);
|
|
1617
|
+
}
|
|
1618
|
+
const pendingAwait = awaitingImage.get(key);
|
|
1619
|
+
if (pendingAwait) {
|
|
1620
|
+
pendingAwait.text = rawContent;
|
|
1621
|
+
pendingAwait.createdAt = Date.now();
|
|
1622
|
+
return `已更新意图「${rawContent}」,请发送要处理的图片。`;
|
|
1623
|
+
}
|
|
1624
|
+
const convo = store.get(key);
|
|
1625
|
+
if (!convo) {
|
|
1626
|
+
if (store.wasRecentlyExpired(key, AWAIT_TIMEOUT_MS)) {
|
|
1627
|
+
logger.info("cmt: expired session key=%s text=%s", key, rawContent.slice(0, 30));
|
|
1628
|
+
return `你的图像会话已因空闲超时结束(超过 ${Math.round(config.session.idleTimeoutMs / 6e4)} 分钟无操作)。发「comfy <图> <意图>」重新开始。`;
|
|
1629
|
+
}
|
|
1630
|
+
logger.info(
|
|
1631
|
+
"cmt: no session, pass down key=%s direct=%s channel=%s user=%s text=%s",
|
|
1632
|
+
key,
|
|
1633
|
+
!!session.isDirect,
|
|
1634
|
+
session.channelId ?? "",
|
|
1635
|
+
session.userId ?? "",
|
|
1636
|
+
rawContent.slice(0, 30)
|
|
1637
|
+
);
|
|
1638
|
+
return next();
|
|
1639
|
+
}
|
|
1640
|
+
if (convo.pendingConfirm && !convo.busy) {
|
|
1641
|
+
logger.info(
|
|
1642
|
+
"cmt: pendingConfirm enter key=%s isConfirm=%s text=%s",
|
|
1643
|
+
key,
|
|
1644
|
+
isConfirmWord((rawContent || "").trim()),
|
|
1645
|
+
(rawContent || "").slice(0, 30)
|
|
1646
|
+
);
|
|
1647
|
+
if (imageUrl) return next();
|
|
1648
|
+
const text = rawContent.trim();
|
|
1649
|
+
if (!text) return "正在等待你对视觉分析结果的确认:回复确认词开始生成,或直接描述需要修正的地方。";
|
|
1650
|
+
if (isExitIntent(text)) {
|
|
1651
|
+
return destroyComfy(key);
|
|
1652
|
+
}
|
|
1653
|
+
if (isConfirmWord(text)) {
|
|
1654
|
+
logger.info("cmt: confirmed by word key=%s text=%s", key, text);
|
|
1655
|
+
return await performTurn(session, convo, "", { runVision: false });
|
|
1656
|
+
}
|
|
1657
|
+
convo.pendingConfirm.analysis = `${convo.pendingConfirm.analysis}
|
|
1658
|
+
|
|
1659
|
+
[用户修正]
|
|
1660
|
+
${text}`;
|
|
1661
|
+
convo.pendingConfirm.corrections = [...convo.pendingConfirm.corrections ?? [], text];
|
|
1662
|
+
convo.materialAnalysis = convo.pendingConfirm.analysis;
|
|
1663
|
+
store.touch(convo);
|
|
1664
|
+
return `已记录你的修正:「${text}」。
|
|
1665
|
+
|
|
1666
|
+
${formatConfirmPrompt(convo.pendingConfirm.analysis, convo.pendingConfirm.intent)}`;
|
|
1667
|
+
}
|
|
1668
|
+
if (convo.busy) return "本会话上一轮还在跑,稍等它完成再发。";
|
|
1669
|
+
if (isLikelyChatter(rawContent)) {
|
|
1670
|
+
logger.info("cmt: chatter detected key=%s text=%s", key, rawContent.slice(0, 30));
|
|
1671
|
+
return [
|
|
1672
|
+
`「${rawContent.slice(0, 60)}」看起来不像改图指令。`,
|
|
1673
|
+
`你当前在 COMFY 工作循环里(第 ${convo.turns}/${config.session.maxTurns} 轮):`,
|
|
1674
|
+
`- 想调整这张图:直接说改法,如「毛再蓬一点」「背景换红」`,
|
|
1675
|
+
`- 想离开循环回到聊天:直接发「清空」或「退出」,无需 comfy 前缀`
|
|
1676
|
+
].join("\n");
|
|
1677
|
+
}
|
|
1678
|
+
return await performTurn(session, convo, rawContent, { runVision: false });
|
|
1679
|
+
});
|
|
1680
|
+
function statusText(convo) {
|
|
1681
|
+
if (!convo) return '当前没有会话。发 "comfy <图> <意图>" 开新的。';
|
|
1682
|
+
const errs = convo.errorHistory.slice(-3).map((e) => `- ${e.message}${e.hint ? `|${e.hint}` : ""}`).join("\n") || "(无)";
|
|
1683
|
+
return [
|
|
1684
|
+
`会话:key=${convo.key}`,
|
|
1685
|
+
`轮次:${convo.turns}/${config.session.maxTurns}`,
|
|
1686
|
+
`源图:name=${convo.source.name} bytes=${convo.source.bytes}`,
|
|
1687
|
+
`参数:${JSON.stringify(convo.params)}`,
|
|
1688
|
+
`视觉报告:${convo.materialAnalysis.slice(0, 200)}`,
|
|
1689
|
+
`最近错误:
|
|
1690
|
+
${errs}`
|
|
1691
|
+
].join("\n");
|
|
1692
|
+
}
|
|
1693
|
+
function helpText() {
|
|
1694
|
+
return [
|
|
1695
|
+
"ComfyUI FLUX.2 图生图 agent(本期仅 i2i):",
|
|
1696
|
+
"- comfy <图> <意图>:一条消息带图+意图,立即开始",
|
|
1697
|
+
"- comfy <意图>:分步接图,先记意图再等图,收到图自动开始(或 comfy 后直接发图)",
|
|
1698
|
+
"- 会话内直接发「退出」:销毁当前会话,离开循环回到聊天(无需 comfy 前缀)",
|
|
1699
|
+
"- comfy 状态:查看会话参数与错误史",
|
|
1700
|
+
"- comfy 帮助:本页",
|
|
1701
|
+
`- 每会话 ${config.session.maxTurns} 轮上限;空闲 ${Math.round(config.session.idleTimeoutMs / 6e4)} 分钟自动销毁。`
|
|
1702
|
+
].join("\n");
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
function sniffMime2(buffer) {
|
|
1706
|
+
if (buffer.length < 4) return null;
|
|
1707
|
+
if (buffer[0] === 137 && buffer[1] === 80 && buffer[2] === 78 && buffer[3] === 71) return "image/png";
|
|
1708
|
+
if (buffer[0] === 255 && buffer[1] === 216 && buffer[2] === 255) return "image/jpeg";
|
|
1709
|
+
if (buffer[0] === 71 && buffer[1] === 73 && buffer[2] === 70) return "image/gif";
|
|
1710
|
+
if (buffer[0] === 82 && buffer[1] === 73 && buffer[2] === 70 && buffer[3] === 70) return "image/webp";
|
|
1711
|
+
return null;
|
|
1712
|
+
}
|
|
1713
|
+
function extForMime2(mime) {
|
|
1714
|
+
if (mime === "image/png") return "png";
|
|
1715
|
+
if (mime === "image/jpeg") return "jpg";
|
|
1716
|
+
if (mime === "image/gif") return "gif";
|
|
1717
|
+
if (mime === "image/webp") return "webp";
|
|
1718
|
+
return "png";
|
|
1719
|
+
}
|
|
1720
|
+
function mimeFromFilename(filename) {
|
|
1721
|
+
const lower = filename.toLowerCase();
|
|
1722
|
+
if (lower.endsWith(".png")) return "image/png";
|
|
1723
|
+
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
|
1724
|
+
if (lower.endsWith(".gif")) return "image/gif";
|
|
1725
|
+
if (lower.endsWith(".webp")) return "image/webp";
|
|
1726
|
+
return "image/png";
|
|
1727
|
+
}
|
|
1728
|
+
function errMsg2(err) {
|
|
1729
|
+
return err instanceof Error ? err.message : String(err);
|
|
1730
|
+
}
|
|
1731
|
+
function statusBar(convo, maxTurns, state) {
|
|
1732
|
+
const label = `【COMFY · 第 ${convo.turns}/${maxTurns} 轮${state ? `|${state}` : ""}】`;
|
|
1733
|
+
return [
|
|
1734
|
+
``,
|
|
1735
|
+
label,
|
|
1736
|
+
`想继续调整就直接发文字(如「毛再蓬一点」「背景换红」),会在这张图上迭代。`,
|
|
1737
|
+
`离开循环回到聊天:直接发「退出」。`
|
|
1738
|
+
].join("\n");
|
|
1739
|
+
}
|
|
1740
|
+
var CONFIRM_WORDS = /* @__PURE__ */ new Set(["确认", "开始", "生成", "可以", "好", "好的", "ok", "OK", "Ok", "oK", "yes", "Yes", "YES", "go", "start", "没问题", "就这个", "就这样", "同意", "干"]);
|
|
1741
|
+
function isConfirmWord(text) {
|
|
1742
|
+
const t = text.trim().toLowerCase().replace(/[。..!!??,,~~\s]/g, "");
|
|
1743
|
+
if (!t) return false;
|
|
1744
|
+
if (CONFIRM_WORDS.has(t)) return true;
|
|
1745
|
+
if (/^(确认|开始|生成|可以|好|ok|yes|go|没问题|就这样|同意|干)$/.test(t)) return true;
|
|
1746
|
+
return false;
|
|
1747
|
+
}
|
|
1748
|
+
var EDIT_SIGNAL_RE = /(背景|材质|毛绒|毛|质感|灯光|光照|颜色|色调|配色|肤色|毛发|睫毛|亮|暗|深|浅|饱和度|对比度|锐化|模糊|清晰|重画|再画|放大|缩小|构图|比例|裁剪|seed|种子|风格|角度|透视|阴影|高光|线条|细节|氛围|改|换|加|去|变|红|橙|黄|绿|蓝|紫|粉|黑|白|灰|金|银|卡通|写实|厚涂|薄涂|塑料|金属|布料|皮毛|皮肤|眼睛|头发|脸部|衣|帽子|鞋子)/i;
|
|
1749
|
+
var CHATTER_SIGNAL_RE = /(你好|在吗|在不在|谢谢|多谢|辛苦|拜拜|再见|哈哈|嘿嘿|不错|太棒|好棒|厉害|牛逼|你是|你是谁|你会|你能|会画|聊天|别的|其他|还能|还会|什么|为什么|怎么用|多少钱|退出|不弄了|算了|到此为止|停)/i;
|
|
1750
|
+
function isLikelyChatter(text) {
|
|
1751
|
+
const t = (text || "").trim();
|
|
1752
|
+
if (!t) return false;
|
|
1753
|
+
if (EDIT_SIGNAL_RE.test(t)) return false;
|
|
1754
|
+
return CHATTER_SIGNAL_RE.test(t);
|
|
1755
|
+
}
|
|
1756
|
+
var EXIT_INTENT_RE = /^(退出|算了|不弄了|停止|离开|quit|exit|stop|bye)(了|吧|啦|哦)?$/i;
|
|
1757
|
+
function isExitIntent(text) {
|
|
1758
|
+
const t = (text || "").trim();
|
|
1759
|
+
return EXIT_INTENT_RE.test(t);
|
|
1760
|
+
}
|
|
1761
|
+
function formatConfirmPrompt(analysis, intent) {
|
|
1762
|
+
const preview = analysis && analysis.length > 900 ? `${analysis.slice(0, 900)}…` : analysis || "(无)";
|
|
1763
|
+
return [
|
|
1764
|
+
`【图像分析 · 待确认】`,
|
|
1765
|
+
`意图:${intent || "(未提供,将按材质分层做保守增强)"}`,
|
|
1766
|
+
``,
|
|
1767
|
+
`视觉分析结果:`,
|
|
1768
|
+
preview,
|
|
1769
|
+
``,
|
|
1770
|
+
`回复「确认」开始生成;或直接描述要修正的地方(如:它是黑色的毛, 领子是红的, 不要偏色),会并入分析后再等你确认。`,
|
|
1771
|
+
`【COMFY 循环 · 待确认】想离开循环回聊天:直接发「清空」或「退出」。`
|
|
1772
|
+
].join("\n");
|
|
1773
|
+
}
|
|
1774
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1775
|
+
0 && (module.exports = {
|
|
1776
|
+
Config,
|
|
1777
|
+
apply,
|
|
1778
|
+
inject,
|
|
1779
|
+
name
|
|
1780
|
+
});
|
|
1781
|
+
//# sourceMappingURL=index.js.map
|