koishi-plugin-imgdraw-selfuse 0.0.7 → 0.1.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/lib/index.d.ts CHANGED
@@ -26,6 +26,26 @@ export declare const Config: Schema<Schemastery.ObjectS<{
26
26
  txt2imgModel: Schema<string, string>;
27
27
  img2imgModel: Schema<string, string>;
28
28
  maxImages: Schema<number, number>;
29
+ enableImgCompress: Schema<boolean, boolean>;
30
+ imgMaxWidth: Schema<number, number>;
31
+ imgMaxHeight: Schema<number, number>;
32
+ imgQuality: Schema<number, number>;
33
+ imgMaxFileSize: Schema<number, number>;
34
+ enableImg2ImgBase64: Schema<boolean, boolean>;
35
+ enablePresets: Schema<boolean, boolean>;
36
+ presets: Schema<Schemastery.ObjectS<{
37
+ enable: Schema<boolean, boolean>;
38
+ text: Schema<string, string>;
39
+ command: Schema<string, string>;
40
+ keyword: Schema<string, string>;
41
+ enableKeywordMatch: Schema<boolean, boolean>;
42
+ }>[], Schemastery.ObjectT<{
43
+ enable: Schema<boolean, boolean>;
44
+ text: Schema<string, string>;
45
+ command: Schema<string, string>;
46
+ keyword: Schema<string, string>;
47
+ enableKeywordMatch: Schema<boolean, boolean>;
48
+ }>[]>;
29
49
  apiList: Schema<Schemastery.ObjectS<{
30
50
  enable: Schema<boolean, boolean>;
31
51
  apiKey: Schema<string, string>;
@@ -105,6 +125,26 @@ export declare const Config: Schema<Schemastery.ObjectS<{
105
125
  txt2imgModel: Schema<string, string>;
106
126
  img2imgModel: Schema<string, string>;
107
127
  maxImages: Schema<number, number>;
128
+ enableImgCompress: Schema<boolean, boolean>;
129
+ imgMaxWidth: Schema<number, number>;
130
+ imgMaxHeight: Schema<number, number>;
131
+ imgQuality: Schema<number, number>;
132
+ imgMaxFileSize: Schema<number, number>;
133
+ enableImg2ImgBase64: Schema<boolean, boolean>;
134
+ enablePresets: Schema<boolean, boolean>;
135
+ presets: Schema<Schemastery.ObjectS<{
136
+ enable: Schema<boolean, boolean>;
137
+ text: Schema<string, string>;
138
+ command: Schema<string, string>;
139
+ keyword: Schema<string, string>;
140
+ enableKeywordMatch: Schema<boolean, boolean>;
141
+ }>[], Schemastery.ObjectT<{
142
+ enable: Schema<boolean, boolean>;
143
+ text: Schema<string, string>;
144
+ command: Schema<string, string>;
145
+ keyword: Schema<string, string>;
146
+ enableKeywordMatch: Schema<boolean, boolean>;
147
+ }>[]>;
108
148
  apiList: Schema<Schemastery.ObjectS<{
109
149
  enable: Schema<boolean, boolean>;
110
150
  apiKey: Schema<string, string>;
package/lib/index.js CHANGED
@@ -10,6 +10,14 @@ const axios_1 = __importDefault(require("axios"));
10
10
  const yaml_1 = __importDefault(require("yaml"));
11
11
  const fs_1 = __importDefault(require("fs"));
12
12
  const path_1 = __importDefault(require("path"));
13
+ // 尝试导入 sharp,如果未安装则给出提示
14
+ let sharp;
15
+ try {
16
+ sharp = require('sharp');
17
+ }
18
+ catch {
19
+ sharp = null;
20
+ }
13
21
  exports.name = 'ai-image';
14
22
  exports.inject = {
15
23
  required: ['console', 'i18n', 'database'],
@@ -17,6 +25,14 @@ exports.inject = {
17
25
  };
18
26
  const logger = new koishi_1.Logger('ai-image');
19
27
  // ==================== 配置 Schema ====================
28
+ // 预置提示词单项配置
29
+ const PresetItem = koishi_1.Schema.object({
30
+ enable: koishi_1.Schema.boolean().default(true).description('启用此预置提示词'),
31
+ text: koishi_1.Schema.string().default('').description('预置提示词文本(将自动添加到 prompt 前)'),
32
+ command: koishi_1.Schema.string().default('').description('触发指令(如 draw0,留空则自动生成 drawN)'),
33
+ keyword: koishi_1.Schema.string().default('').description('匹配关键词(如 猫娘,留空则不启用关键词匹配)'),
34
+ enableKeywordMatch: koishi_1.Schema.boolean().default(false).description('启用关键词匹配(用户 prompt 包含关键词时自动添加)'),
35
+ }).description('预置提示词配置项');
20
36
  exports.Config = koishi_1.Schema.object({
21
37
  debug: koishi_1.Schema.boolean().default(false).description('开启调试模式,输出完整请求日志'),
22
38
  apiStrategy: koishi_1.Schema.union([
@@ -30,6 +46,20 @@ exports.Config = koishi_1.Schema.object({
30
46
  txt2imgModel: koishi_1.Schema.string().default('').description('文生图专用模型,留空则使用通用模型'),
31
47
  img2imgModel: koishi_1.Schema.string().default('').description('图生图专用模型,留空则使用通用模型'),
32
48
  maxImages: koishi_1.Schema.number().default(5).description('图生图最大支持图片数量'),
49
+ // ==================== 图片压缩配置 ====================
50
+ enableImgCompress: koishi_1.Schema.boolean().default(true).description('启用图生图图片压缩(推荐开启,可防止大图超时)'),
51
+ imgMaxWidth: koishi_1.Schema.number().default(1536).description('图片压缩最大宽度(像素)'),
52
+ imgMaxHeight: koishi_1.Schema.number().default(1536).description('图片压缩最大高度(像素)'),
53
+ imgQuality: koishi_1.Schema.number().default(85).description('JPEG 压缩质量 1-100(越高越清晰,建议 80-90)'),
54
+ imgMaxFileSize: koishi_1.Schema.number().default(3).description('图片最大体积(MB),超过会进一步压缩'),
55
+ // ==========================================================
56
+ // ==================== 新增:图生图 base64 转换开关 ====================
57
+ enableImg2ImgBase64: koishi_1.Schema.boolean().default(true).description('图生图将图片转换为 base64(关闭则直接传 URL,部分 API 不需要 base64)'),
58
+ // ==========================================================
59
+ // ==================== 预置提示词配置 ====================
60
+ enablePresets: koishi_1.Schema.boolean().default(false).description('启用预置提示词功能(仅用于文生图)'),
61
+ presets: koishi_1.Schema.array(PresetItem).default([]).description('预置提示词列表(可添加多个,支持指令触发和关键词匹配)'),
62
+ // ==========================================================
33
63
  apiList: koishi_1.Schema.array(koishi_1.Schema.object({
34
64
  enable: koishi_1.Schema.boolean().default(true).description('启用此 API'),
35
65
  apiKey: koishi_1.Schema.string().description('API Key'),
@@ -37,7 +67,7 @@ exports.Config = koishi_1.Schema.object({
37
67
  })).default([]).description('API 配置列表(支持多账号负载)'),
38
68
  enableTxt2Img: koishi_1.Schema.boolean().default(true).description('启用文生图'),
39
69
  enableImg2Img: koishi_1.Schema.boolean().default(true).description('启用图生图'),
40
- command: koishi_1.Schema.string().default('draw').description('文生图指令'),
70
+ command: koishi_1.Schema.string().default('draw').description('文生图主指令'),
41
71
  aliases: koishi_1.Schema.array(String).default([]).description('文生图指令别名'),
42
72
  img2imgCommand: koishi_1.Schema.string().default('imgdraw').description('图生图指令'),
43
73
  img2imgAliases: koishi_1.Schema.array(String).default([]).description('图生图指令别名'),
@@ -74,6 +104,11 @@ exports.Config = koishi_1.Schema.object({
74
104
  // ==================== 主函数 ====================
75
105
  async function apply(ctx, cfg) {
76
106
  const debug = cfg.debug;
107
+ // 检查 sharp 是否安装
108
+ if (cfg.enableImgCompress && !sharp) {
109
+ logger.warn('图片压缩已启用,但未检测到 sharp 库。请运行:npm install sharp');
110
+ logger.warn('在未安装 sharp 的情况下,将跳过压缩,可能导致大图超时');
111
+ }
77
112
  // 加载本地化文件
78
113
  try {
79
114
  const loc = path_1.default.join(__dirname, 'locales', 'zh-CN.yml');
@@ -126,11 +161,11 @@ async function apply(ctx, cfg) {
126
161
  if (!str)
127
162
  return '';
128
163
  // 1. 清理标准 HTML 标签
129
- let cleaned = str.replace(/<<[^>]+>/g, ' ');
164
+ let cleaned = str.replace(/<[^>]+>/g, ' ');
130
165
  // 2. 清理 QQ XML 图片标签(如 <img src="..." file="..."/>)
131
- cleaned = cleaned.replace(/<<img\s+[^>]+\/>/gi, ' ');
166
+ cleaned = cleaned.replace(/<img\s+[^>]+\/>/gi, ' ');
132
167
  // 3. 清理其他 XML 标签
133
- cleaned = cleaned.replace(/<<[^>]+>/g, ' ');
168
+ cleaned = cleaned.replace(/<[^>]+>/g, ' ');
134
169
  // 4. 清理多余空格和换行
135
170
  cleaned = cleaned.replace(/\s+/g, ' ').trim();
136
171
  return cleaned;
@@ -153,27 +188,104 @@ async function apply(ctx, cfg) {
153
188
  return markdownMatch[1];
154
189
  return null;
155
190
  }
156
- // ==================== 新增:URL 转 base64 ====================
157
- async function urlToBase64(url) {
191
+ // ==================== 图片压缩函数 ====================
192
+ async function compressImage(buffer) {
193
+ if (!sharp || !cfg.enableImgCompress) {
194
+ return buffer;
195
+ }
196
+ try {
197
+ let image = sharp(buffer);
198
+ const metadata = await image.metadata();
199
+ // 计算缩放比例
200
+ let width = metadata.width || cfg.imgMaxWidth;
201
+ let height = metadata.height || cfg.imgMaxHeight;
202
+ const ratio = Math.min(cfg.imgMaxWidth / width, cfg.imgMaxHeight / height, 1 // 不放大
203
+ );
204
+ if (ratio < 1) {
205
+ width = Math.round(width * ratio);
206
+ height = Math.round(height * ratio);
207
+ image = image.resize(width, height, { fit: 'inside' });
208
+ }
209
+ // 压缩并转换格式
210
+ let quality = Math.max(1, Math.min(100, cfg.imgQuality));
211
+ let compressed = await image
212
+ .jpeg({ quality, progressive: true, mozjpeg: true })
213
+ .toBuffer();
214
+ // 如果还超过限制,进一步降低质量
215
+ const maxBytes = cfg.imgMaxFileSize * 1024 * 1024;
216
+ while (compressed.length > maxBytes && quality > 40) {
217
+ quality -= 5;
218
+ compressed = await image
219
+ .jpeg({ quality, progressive: true, mozjpeg: true })
220
+ .toBuffer();
221
+ }
222
+ if (debug) {
223
+ logger.info(`图片压缩: ${buffer.length} -> ${compressed.length} bytes ` +
224
+ `(${Math.round(compressed.length / buffer.length * 100)}%), ` +
225
+ `尺寸: ${Math.round(width)}x${Math.round(height)}, 质量: ${quality}`);
226
+ }
227
+ return compressed;
228
+ }
229
+ catch (e) {
230
+ logger.error('图片压缩失败,使用原图', e);
231
+ return buffer;
232
+ }
233
+ }
234
+ async function compressBase64Image(base64Url) {
235
+ if (!sharp || !cfg.enableImgCompress) {
236
+ return base64Url;
237
+ }
238
+ try {
239
+ const base64Data = base64Url.split(',')[1];
240
+ const buffer = Buffer.from(base64Data, 'base64');
241
+ const compressed = await compressImage(buffer);
242
+ return `data:image/jpeg;base64,${compressed.toString('base64')}`;
243
+ }
244
+ catch (e) {
245
+ logger.error('base64 图片压缩失败,使用原图', e);
246
+ return base64Url;
247
+ }
248
+ }
249
+ // ==========================================================
250
+ // ==================== 修改:URL 转 base64(支持开关)====================
251
+ /**
252
+ * 处理图片 URL,根据配置决定返回 base64 还是原始 URL
253
+ * @param url 图片 URL
254
+ * @param forceBase64 强制使用 base64(覆盖全局配置)
255
+ */
256
+ async function processImageUrl(url, forceBase64) {
158
257
  if (!url)
159
258
  return null;
259
+ // 如果已经是 base64,检查是否需要压缩
160
260
  if (url.startsWith('data:image/')) {
261
+ if (cfg.enableImgCompress && sharp) {
262
+ return await compressBase64Image(url);
263
+ }
161
264
  return url;
162
265
  }
266
+ // 判断是否转换为 base64
267
+ const needBase64 = forceBase64 !== undefined ? forceBase64 : cfg.enableImg2ImgBase64;
268
+ if (!needBase64) {
269
+ // 直接返回 URL(不转换 base64)
270
+ if (debug)
271
+ logger.info('图生图使用原始 URL:', url.slice(0, 100));
272
+ return url;
273
+ }
274
+ // 转换为 base64
163
275
  try {
164
276
  const res = await axios_1.default.get(url, {
165
277
  responseType: 'arraybuffer',
166
278
  timeout: 30000,
167
279
  });
168
- const base64 = Buffer.from(res.data).toString('base64');
169
- const mime = res.headers['content-type'] || 'image/jpeg';
170
- return `data:${mime};base64,${base64}`;
280
+ const compressed = await compressImage(Buffer.from(res.data));
281
+ return `data:image/jpeg;base64,${compressed.toString('base64')}`;
171
282
  }
172
283
  catch (e) {
173
284
  logger.error('图片转 base64 失败', e);
174
285
  throw new Error('图片下载失败,请检查 selfUrl 是否可访问');
175
286
  }
176
287
  }
288
+ // ==========================================================
177
289
  // 统一发送图片函数
178
290
  async function sendImage(session, imgUrl) {
179
291
  const trimmed = imgUrl.trim();
@@ -318,6 +430,68 @@ async function apply(ctx, cfg) {
318
430
  }
319
431
  return { success, fail };
320
432
  }
433
+ // ==================== 预置提示词处理函数 ====================
434
+ /**
435
+ * 获取所有启用的预置提示词配置
436
+ */
437
+ function getEnabledPresets() {
438
+ if (!cfg.enablePresets || !cfg.presets)
439
+ return [];
440
+ return cfg.presets.filter((p) => p.enable && p.text);
441
+ }
442
+ /**
443
+ * 根据指令名查找匹配的预置提示词
444
+ * @param cmd 指令名,如 "draw0"
445
+ */
446
+ function findPresetByCommand(cmd) {
447
+ const presets = getEnabledPresets();
448
+ for (const preset of presets) {
449
+ const presetCmd = preset.command?.trim();
450
+ if (presetCmd && presetCmd === cmd) {
451
+ return preset;
452
+ }
453
+ }
454
+ return null;
455
+ }
456
+ /**
457
+ * 根据用户 prompt 关键词匹配预置提示词
458
+ * @param prompt 用户输入的 prompt
459
+ */
460
+ function findPresetsByKeyword(prompt) {
461
+ const presets = getEnabledPresets();
462
+ const matched = [];
463
+ if (!prompt)
464
+ return matched;
465
+ const lowerPrompt = prompt.toLowerCase();
466
+ for (const preset of presets) {
467
+ if (preset.enableKeywordMatch && preset.keyword) {
468
+ const keywords = preset.keyword.split(/[,,|\/\s]+/).map((k) => k.trim().toLowerCase()).filter(Boolean);
469
+ for (const kw of keywords) {
470
+ if (lowerPrompt.includes(kw)) {
471
+ matched.push(preset);
472
+ break;
473
+ }
474
+ }
475
+ }
476
+ }
477
+ return matched;
478
+ }
479
+ /**
480
+ * 拼接预置提示词和用户 prompt
481
+ * @param prompt 用户输入的原始 prompt
482
+ * @param presets 要应用的预置提示词列表
483
+ */
484
+ function buildPromptWithPresets(prompt, presets) {
485
+ if (!presets || presets.length === 0)
486
+ return prompt;
487
+ const presetTexts = presets.map((p) => p.text.trim()).filter(Boolean);
488
+ if (presetTexts.length === 0)
489
+ return prompt;
490
+ const combinedPreset = presetTexts.join(',');
491
+ // 预置提示词放在前面,用户 prompt 在后面
492
+ return `${combinedPreset},${prompt}`;
493
+ }
494
+ // ==========================================================
321
495
  // ==================== 核心生成函数 ====================
322
496
  async function generate(session, prompt, imageUrl, modelOverride) {
323
497
  if (!checkRateLimit()) {
@@ -334,14 +508,14 @@ async function apply(ctx, cfg) {
334
508
  const model = modelOverride || cfg.model;
335
509
  let content;
336
510
  if (imageUrl) {
337
- const base64Url = await urlToBase64(imageUrl);
338
- if (!base64Url) {
339
- await safeSend(session, cfg.messages.fail + '(图片转换失败)');
511
+ const processedUrl = await processImageUrl(imageUrl);
512
+ if (!processedUrl) {
513
+ await safeSend(session, cfg.messages.fail + '(图片处理失败)');
340
514
  return;
341
515
  }
342
516
  content = [
343
517
  { type: 'text', text: prompt },
344
- { type: 'image_url', image_url: { url: base64Url } },
518
+ { type: 'image_url', image_url: { url: processedUrl } },
345
519
  ];
346
520
  }
347
521
  else {
@@ -400,14 +574,14 @@ async function apply(ctx, cfg) {
400
574
  }
401
575
  const model = modelOverride || cfg.model;
402
576
  const finalPrompt = prompt.replace('{url}', imageUrls.join(', '));
403
- const base64Urls = (await Promise.all(imageUrls.map(url => urlToBase64(url)))).filter((url) => url !== null);
404
- if (base64Urls.length === 0) {
405
- await safeSend(session, cfg.messages.fail + '(图片转换失败)');
577
+ const processedUrls = (await Promise.all(imageUrls.map(url => processImageUrl(url)))).filter((url) => url !== null);
578
+ if (processedUrls.length === 0) {
579
+ await safeSend(session, cfg.messages.fail + '(图片处理失败)');
406
580
  return;
407
581
  }
408
582
  const content = [
409
583
  { type: 'text', text: finalPrompt },
410
- ...base64Urls.map(url => ({ type: 'image_url', image_url: { url } })),
584
+ ...processedUrls.map(url => ({ type: 'image_url', image_url: { url } })),
411
585
  ];
412
586
  const body = {
413
587
  model,
@@ -494,30 +668,90 @@ async function apply(ctx, cfg) {
494
668
  }
495
669
  return cleanHtmlTags(textParts.join(' '));
496
670
  }
671
+ // ==================== 新增:文生图核心逻辑(支持预置提示词)====================
672
+ async function doTxt2Img(session, rawPrompt, explicitPresets) {
673
+ if (!session)
674
+ return;
675
+ if (await isBlacklisted(session.userId)) {
676
+ await safeSend(session, cfg.messages.blacklisted);
677
+ return;
678
+ }
679
+ if (!cfg.enableTxt2Img) {
680
+ await safeSend(session, cfg.messages.txt2imgDisabled);
681
+ return;
682
+ }
683
+ let prompt = cleanHtmlTags(rawPrompt || '');
684
+ if (!prompt) {
685
+ await safeSend(session, cfg.messages.empty);
686
+ return;
687
+ }
688
+ // 处理预置提示词
689
+ const appliedPresets = [];
690
+ // 1. 显式指定的预置提示词(通过指令触发)
691
+ if (explicitPresets && explicitPresets.length > 0) {
692
+ appliedPresets.push(...explicitPresets);
693
+ }
694
+ // 2. 关键词匹配(仅在未通过指令触发时,或允许叠加时)
695
+ if (cfg.enablePresets) {
696
+ const keywordPresets = findPresetsByKeyword(prompt);
697
+ for (const kp of keywordPresets) {
698
+ // 避免重复添加
699
+ if (!appliedPresets.some((p) => p.text === kp.text)) {
700
+ appliedPresets.push(kp);
701
+ }
702
+ }
703
+ }
704
+ // 拼接预置提示词
705
+ if (appliedPresets.length > 0) {
706
+ prompt = buildPromptWithPresets(prompt, appliedPresets);
707
+ if (debug) {
708
+ logger.info(`应用预置提示词: ${appliedPresets.map((p) => p.command || 'keyword').join(', ')}`);
709
+ logger.info(`最终 prompt: ${prompt.slice(0, 200)}...`);
710
+ }
711
+ }
712
+ await safeSend(session, cfg.messages.generating);
713
+ const finalPrompt = cfg.txt2imgPrompt.replace('{prompt}', prompt);
714
+ const model = cfg.txt2imgModel || cfg.model;
715
+ await generate(session, finalPrompt, undefined, model);
716
+ }
717
+ // ==========================================================
497
718
  // ==================== 命令注册 ====================
719
+ // 主文生图指令
498
720
  const cmd = ctx.command(`${cfg.command} <raw:text>`, 'draw');
499
721
  cfg.aliases.forEach((alias) => cmd.alias(alias));
500
722
  cmd.action(async ({ session }, raw) => {
501
723
  try {
502
- if (!session)
503
- return;
504
- if (await isBlacklisted(session.userId))
505
- return safeSend(session, cfg.messages.blacklisted);
506
- if (!cfg.enableTxt2Img)
507
- return safeSend(session, cfg.messages.txt2imgDisabled);
508
- const prompt = cleanHtmlTags(raw || '');
509
- if (!prompt)
510
- return safeSend(session, cfg.messages.empty);
511
- await safeSend(session, cfg.messages.generating);
512
- const finalPrompt = cfg.txt2imgPrompt.replace('{prompt}', prompt);
513
- const model = cfg.txt2imgModel || cfg.model;
514
- await generate(session, finalPrompt, undefined, model);
724
+ await doTxt2Img(session, raw);
515
725
  }
516
726
  catch (e) {
517
727
  logger.error('文生图命令异常', e);
518
728
  await safeSend(session, cfg.messages.fail);
519
729
  }
520
730
  });
731
+ // ==================== 新增:预置提示词指令注册 ====================
732
+ if (cfg.enablePresets && cfg.presets) {
733
+ const presets = getEnabledPresets();
734
+ for (let i = 0; i < presets.length; i++) {
735
+ const preset = presets[i];
736
+ const presetCmd = preset.command?.trim() || `draw${i}`;
737
+ // 注册指令
738
+ const pCmd = ctx.command(`${presetCmd} <raw:text>`, `使用预置提示词: ${preset.text.slice(0, 20)}...`);
739
+ pCmd.action(async ({ session }, raw) => {
740
+ try {
741
+ if (debug)
742
+ logger.info(`预置指令触发: ${presetCmd}, 预置文本: ${preset.text.slice(0, 50)}`);
743
+ await doTxt2Img(session, raw, [preset]);
744
+ }
745
+ catch (e) {
746
+ logger.error(`预置指令 ${presetCmd} 异常`, e);
747
+ await safeSend(session, cfg.messages.fail);
748
+ }
749
+ });
750
+ if (debug)
751
+ logger.info(`注册预置提示词指令: ${presetCmd}`);
752
+ }
753
+ }
754
+ // ==========================================================
521
755
  // ==================== 修改:图生图命令支持合并消息 ====================
522
756
  const imgCmd = ctx.command(`${cfg.img2imgCommand} <raw:text>`, 'imgdraw');
523
757
  cfg.img2imgAliases.forEach((alias) => imgCmd.alias(alias));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-imgdraw-selfuse",
3
- "description": "修改自ai-image的画图插件,支持openai兼容api,增加base64转换以适配GPTimg2,支持发送合并消息图生图",
4
- "version": "0.0.7",
3
+ "description": "修改自ai-image的画图插件,支持openai兼容api,增加base64转换自由开启,增加图文合并消息图生图,增加预置提示词",
4
+ "version": "0.1.0",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [