koishi-plugin-media-luna 0.0.23 → 0.0.25

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.
@@ -11,6 +11,10 @@ export interface KoishiCommandsConfig {
11
11
  strictTagMatch: boolean;
12
12
  /** 确认超时时间(秒) */
13
13
  confirmTimeout: number;
14
+ /** 收集模式超时时间(秒) */
15
+ collectTimeout: number;
16
+ /** 直接触发所需的最小图片数量 */
17
+ directTriggerImageCount: number;
14
18
  }
15
19
  /** 默认配置 */
16
20
  export declare const defaultKoishiCommandsConfig: KoishiCommandsConfig;
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/plugins/koishi-commands/config.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAE9C,oBAAoB;AACpB,MAAM,WAAW,oBAAoB;IACnC,eAAe;IACf,OAAO,EAAE,OAAO,CAAA;IAChB,eAAe;IACf,cAAc,EAAE,MAAM,CAAA;IACtB,eAAe;IACf,aAAa,EAAE,MAAM,CAAA;IACrB,8BAA8B;IAC9B,cAAc,EAAE,OAAO,CAAA;IACvB,gBAAgB;IAChB,cAAc,EAAE,MAAM,CAAA;CACvB;AAED,WAAW;AACX,eAAO,MAAM,2BAA2B,EAAE,oBAMzC,CAAA;AAED,aAAa;AACb,eAAO,MAAM,0BAA0B,EAAE,WAAW,EAoCnD,CAAA"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/plugins/koishi-commands/config.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAE9C,oBAAoB;AACpB,MAAM,WAAW,oBAAoB;IACnC,eAAe;IACf,OAAO,EAAE,OAAO,CAAA;IAChB,eAAe;IACf,cAAc,EAAE,MAAM,CAAA;IACtB,eAAe;IACf,aAAa,EAAE,MAAM,CAAA;IACrB,8BAA8B;IAC9B,cAAc,EAAE,OAAO,CAAA;IACvB,gBAAgB;IAChB,cAAc,EAAE,MAAM,CAAA;IACtB,kBAAkB;IAClB,cAAc,EAAE,MAAM,CAAA;IACtB,oBAAoB;IACpB,uBAAuB,EAAE,MAAM,CAAA;CAChC;AAED,WAAW;AACX,eAAO,MAAM,2BAA2B,EAAE,oBAQzC,CAAA;AAED,aAAa;AACb,eAAO,MAAM,0BAA0B,EAAE,WAAW,EAkDnD,CAAA"}
@@ -8,7 +8,9 @@ exports.defaultKoishiCommandsConfig = {
8
8
  presetsCommand: 'presets',
9
9
  presetCommand: 'preset',
10
10
  strictTagMatch: true,
11
- confirmTimeout: 30
11
+ confirmTimeout: 30,
12
+ collectTimeout: 120,
13
+ directTriggerImageCount: 2
12
14
  };
13
15
  /** 配置字段定义 */
14
16
  exports.koishiCommandsConfigFields = [
@@ -46,6 +48,20 @@ exports.koishiCommandsConfigFields = [
46
48
  type: 'number',
47
49
  default: 30,
48
50
  description: '用户确认操作的超时时间'
51
+ },
52
+ {
53
+ key: 'collectTimeout',
54
+ label: '收集超时(秒)',
55
+ type: 'number',
56
+ default: 120,
57
+ description: '收集模式下等待用户输入的超时时间'
58
+ },
59
+ {
60
+ key: 'directTriggerImageCount',
61
+ label: '直接触发图片数',
62
+ type: 'number',
63
+ default: 2,
64
+ description: '图片数量达到此值时直接触发生成,否则进入收集模式'
49
65
  }
50
66
  ];
51
67
  //# sourceMappingURL=config.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../../../src/plugins/koishi-commands/config.ts"],"names":[],"mappings":";AAAA,cAAc;;;AAkBd,WAAW;AACE,QAAA,2BAA2B,GAAyB;IAC/D,OAAO,EAAE,IAAI;IACb,cAAc,EAAE,SAAS;IACzB,aAAa,EAAE,QAAQ;IACvB,cAAc,EAAE,IAAI;IACpB,cAAc,EAAE,EAAE;CACnB,CAAA;AAED,aAAa;AACA,QAAA,0BAA0B,GAAkB;IACvD;QACE,GAAG,EAAE,SAAS;QACd,KAAK,EAAE,MAAM;QACb,IAAI,EAAE,SAAS;QACf,OAAO,EAAE,IAAI;QACb,WAAW,EAAE,2BAA2B;KACzC;IACD;QACE,GAAG,EAAE,gBAAgB;QACrB,KAAK,EAAE,QAAQ;QACf,IAAI,EAAE,MAAM;QACZ,OAAO,EAAE,SAAS;QAClB,WAAW,EAAE,aAAa;KAC3B;IACD;QACE,GAAG,EAAE,eAAe;QACpB,KAAK,EAAE,QAAQ;QACf,IAAI,EAAE,MAAM;QACZ,OAAO,EAAE,QAAQ;QACjB,WAAW,EAAE,aAAa;KAC3B;IACD;QACE,GAAG,EAAE,gBAAgB;QACrB,KAAK,EAAE,QAAQ;QACf,IAAI,EAAE,SAAS;QACf,OAAO,EAAE,IAAI;QACb,WAAW,EAAE,+BAA+B;KAC7C;IACD;QACE,GAAG,EAAE,gBAAgB;QACrB,KAAK,EAAE,SAAS;QAChB,IAAI,EAAE,QAAQ;QACd,OAAO,EAAE,EAAE;QACX,WAAW,EAAE,aAAa;KAC3B;CACF,CAAA"}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../../../src/plugins/koishi-commands/config.ts"],"names":[],"mappings":";AAAA,cAAc;;;AAsBd,WAAW;AACE,QAAA,2BAA2B,GAAyB;IAC/D,OAAO,EAAE,IAAI;IACb,cAAc,EAAE,SAAS;IACzB,aAAa,EAAE,QAAQ;IACvB,cAAc,EAAE,IAAI;IACpB,cAAc,EAAE,EAAE;IAClB,cAAc,EAAE,GAAG;IACnB,uBAAuB,EAAE,CAAC;CAC3B,CAAA;AAED,aAAa;AACA,QAAA,0BAA0B,GAAkB;IACvD;QACE,GAAG,EAAE,SAAS;QACd,KAAK,EAAE,MAAM;QACb,IAAI,EAAE,SAAS;QACf,OAAO,EAAE,IAAI;QACb,WAAW,EAAE,2BAA2B;KACzC;IACD;QACE,GAAG,EAAE,gBAAgB;QACrB,KAAK,EAAE,QAAQ;QACf,IAAI,EAAE,MAAM;QACZ,OAAO,EAAE,SAAS;QAClB,WAAW,EAAE,aAAa;KAC3B;IACD;QACE,GAAG,EAAE,eAAe;QACpB,KAAK,EAAE,QAAQ;QACf,IAAI,EAAE,MAAM;QACZ,OAAO,EAAE,QAAQ;QACjB,WAAW,EAAE,aAAa;KAC3B;IACD;QACE,GAAG,EAAE,gBAAgB;QACrB,KAAK,EAAE,QAAQ;QACf,IAAI,EAAE,SAAS;QACf,OAAO,EAAE,IAAI;QACb,WAAW,EAAE,+BAA+B;KAC7C;IACD;QACE,GAAG,EAAE,gBAAgB;QACrB,KAAK,EAAE,SAAS;QAChB,IAAI,EAAE,QAAQ;QACd,OAAO,EAAE,EAAE;QACX,WAAW,EAAE,aAAa;KAC3B;IACD;QACE,GAAG,EAAE,gBAAgB;QACrB,KAAK,EAAE,SAAS;QAChB,IAAI,EAAE,QAAQ;QACd,OAAO,EAAE,GAAG;QACZ,WAAW,EAAE,kBAAkB;KAChC;IACD;QACE,GAAG,EAAE,yBAAyB;QAC9B,KAAK,EAAE,SAAS;QAChB,IAAI,EAAE,QAAQ;QACd,OAAO,EAAE,CAAC;QACV,WAAW,EAAE,0BAA0B;KACxC;CACF,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/plugins/koishi-commands/index.ts"],"names":[],"mappings":";AAiBA,wBAwDE;AAkbF,YAAY,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/plugins/koishi-commands/index.ts"],"names":[],"mappings":";AAyBA,wBA0DE;AA8qBF,YAAY,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAA"}
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  // Koishi 指令插件入口
3
- // 注册渠道名指令,预设名作为可选参数
3
+ // 注册渠道名指令,支持收集模式
4
4
  Object.defineProperty(exports, "__esModule", { value: true });
5
5
  const core_1 = require("../../core");
6
6
  const config_1 = require("./config");
@@ -19,17 +19,19 @@ exports.default = (0, core_1.definePlugin)({
19
19
  async onLoad(pluginCtx) {
20
20
  const ctx = pluginCtx.ctx;
21
21
  const config = pluginCtx.getConfig();
22
- if (!config.enabled) {
23
- pluginCtx.logger.info('Koishi commands disabled');
24
- return;
25
- }
22
+ // 注意:即使全局 enabled=false,也需要加载插件
23
+ // 因为渠道级配置可能覆盖启用了某些渠道
24
+ // 具体的启用判断在 refreshGenerateCommands 中进行
26
25
  // 保存 mediaLuna 引用,避免在事件处理器中重复访问 ctx.mediaLuna 触发警告
27
26
  let mediaLunaRef = null;
28
27
  // 等待 mediaLuna 服务就绪后注册指令
29
28
  ctx.on('ready', async () => {
30
29
  mediaLunaRef = ctx.mediaLuna;
31
30
  await refreshGenerateCommands(pluginCtx, config, mediaLunaRef);
32
- registerPresetCommands(pluginCtx, config, mediaLunaRef);
31
+ // 预设查询指令使用全局配置(传 null 表示不看渠道覆盖)
32
+ if (mediaLunaRef.isPluginEnabledForChannel('koishi-commands', null)) {
33
+ registerPresetCommands(pluginCtx, config, mediaLunaRef);
34
+ }
33
35
  });
34
36
  // 监听渠道变化,动态刷新指令
35
37
  ctx.on('mediaLuna/channel-updated', async () => {
@@ -100,6 +102,12 @@ async function refreshGenerateCommands(pluginCtx, config, mediaLuna) {
100
102
  }
101
103
  channelCommandDisposables.delete(channel.id);
102
104
  }
105
+ // 检查渠道级配置是否禁用了 koishi-commands
106
+ // 使用 MediaLuna 服务的统一方法:全局配置 + 渠道覆盖
107
+ if (!mediaLuna.isPluginEnabledForChannel('koishi-commands', channel)) {
108
+ pluginCtx.logger.debug(`Channel ${channel.name}: koishi-commands disabled, skipping`);
109
+ continue;
110
+ }
103
111
  // 注册渠道指令
104
112
  const dispose = registerChannelCommand(ctx, mediaLuna, channel, presets, config, pluginCtx.logger);
105
113
  channelCommandDisposables.set(channel.id, dispose);
@@ -119,49 +127,305 @@ function registerChannelCommand(ctx, mediaLuna, channel, presets, config, logger
119
127
  .option('image', '-i <url:string> 输入图片URL')
120
128
  .usage(`用法: ${channel.name} [预设名] <提示词>\n可用预设: ${presets.map((p) => p.name).join(', ') || '无'}`)
121
129
  .action(async ({ session, options }, ...rest) => {
122
- // rest 是所有参数的数组
123
- // 对于 /draw anime xxx,rest = ['anime', 'xxx']
124
- // 对于 /draw xxx,rest = ['xxx']
125
- let presetName;
126
- let promptParts = rest;
127
- if (rest.length > 0) {
128
- const firstWord = rest[0]?.toLowerCase();
129
- // 检查首词是否是预设名
130
- if (firstWord && presetNamesLower.has(firstWord)) {
131
- presetName = presetNameMap.get(firstWord);
132
- promptParts = rest.slice(1);
130
+ // 初始化收集状态(预设名稍后解析)
131
+ const state = {
132
+ files: [],
133
+ processedUrls: new Set(),
134
+ prompts: [],
135
+ presetName: undefined
136
+ };
137
+ // 创建提取器
138
+ const extractor = new MessageExtractor(ctx, logger, state);
139
+ // 从当前消息提取所有内容(图片、at、引用、文本)
140
+ const messageText = await extractor.extractAll(session);
141
+ // 合并命令行参数和消息文本
142
+ // rest 是 Koishi 解析的命令行参数,messageText 是消息元素中的文本
143
+ // 优先使用 rest(因为更准确),如果 rest 为空则使用 messageText
144
+ const allText = rest.length > 0 ? rest.join(' ') : messageText;
145
+ if (allText.trim()) {
146
+ state.prompts.push(allText.trim());
147
+ }
148
+ // 如果命令行指定了图片 URL,也获取
149
+ if (options?.image) {
150
+ await extractor.fetchImage(options.image, 'input');
151
+ }
152
+ // 判断是否直接触发
153
+ if (state.files.length >= config.directTriggerImageCount) {
154
+ // 图片数量足够,直接生成
155
+ return executeGenerateWithPresetCheck(ctx, session, channel, state, presetNamesLower, presetNameMap, config, mediaLuna);
156
+ }
157
+ // 进入收集模式
158
+ return enterCollectMode(ctx, session, channel, state, presetNamesLower, presetNameMap, config, mediaLuna, logger);
159
+ });
160
+ logger.debug(`Registered command: ${channel.name} (${presets.length} presets available)`);
161
+ return () => channelCmd.dispose();
162
+ }
163
+ /**
164
+ * 消息内容提取器
165
+ * 统一处理图片、at、引用消息等元素的提取
166
+ */
167
+ class MessageExtractor {
168
+ ctx;
169
+ logger;
170
+ state;
171
+ constructor(ctx, logger, state) {
172
+ this.ctx = ctx;
173
+ this.logger = logger;
174
+ this.state = state;
175
+ }
176
+ /**
177
+ * 从 Session 提取所有内容(图片、at、引用、文本)
178
+ */
179
+ async extractAll(session) {
180
+ if (!session?.elements)
181
+ return '';
182
+ // 提取媒体内容
183
+ await this.extractMedia(session);
184
+ // 提取文本
185
+ return this.extractText(session.elements);
186
+ }
187
+ /**
188
+ * 从 Session 只提取媒体内容(图片、at、引用),不提取文本
189
+ * 用于第一次提取,因为文本中可能包含预设名需要单独处理
190
+ */
191
+ async extractMedia(session) {
192
+ if (!session?.elements)
193
+ return;
194
+ // 提取图片
195
+ await this.extractImages(session.elements);
196
+ // 提取 at 用户头像
197
+ await this.extractAtAvatars(session);
198
+ // 提取引用消息中的图片
199
+ await this.extractFromQuote(session.elements);
200
+ }
201
+ /**
202
+ * 从元素数组中提取图片
203
+ */
204
+ async extractImages(elements) {
205
+ const imageElements = koishi_1.h.select(elements, 'img,image');
206
+ for (const img of imageElements) {
207
+ await this.fetchImage(img.attrs?.src || img.attrs?.url, 'input');
208
+ }
209
+ }
210
+ /**
211
+ * 从 Session 中提取 at 用户的头像
212
+ */
213
+ async extractAtAvatars(session) {
214
+ if (!session.elements)
215
+ return;
216
+ const atElements = koishi_1.h.select(session.elements, 'at');
217
+ for (const at of atElements) {
218
+ const userId = at.attrs?.id;
219
+ if (userId && session.bot) {
220
+ try {
221
+ const user = await session.bot.getUser(userId);
222
+ const avatarUrl = user?.avatar;
223
+ if (avatarUrl) {
224
+ await this.fetchImage(avatarUrl, `avatar_${userId}`);
225
+ this.logger.debug('Extracted avatar for user %s', userId);
226
+ }
227
+ }
228
+ catch (e) {
229
+ this.logger.warn('Failed to get user info for %s: %s', userId, e);
230
+ }
231
+ }
232
+ }
233
+ }
234
+ /**
235
+ * 从引用消息中提取图片
236
+ */
237
+ async extractFromQuote(elements) {
238
+ const quoteElements = koishi_1.h.select(elements, 'quote');
239
+ for (const quote of quoteElements) {
240
+ if (quote.children && quote.children.length > 0) {
241
+ const quoteImages = koishi_1.h.select(quote.children, 'img,image');
242
+ for (const img of quoteImages) {
243
+ await this.fetchImage(img.attrs?.src || img.attrs?.url, 'quote');
244
+ }
133
245
  }
134
246
  }
135
- const actualPrompt = promptParts.join(' ');
136
- // 严格标签匹配检查
137
- if (config.strictTagMatch && presetName) {
138
- const presetService = mediaLuna?.presets;
139
- if (presetService) {
140
- const presetData = await presetService.getByName(presetName);
141
- if (presetData) {
142
- const channelTags = channel.tags || [];
143
- const presetTags = presetData.tags || [];
144
- const hasMatch = channelTags.length === 0 ||
145
- presetTags.some((t) => channelTags.includes(t));
146
- if (!hasMatch) {
147
- await session?.send(`该模型类别不支持预设「${presetName}」,输入"确认"继续,输入其他取消`);
148
- const confirmInput = await session?.prompt(config.confirmTimeout * 1000);
149
- if (confirmInput?.trim() !== '确认') {
150
- return '已取消';
151
- }
247
+ }
248
+ /**
249
+ * 从元素数组中提取文本
250
+ */
251
+ extractText(elements) {
252
+ const textElements = koishi_1.h.select(elements, 'text');
253
+ return textElements.map(el => el.attrs?.content || '').join('').trim();
254
+ }
255
+ /**
256
+ * 获取图片并添加到 state
257
+ */
258
+ async fetchImage(url, prefix) {
259
+ if (!url || this.state.processedUrls.has(url))
260
+ return false;
261
+ this.state.processedUrls.add(url);
262
+ try {
263
+ const response = await this.ctx.http.get(url, { responseType: 'arraybuffer' });
264
+ const buffer = Buffer.from(response);
265
+ const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
266
+ this.state.files.push({
267
+ data: arrayBuffer,
268
+ mime: 'image/png',
269
+ filename: `${prefix}_${this.state.files.length}.png`
270
+ });
271
+ return true;
272
+ }
273
+ catch (e) {
274
+ this.logger.warn('Failed to fetch image from %s: %s', prefix, e);
275
+ return false;
276
+ }
277
+ }
278
+ /**
279
+ * 添加文本到提示词
280
+ */
281
+ addPrompt(text) {
282
+ if (text && !['开始', 'go', 'start', '取消', 'cancel'].includes(text.toLowerCase())) {
283
+ this.state.prompts.push(text);
284
+ }
285
+ }
286
+ }
287
+ /**
288
+ * 解析预设名并执行生成
289
+ * 从 prompts 的第一个词判断是否为预设名
290
+ */
291
+ async function executeGenerateWithPresetCheck(ctx, session, channel, state, presetNamesLower, presetNameMap, config, mediaLuna) {
292
+ // 合并所有提示词
293
+ const fullPrompt = state.prompts.join(' ').trim();
294
+ const words = fullPrompt.split(/\s+/);
295
+ let presetName;
296
+ let actualPrompt = fullPrompt;
297
+ // 检查第一个词是否是预设名
298
+ if (words.length > 0 && words[0]) {
299
+ const firstWord = words[0].toLowerCase();
300
+ if (presetNamesLower.has(firstWord)) {
301
+ presetName = presetNameMap.get(firstWord);
302
+ actualPrompt = words.slice(1).join(' ');
303
+ }
304
+ }
305
+ // 严格标签匹配检查
306
+ if (config.strictTagMatch && presetName) {
307
+ const presetService = mediaLuna?.presets;
308
+ if (presetService) {
309
+ const presetData = await presetService.getByName(presetName);
310
+ if (presetData) {
311
+ const channelTags = channel.tags || [];
312
+ const presetTags = presetData.tags || [];
313
+ const hasMatch = channelTags.length === 0 ||
314
+ presetTags.some((t) => channelTags.includes(t));
315
+ if (!hasMatch) {
316
+ await session?.send(`该模型类别不支持预设「${presetName}」,输入"确认"继续,输入其他取消`);
317
+ const confirmInput = await session?.prompt(config.confirmTimeout * 1000);
318
+ if (confirmInput?.trim() !== '确认') {
319
+ return '已取消';
152
320
  }
153
321
  }
154
322
  }
155
323
  }
156
- return executeGenerate(ctx, session, {
157
- channelName: channel.name,
158
- presetName,
159
- prompt: actualPrompt,
160
- imageUrl: options?.image
161
- });
324
+ }
325
+ // 构建生成摘要信息
326
+ const summaryParts = [];
327
+ if (presetName) {
328
+ summaryParts.push(`预设: ${presetName}`);
329
+ }
330
+ else {
331
+ summaryParts.push('无预设');
332
+ }
333
+ summaryParts.push(`提示词: ${actualPrompt.length} 字`);
334
+ summaryParts.push(`图片: ${state.files.length} 张`);
335
+ const summaryMsg = `开始生成 | ${summaryParts.join(' | ')}`;
336
+ // 执行生成
337
+ return executeGenerate(ctx, session, {
338
+ channelName: channel.name,
339
+ presetName,
340
+ prompt: actualPrompt,
341
+ files: state.files,
342
+ summaryMsg
162
343
  });
163
- logger.debug(`Registered command: ${channel.name} (${presets.length} presets available)`);
164
- return () => channelCmd.dispose();
344
+ }
345
+ /**
346
+ * 进入收集模式
347
+ * 使用中间件捕获完整消息(包括图片)
348
+ */
349
+ async function enterCollectMode(ctx, session, channel, state, presetNamesLower, presetNameMap, config, mediaLuna, logger) {
350
+ if (!session) {
351
+ return '会话不可用';
352
+ }
353
+ // 发送收集模式提示
354
+ const hintMsgIds = await session.send(`已进入收集模式,请继续发送图片/@用户/文字\n发送「开始」触发生成,发送「取消」退出\n当前已收集: ${state.files.length} 张图片`);
355
+ const timeoutMs = config.collectTimeout * 1000;
356
+ const extractor = new MessageExtractor(ctx, logger, state);
357
+ // 使用 Promise 来等待收集完成
358
+ return new Promise((resolve) => {
359
+ let disposed = false;
360
+ // 超时处理
361
+ const timeoutHandle = setTimeout(async () => {
362
+ if (disposed)
363
+ return;
364
+ disposed = true;
365
+ disposeMiddleware();
366
+ await deleteMessages(session, hintMsgIds);
367
+ resolve('收集超时,已取消');
368
+ }, timeoutMs);
369
+ // 注册中间件来捕获消息
370
+ const disposeMiddleware = ctx.middleware(async (sess, next) => {
371
+ // 只处理同一用户、同一频道的消息
372
+ if (disposed)
373
+ return next();
374
+ if (sess.userId !== session.userId)
375
+ return next();
376
+ if (sess.channelId !== session.channelId)
377
+ return next();
378
+ // 提取文本
379
+ const textContent = extractor.extractText(sess.elements || []).toLowerCase();
380
+ // 检查触发词
381
+ if (textContent === '开始' || textContent === 'go' || textContent === 'start') {
382
+ if (disposed)
383
+ return;
384
+ disposed = true;
385
+ clearTimeout(timeoutHandle);
386
+ disposeMiddleware();
387
+ await deleteMessages(session, hintMsgIds);
388
+ // 检查是否有内容可生成
389
+ if (state.files.length === 0 && state.prompts.length === 0) {
390
+ resolve('没有可生成的内容');
391
+ return;
392
+ }
393
+ // 开始生成(带预设检查)
394
+ const result = await executeGenerateWithPresetCheck(ctx, session, channel, state, presetNamesLower, presetNameMap, config, mediaLuna);
395
+ resolve(result);
396
+ return;
397
+ }
398
+ if (textContent === '取消' || textContent === 'cancel') {
399
+ if (disposed)
400
+ return;
401
+ disposed = true;
402
+ clearTimeout(timeoutHandle);
403
+ disposeMiddleware();
404
+ await deleteMessages(session, hintMsgIds);
405
+ resolve('已取消');
406
+ return;
407
+ }
408
+ // 从消息中提取所有内容
409
+ const text = await extractor.extractAll(sess);
410
+ extractor.addPrompt(text);
411
+ // 不传递给下一个中间件,阻止其他指令处理
412
+ }, true); // true 表示优先级高
413
+ });
414
+ }
415
+ /**
416
+ * 删除消息
417
+ */
418
+ async function deleteMessages(session, msgIds) {
419
+ if (!msgIds || msgIds.length === 0)
420
+ return;
421
+ for (const msgId of msgIds) {
422
+ try {
423
+ await session.bot?.deleteMessage(session.channelId, msgId);
424
+ }
425
+ catch (e) {
426
+ // 忽略删除失败(可能没有权限或消息已删除)
427
+ }
428
+ }
165
429
  }
166
430
  /**
167
431
  * 注册预设查询指令
@@ -297,101 +561,31 @@ async function executeGenerate(ctx, session, options) {
297
561
  const logger = ctx.logger('media-luna/commands');
298
562
  // 获取用户 ID
299
563
  const uid = session?.user?.id;
300
- // 处理输入文件
301
- const files = [];
302
- const processedUrls = new Set(); // 用于去重
303
- // 从消息中提取图片(使用 Koishi 元素选择器)
304
- if (session?.elements) {
305
- const imageElements = koishi_1.h.select(session.elements, 'img,image');
306
- for (const img of imageElements) {
307
- const src = img.attrs?.src || img.attrs?.url;
308
- if (src && !processedUrls.has(src)) {
309
- processedUrls.add(src);
310
- try {
311
- const response = await ctx.http.get(src, { responseType: 'arraybuffer' });
312
- const buffer = Buffer.from(response);
313
- const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
314
- files.push({
315
- data: arrayBuffer,
316
- mime: 'image/png',
317
- filename: `input_${files.length}.png`
318
- });
319
- }
320
- catch (e) {
321
- logger.warn('Failed to fetch image from message: %s', e);
322
- }
323
- }
324
- }
325
- // 从消息中提取 at 元素,获取被 at 用户的头像
326
- const atElements = koishi_1.h.select(session.elements, 'at');
327
- for (const at of atElements) {
328
- const userId = at.attrs?.id;
329
- if (userId && session.bot) {
330
- try {
331
- // 尝试获取用户信息
332
- const user = await session.bot.getUser(userId);
333
- const avatarUrl = user?.avatar;
334
- if (avatarUrl && !processedUrls.has(avatarUrl)) {
335
- processedUrls.add(avatarUrl);
336
- try {
337
- const response = await ctx.http.get(avatarUrl, { responseType: 'arraybuffer' });
338
- const buffer = Buffer.from(response);
339
- const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
340
- files.push({
341
- data: arrayBuffer,
342
- mime: 'image/png',
343
- filename: `avatar_${userId}.png`
344
- });
345
- logger.debug('Extracted avatar for user %s', userId);
346
- }
347
- catch (e) {
348
- logger.warn('Failed to fetch avatar for user %s: %s', userId, e);
349
- }
350
- }
351
- }
352
- catch (e) {
353
- logger.warn('Failed to get user info for %s: %s', userId, e);
354
- }
355
- }
356
- }
357
- }
358
- // 如果命令行指定了图片 URL,也获取(去重)
359
- if (options.imageUrl && !processedUrls.has(options.imageUrl)) {
360
- processedUrls.add(options.imageUrl);
361
- try {
362
- const response = await ctx.http.get(options.imageUrl, { responseType: 'arraybuffer' });
363
- const buffer = Buffer.from(response);
364
- const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
365
- files.push({
366
- data: arrayBuffer,
367
- mime: 'image/png',
368
- filename: `input_${files.length}.png`
369
- });
370
- }
371
- catch (e) {
372
- logger.warn('Failed to fetch input image: %s', e);
373
- }
374
- }
375
- // 清理 prompt 中的图片标签,只保留文本
376
- let cleanPrompt = options.prompt;
377
- if (session?.elements) {
378
- const textElements = koishi_1.h.select(session.elements, 'text');
379
- cleanPrompt = textElements.map(el => el.attrs?.content || '').join('').trim();
380
- }
381
- // 发送"正在生成中"提示
382
- await session?.send('正在生成中...');
564
+ // 发送摘要和"正在生成中"提示
565
+ const statusMsg = options.summaryMsg
566
+ ? `${options.summaryMsg}\n正在生成中...`
567
+ : '正在生成中...';
568
+ const generatingMsgIds = await session?.send(statusMsg);
383
569
  try {
384
570
  const result = await ctx.mediaLuna.generateByName({
385
571
  channelName: options.channelName,
386
572
  presetName: options.presetName,
387
- prompt: cleanPrompt,
388
- files,
573
+ prompt: options.prompt,
574
+ files: options.files,
389
575
  session,
390
576
  uid
391
577
  });
578
+ // 撤销"正在生成中"消息
579
+ if (session && generatingMsgIds) {
580
+ await deleteMessages(session, generatingMsgIds);
581
+ }
392
582
  return formatResult(result);
393
583
  }
394
584
  catch (error) {
585
+ // 撤销"正在生成中"消息
586
+ if (session && generatingMsgIds) {
587
+ await deleteMessages(session, generatingMsgIds);
588
+ }
395
589
  logger.error('Generate failed: %s', error);
396
590
  return `生成失败: ${error instanceof Error ? error.message : '未知错误'}`;
397
591
  }
@@ -400,15 +594,24 @@ async function executeGenerate(ctx, session, options) {
400
594
  * 格式化生成结果
401
595
  */
402
596
  function formatResult(result) {
597
+ const messages = [];
598
+ // 添加生成前提示(来自中间件,如 billing 预扣费)
599
+ if (result.hints?.before && result.hints.before.length > 0) {
600
+ messages.push(result.hints.before.join('\n'));
601
+ }
602
+ // 添加生成后提示(来自中间件,如 billing 结算)
603
+ if (result.hints?.after && result.hints.after.length > 0) {
604
+ messages.push(result.hints.after.join('\n'));
605
+ }
403
606
  if (!result.success) {
404
- // TODO: 支持自定义错误消息模板
405
- return `生成失败: ${result.error || '未知错误'}`;
607
+ messages.push(`生成失败: ${result.error || '未知错误'}`);
608
+ return messages.join('\n');
406
609
  }
407
610
  if (!result.output || result.output.length === 0) {
408
- return '生成完成,但没有输出';
611
+ messages.push('生成完成,但没有输出');
612
+ return messages.join('\n');
409
613
  }
410
614
  // 构建输出消息
411
- const messages = [];
412
615
  for (const asset of result.output) {
413
616
  if (asset.kind === 'image' && asset.url) {
414
617
  messages.push(`<image url="${asset.url}"/>`);
@@ -420,7 +623,6 @@ function formatResult(result) {
420
623
  messages.push(`<video url="${asset.url}"/>`);
421
624
  }
422
625
  }
423
- // TODO: 添加成功消息模板支持(如消费 {cost}{currency},余额 {balance})
424
626
  return messages.join('\n');
425
627
  }
426
628
  //# sourceMappingURL=index.js.map