koishi-plugin-p-draw 1.4.0 → 1.5.1

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/index.js CHANGED
@@ -3,7 +3,7 @@ const fs = require('fs')
3
3
  const fsp = require('fs/promises')
4
4
  const path = require('path')
5
5
  const crypto = require('crypto')
6
- const { pathToFileURL } = require('url')
6
+ const { pathToFileURL } = require('url')
7
7
 
8
8
  exports.name = 'p-draw'
9
9
 
@@ -28,9 +28,9 @@ exports.usage = `
28
28
  - **联网搜索:** 描述中带 \`联网\` / \`搜索\` / \`查一下\` 等词时,会先联网搜索补充角色设定(需配置 Tavily Key)。
29
29
  - **画师组:** \`创建画师组 名称=tags\` \`切换画师组 名称\` \`查看画师组\` \`删除画师组 名称\`
30
30
  - **固定角色:** \`添加角色 名称=tags\`
31
- `;
31
+ `;
32
32
 
33
- const { zhCN } = require('./lib/i18n')
33
+ const { zhCN } = require('./lib/i18n')
34
34
  exports.Config = Schema.object({
35
35
  // ComfyUI 连接
36
36
  comfyuiBaseUrl: Schema.string().default('http://127.0.0.1:8188').description('ComfyUI 地址'),
@@ -81,6 +81,7 @@ exports.Config = Schema.object({
81
81
  activeArtistPreset: Schema.string().default('').description('启用的画师组名称'),
82
82
  defaultArtistTags: Schema.string().default('').description('备用画师 tags'),
83
83
  styleTags: Schema.string().default('').description('画风 tags'),
84
+ fixedCharacters: Schema.array(Schema.string()).default([]).description('固定角色(格式:角色名=tags;仅用于兼容旧配置,运行时数据保存在数据库)'),
84
85
 
85
86
  // 队列
86
87
  queueEnabled: Schema.boolean().default(true).description('启用生成队列(逐张顺序执行)'),
@@ -92,7 +93,6 @@ exports.Config = Schema.object({
92
93
  multiPrice: Schema.number().default(900).description('多人指令(p-draw 多人)单张消耗的 P 点'),
93
94
  couponPrice: Schema.number().default(3000).description('提示词优化券单价(P 点/张,购买询问时显示;可自动读取 data/p-shop.json 里的价格覆盖)'),
94
95
  couponAskTimeout: Schema.number().default(60).description('提示词优化券确认等待时间(秒)'),
95
- seriesAskTimeout: Schema.number().default(60).description('连续图 LLM 使用确认等待时间(秒)'),
96
96
  adminUsers: Schema.array(Schema.string()).default([]).description('免 P 点管理员用户 ID 列表'),
97
97
  outputLogs: Schema.boolean().default(true).description('是否在控制台输出详细日志'),
98
98
 
@@ -115,27 +115,27 @@ exports.Config = Schema.object({
115
115
  'zh-CN': zhCN,
116
116
  })
117
117
 
118
- // ------------------------------------------------------------------
119
- // 纯函数库已拆分到 lib/(解析 / tag 清洗 / 工作流 / Comfy 等待 / 多人规划 / HTTP 客户端)
120
- // ------------------------------------------------------------------
121
- const {
122
- normalizeBaseUrl, escapeRe, parseGenerationSize, parseBatchCount, parseSeed,
123
- stripRawPrefix, splitPositiveNegativePrompt, parseNameTags, parsePresetList, mergeTagText,
124
- } = require('./lib/parse')
125
- const {
126
- splitTags, canonicalTagText, joinPromptParts, mergeNegativePrompts, cleanContentTags, appendInlineProtectedTags,
127
- NO_ARTIST_RE, NO_STYLE_RE,
128
- } = require('./lib/tags')
129
- const {
130
- buildWorkflow,
131
- } = require('./lib/workflows')
132
- const { outputImages, waitComfyResult } = require('./lib/comfy')
133
- const { materializeImageSource } = require('./lib/media')
134
- const {
135
- MULTI_PERSON_NEGATIVE_TAGS, buildMultiPersonPlanPrompt, parseMultiPersonPlan,
136
- renderMultiPersonCharacter, multiPersonAutoSize,
137
- } = require('./lib/multi')
138
- const { buildComfyClient } = require('./lib/http')
118
+ // ------------------------------------------------------------------
119
+ // 纯函数库已拆分到 lib/(解析 / tag 清洗 / 工作流 / Comfy 等待 / 多人规划 / HTTP 客户端)
120
+ // ------------------------------------------------------------------
121
+ const {
122
+ normalizeBaseUrl, escapeRe, parseGenerationSize, parseBatchCount, parseSeed,
123
+ stripRawPrefix, splitPositiveNegativePrompt, parseNameTags, parsePresetList, mergeTagText,
124
+ } = require('./lib/parse')
125
+ const {
126
+ splitTags, joinPromptParts, mergeNegativePrompts, cleanContentTags, appendInlineProtectedTags,
127
+ NO_ARTIST_RE, NO_STYLE_RE,
128
+ } = require('./lib/tags')
129
+ const {
130
+ buildWorkflow,
131
+ } = require('./lib/workflows')
132
+ const { outputImages, waitComfyResult } = require('./lib/comfy')
133
+ const { materializeImageSource } = require('./lib/media')
134
+ const {
135
+ MULTI_PERSON_NEGATIVE_TAGS, buildMultiPersonPlanPrompt, parseMultiPersonPlan,
136
+ renderMultiPersonCharacter, multiPersonAutoSize,
137
+ } = require('./lib/multi')
138
+ const { buildComfyClient } = require('./lib/http')
139
139
  exports.apply = async function apply(ctx, cfg) {
140
140
  // 注意:不在此处 extend p_system 表 —— 该表由 p-qiandao 等 p 系插件创建。
141
141
  // 重复声明同一张表可能导致 Koishi 的 schema 迁移冲突,拖垮签到插件。
@@ -143,13 +143,14 @@ exports.apply = async function apply(ctx, cfg) {
143
143
  const logger = ctx.logger('p-draw')
144
144
  ctx.i18n.define('zh-CN', zhCN)
145
145
 
146
- // 运行时数据(画师组/固定角色)持久化到 p_draw_config 表,而不是调用 scope.update
146
+ // 运行时数据持久化到数据库,而不是调用 scope.update
147
147
  // 写 koishi.yml:scope.update 会触发插件重载,导致正在生成的图被 dispose(Context has
148
- // been disposed),且连续多次写入时配置文件会被冲掉(曾出现配置整体恢复成默认)。
148
+ // been disposed),且重复写入时配置文件会被冲掉(曾出现配置整体恢复成默认)。
149
149
  try {
150
150
  ctx.model.extend('p_draw_config', {
151
151
  id: 'unsigned',
152
152
  fixed_characters: 'json',
153
+ fixed_characters_migrated: 'boolean',
153
154
  artist_presets: 'json',
154
155
  active_artist_preset: 'text',
155
156
  default_artist_tags: 'text',
@@ -159,6 +160,16 @@ exports.apply = async function apply(ctx, cfg) {
159
160
  logger.warn(`p_draw_config 表初始化失败:${e.message}`)
160
161
  }
161
162
 
163
+ try {
164
+ ctx.model.extend('p_draw_fixed_characters', {
165
+ id: 'unsigned',
166
+ name: 'string',
167
+ tags: 'text',
168
+ }, { autoInc: true, unique: ['name'] })
169
+ } catch (e) {
170
+ logger.warn(`p_draw_fixed_characters 表初始化失败:${e.message}`)
171
+ }
172
+
162
173
  // 用户自选模型偏好(userid -> unet 文件名),持久化在 p_draw_config.user_models
163
174
  if (!cfg.userModels || typeof cfg.userModels !== 'object') cfg.userModels = {}
164
175
 
@@ -240,14 +251,14 @@ exports.apply = async function apply(ctx, cfg) {
240
251
  if (objectInfo) {
241
252
  const unetList = availableModels(objectInfo, 'UNETLoader', 'unet_name')
242
253
  const clipList = availableModels(objectInfo, 'CLIPLoader', 'clip_name')
243
- const vaeList = availableModels(objectInfo, 'VAELoader', 'vae_name')
244
- payload.unet_available = unetList.includes(cfg.unetName)
245
- payload.unet_models = unetList
246
- payload.clip_available = clipList.includes(cfg.clipName)
247
- payload.vae_available = vaeList.includes(cfg.vaeName)
254
+ const vaeList = availableModels(objectInfo, 'VAELoader', 'vae_name')
255
+ payload.unet_available = unetList.includes(cfg.unetName)
256
+ payload.unet_models = unetList
257
+ payload.clip_available = clipList.includes(cfg.clipName)
258
+ payload.vae_available = vaeList.includes(cfg.vaeName)
248
259
  } else {
249
- payload.unet_available = undefined
250
- payload.clip_available = undefined
260
+ payload.unet_available = undefined
261
+ payload.clip_available = undefined
251
262
  payload.vae_available = undefined
252
263
  }
253
264
  return payload
@@ -290,9 +301,9 @@ exports.apply = async function apply(ctx, cfg) {
290
301
  const lines = [
291
302
  `ComfyUI 状态:在线`,
292
303
  `版本:${payload.comfyui_version || '未知'}`,
293
- `GPU:${payload.gpu || '未知'}(显存 ${payload.vram_total_mb}MB / 空闲 ${payload.vram_free_mb}MB)`,
294
- `主模型:${cfg.unetName} ${modelStatus(cfg.unetName, payload.unet_available)}`,
295
- `文本编码器:${cfg.clipName} ${modelStatus(cfg.clipName, payload.clip_available)}`,
304
+ `GPU:${payload.gpu || '未知'}(显存 ${payload.vram_total_mb}MB / 空闲 ${payload.vram_free_mb}MB)`,
305
+ `主模型:${cfg.unetName} ${modelStatus(cfg.unetName, payload.unet_available)}`,
306
+ `文本编码器:${cfg.clipName} ${modelStatus(cfg.clipName, payload.clip_available)}`,
296
307
  `VAE:${cfg.vaeName} ${modelStatus(cfg.vaeName, payload.vae_available)}`,
297
308
  `可用尺寸:${payload.allowed_sizes.join('、')}`,
298
309
  ]
@@ -403,7 +414,7 @@ exports.apply = async function apply(ctx, cfg) {
403
414
  return String(cfg.styleTags).trim()
404
415
  }
405
416
 
406
- // P 点余额预检(单图/多人/连续共用)
417
+ // P 点余额预检(单图/多人共用)
407
418
  async function precheckPoints(session, USERID, isAdmin, totalPrice) {
408
419
  if (isAdmin) return { ok: true }
409
420
  const notExists = await isAccountExists(USERID)
@@ -438,57 +449,66 @@ exports.apply = async function apply(ctx, cfg) {
438
449
  return { ok: true, tasks, firstPosition }
439
450
  }
440
451
 
441
- // 即时反馈的公共部分。扣费提醒单独返回,随后以引用消息发送。
442
- function feedbackBase(session, { firstPosition, count, totalPrice, isAdmin }) {
443
- const notices = []
444
- if (cfg.queueEnabled) {
445
- notices.push(session.text('.queued', [firstPosition, cfg.queueMaxRequests || '∞']))
446
- if (count > 1) notices.push(session.text('.batch-count', [count]))
447
- } else {
448
- notices.push(session.text('.generating'))
449
- if (count > 1) notices.push(session.text('.batch-count', [count]))
450
- }
451
- return {
452
- notices,
453
- chargeNotice: isAdmin ? '' : session.text('.charged', [totalPrice]),
454
- }
455
- }
456
-
457
- async function sendNotices(session, notices, opts = {}) {
458
- const content = notices.filter(Boolean).join('\n')
459
- if (!content) return
460
- try {
461
- const message = opts.quote && session.messageId ? h.quote(session.messageId) + content : content
462
- await session.send(message)
463
- } catch (e) {
464
- logger.warn(`发送反馈消息失败:${e.message}`)
465
- }
452
+ // 即时反馈的公共部分。扣费提醒在生成完成后使用实际 seed 发送。
453
+ function feedbackBase(session, { firstPosition, count }) {
454
+ const notices = []
455
+ if (cfg.queueEnabled) {
456
+ notices.push(session.text('.queued', [firstPosition, cfg.queueMaxRequests || '∞']))
457
+ if (count > 1) notices.push(session.text('.batch-count', [count]))
458
+ } else {
459
+ notices.push(session.text('.generating'))
460
+ if (count > 1) notices.push(session.text('.batch-count', [count]))
461
+ }
462
+ return { notices }
463
+ }
464
+
465
+ function buildChargeNotice({ isAdmin, totalPrice, unetName, seeds }) {
466
+ if (isAdmin || !seeds.length) return ''
467
+ return `已扣除 ${totalPrice} P 点,当前模型:${unetName},--seed=${seeds.join(',')}`
466
468
  }
467
469
 
468
- // 生成图片使用合并转发;每张图后紧跟实际使用的正负面提示词,不附原消息引用。
469
- async function sendImagesAsForward(session, outputs) {
470
- const nodes = []
471
- const fallback = []
472
- for (const output of outputs) {
473
- const src = typeof output === 'string' ? output : output.src
474
- const prompt = typeof output === 'string' ? '' : String(output.prompt || '')
475
- const negativePrompt = typeof output === 'string' ? '' : String(output.negativePrompt || '')
476
- const materialized = await materializeImageSource(src)
477
- const image = Buffer.isBuffer(materialized) ? h.image(materialized) : h.image(src)
478
- nodes.push(h('message', image))
479
- fallback.push(image)
480
- const promptText = `Positive:\n${prompt}\n\nNegative:\n${negativePrompt}`
481
- nodes.push(h('message', promptText))
482
- fallback.push(promptText)
483
- }
484
- try {
485
- await session.send(h('figure', nodes))
486
- } catch (e) {
487
- logger.warn(`发送转发图片失败:${e.message}`)
488
- await session.send(fallback)
489
- }
470
+ function buildGenerationReply(session, { successCount, count, failures, notes = [] }) {
471
+ const reply = []
472
+ if (notes.length) reply.push(notes.join('\n'))
473
+ if (failures.length) reply.push(session.text('.batch-partial', [successCount, count, failures.length, failures.join(';')]))
474
+ return reply.filter(Boolean).join('\n')
490
475
  }
491
476
 
477
+ async function sendNotices(session, notices, opts = {}) {
478
+ const content = notices.filter(Boolean).join('\n')
479
+ if (!content) return
480
+ try {
481
+ const message = opts.quote && session.messageId ? h.quote(session.messageId) + content : content
482
+ await session.send(message)
483
+ } catch (e) {
484
+ logger.warn(`发送反馈消息失败:${e.message}`)
485
+ }
486
+ }
487
+
488
+ // 生成图片使用合并转发;每张图后紧跟实际使用的正负面提示词,不附原消息引用。
489
+ async function sendImagesAsForward(session, outputs) {
490
+ const nodes = []
491
+ const fallback = []
492
+ for (const output of outputs) {
493
+ const src = typeof output === 'string' ? output : output.src
494
+ const prompt = typeof output === 'string' ? '' : String(output.prompt || '')
495
+ const negativePrompt = typeof output === 'string' ? '' : String(output.negativePrompt || '')
496
+ const materialized = await materializeImageSource(src)
497
+ const image = Buffer.isBuffer(materialized) ? h.image(materialized) : h.image(src)
498
+ nodes.push(h('message', image))
499
+ fallback.push(image)
500
+ const promptText = `Positive:\n${prompt}\n\nNegative:\n${negativePrompt}`
501
+ nodes.push(h('message', promptText))
502
+ fallback.push(promptText)
503
+ }
504
+ try {
505
+ await session.send(h('figure', nodes))
506
+ } catch (e) {
507
+ logger.warn(`发送转发图片失败:${e.message}`)
508
+ await session.send(fallback)
509
+ }
510
+ }
511
+
492
512
  // P 点读改写按用户串行化,避免并发指令互相覆盖余额
493
513
  const userLocks = new Map()
494
514
  function withUserLock(USERID, fn) {
@@ -499,17 +519,22 @@ exports.apply = async function apply(ctx, cfg) {
499
519
  }
500
520
 
501
521
  // ---------------- 用户自选模型 ----------------
502
- // 从 ComfyUI /object_info(10 分钟缓存)读取真实的 UNET 模型列表
522
+ // 从 ComfyUI /object_info(10 分钟缓存)读取 UNET 和 checkpoint 模型列表
503
523
  async function listUnetModels() {
504
524
  try {
505
525
  const objectInfo = await getObjectInfoCached()
506
- const list = availableModels(objectInfo, 'UNETLoader', 'unet_name')
507
- if (list.length) return list
526
+ const list = [
527
+ ...availableModels(objectInfo, 'UNETLoader', 'unet_name'),
528
+ ...availableModels(objectInfo, 'CheckpointLoaderSimple', 'ckpt_name')
529
+ .filter(name => String(name).trim().toLowerCase().replace(/[-_]/g, '~') === 'animagine~xl~3.1.safetensors'),
530
+ ]
531
+ const unique = [...new Set(list)]
532
+ if (unique.length) return unique
508
533
  } catch (e) { /* ignore */ }
509
534
  return []
510
535
  }
511
536
 
512
- // 解析该用户当前生效的 UNET 模型:有偏好且仍存在于 ComfyUI 时用偏好,否则回落默认
537
+ // 解析该用户当前生效的模型:有偏好且仍存在于 ComfyUI 时用偏好,否则回落默认
513
538
  async function resolveUnet(USERID) {
514
539
  const chosen = cfg.userModels && cfg.userModels[USERID]
515
540
  if (!chosen || !String(chosen).trim()) return cfg.unetName
@@ -559,12 +584,12 @@ exports.apply = async function apply(ctx, cfg) {
559
584
 
560
585
  async function runComfyGenerate(prompt, size, overrides) {
561
586
  const sizes = parseAllowedSizes()
562
- const requestedWidth = (size && size[0]) || overrides.width || cfg.width
563
- const requestedHeight = (size && size[1]) || overrides.height || cfg.height
564
587
  const unetName = overrides.unet || cfg.unetName
565
588
  // 应用该模型的独立参数覆盖(modelParams),再叠加命令级 overrides(overrides.steps/cfg 优先于模型级)
566
589
  const modelSpecific = resolveModelParams(unetName)
567
590
  const workCfg = Object.assign({}, cfg, modelSpecific, { unetName })
591
+ const requestedWidth = (size && size[0]) || overrides.width || workCfg.width
592
+ const requestedHeight = (size && size[1]) || overrides.height || workCfg.height
568
593
  // 防御:sampler/scheduler 配置若带尾随空格会导致 ComfyUI 报 "Value not in list",统一 trim
569
594
  if (typeof workCfg.samplerName === 'string') workCfg.samplerName = workCfg.samplerName.trim()
570
595
  if (typeof workCfg.scheduler === 'string') workCfg.scheduler = workCfg.scheduler.trim()
@@ -574,7 +599,7 @@ exports.apply = async function apply(ctx, cfg) {
574
599
  const cfgVal = Number(overrides.cfg) || workCfg.cfg
575
600
  const seed = Number(overrides.seed) || crypto.randomInt(1, 2 ** 32 - 1)
576
601
  const negativePrompt = joinPromptParts([overrides.negativePrompt || cfg.negativePrompt || ''])
577
- const promptBody = buildWorkflow(workCfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, Boolean(size))
602
+ const promptBody = buildWorkflow(workCfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, Boolean(size))
578
603
 
579
604
  const clientId = crypto.randomUUID()
580
605
  const submit = await comfyPost('/prompt', { prompt: promptBody, client_id: clientId }, 20000)
@@ -634,10 +659,10 @@ exports.apply = async function apply(ctx, cfg) {
634
659
  width,
635
660
  height,
636
661
  steps,
637
- cfg: cfgVal,
638
- prompt_id: promptId,
639
- negativePrompt,
640
- }
662
+ cfg: cfgVal,
663
+ prompt_id: promptId,
664
+ negativePrompt,
665
+ }
641
666
  }
642
667
 
643
668
  // ---------------- LLM 提示词优化 ----------------
@@ -679,9 +704,14 @@ exports.apply = async function apply(ctx, cfg) {
679
704
  }
680
705
 
681
706
  // 把命中的固定角色 tags 渲染进优化模板的 {character_rule} 占位符。
682
- function buildCharacterRule(prompt) {
707
+ async function fixedCharacterRows(query = {}) {
708
+ const rows = await ctx.database.get('p_draw_fixed_characters', query)
709
+ return rows.sort((a, b) => Number(a.id) - Number(b.id))
710
+ }
711
+
712
+ async function buildCharacterRule(prompt) {
683
713
  const text = String(prompt || '')
684
- for (const [name, tags] of Object.entries(parsePresetList(cfg.fixedCharacters))) {
714
+ for (const { name, tags } of await fixedCharacterRows()) {
685
715
  if (name && text.includes(name)) {
686
716
  return `用户提到了固定角色「${name}」,其外观 tags 为:${tags} 请优先保留这些特征。`
687
717
  }
@@ -741,7 +771,7 @@ exports.apply = async function apply(ctx, cfg) {
741
771
  }
742
772
  }
743
773
 
744
- async function optimizePrompt(session, userPrompt, force = false, precomputedSearch = null) {
774
+ async function optimizePrompt(session, userPrompt, force = false, precomputedSearch = null) {
745
775
  if (!cfg.promptOptimizeEnabled && !force) {
746
776
  return { ok: true, prompt: userPrompt, reason: 'optimize_disabled' }
747
777
  }
@@ -756,8 +786,8 @@ exports.apply = async function apply(ctx, cfg) {
756
786
  } else if (wantsWebSearch(userPrompt)) {
757
787
  searchBlock = await webSearch(userPrompt)
758
788
  }
759
- const characterRule = buildCharacterRule(userPrompt)
760
- const defaultTemplate = `你是为图像生成模型编写正面提示词的 AI 画师。\n\n请根据用户的原始要求设计一幅完整、协调、具有视觉吸引力的画面,并将结果输出为英文 Danbooru-style tags。\n\n输出要求:\n- 只输出一行英文 tags,使用英文逗号分隔。\n- 不要输出解释、分析、标题、编号、Markdown、代码块或中文。\n- 不要输出 masterpiece、best quality、score 等质量前缀。\n- 不要输出画师 tags;质量词和画师组会由程序另行拼接。\n- 尽量使用模型容易理解的可见画面描述。\n- 保持用户明确指定的角色、主体、人数、关键服装、动作、表情和道具。\n- 以最终图像协调、精致、有表现力和好看为优先。\n\n角色和动态上下文:\n{character_rule}\n{search_block}\n\n用户原始要求:\n{theme}`
789
+ const characterRule = await buildCharacterRule(userPrompt)
790
+ const defaultTemplate = `你是为图像生成模型编写正面提示词的 AI 画师。\n\n请根据用户的原始要求设计一幅完整、协调、具有视觉吸引力的画面,并将结果输出为英文 Danbooru-style tags。\n\n输出要求:\n- 只输出一行英文 tags,使用英文逗号分隔。\n- 不要输出解释、分析、标题、编号、Markdown、代码块或中文。\n- 不要输出 masterpiece、best quality、score 等质量前缀。\n- 不要输出画师 tags;质量词和画师组会由程序另行拼接。\n- 尽量使用模型容易理解的可见画面描述。\n- 保持用户明确指定的角色、主体、人数、关键服装、动作、表情和道具。\n- 以最终图像协调、精致、有表现力和好看为优先。\n\n角色和动态上下文:\n{character_rule}\n{search_block}\n\n用户原始要求:\n{theme}`
761
791
  const template = (cfg.promptOptimizeTemplate || '').trim() || defaultTemplate
762
792
  const searchBlockText = searchBlock
763
793
  ? `联网搜索参考信息(请尽量依据这些内容补全角色外观与设定):\n${searchBlock}`
@@ -815,54 +845,9 @@ exports.apply = async function apply(ctx, cfg) {
815
845
  }
816
846
  }
817
847
 
818
- function extractSeriesOptimizeJson(text) {
819
- let raw = String(text || '').trim()
820
- if (raw.startsWith('```')) raw = (raw.match(/```(?:json)?([\s\S]*?)```/) || [null, raw])[1].trim()
821
- const start = raw.indexOf('{')
822
- const end = raw.lastIndexOf('}')
823
- if (start === -1 || end === -1 || end <= start) return null
824
- try { return JSON.parse(raw.slice(start, end + 1)) } catch (e) { return null }
825
- }
826
-
827
- function filterFixedTags(tags, drops) {
828
- if (!tags) return ''
829
- const dropKeys = (drops || []).map(d => canonicalTagText(String(d))).filter(Boolean)
830
- if (!dropKeys.length) return tags
831
- return splitTags(tags)
832
- .filter(t => !dropKeys.some(k => k && canonicalTagText(t).includes(k)))
833
- .join(', ')
834
- }
835
-
836
- // 连续图专用的阶段优化:LLM 把「角色 + 阶段描述」转成 Danbooru tags,并返回
837
- // 要从固定角色 tags 中移除的冲突项(如固定 silver hair、阶段变成 black hair)。
838
- async function optimizeSeriesStage(userPrompt, identity) {
839
- if (!cfg.llmModel || !cfg.llmBaseUrl) {
840
- return { ok: false, prompt: userPrompt, drops: [], reason: 'llm_not_configured' }
841
- }
842
- const fixedTags = identity && parsePresetList(cfg.fixedCharacters)[identity]
843
- ? parsePresetList(cfg.fixedCharacters)[identity]
844
- : ''
845
- const template = `你是为图像生成模型编写正面提示词的 AI 画师。这是「同一个角色」的连续变化过程中的某一个阶段。\n\n用户给出一行描述:<角色身份>,<本阶段的外貌/状态描述>。\n\n固定角色 tags(身份锚点,包含角色名标签、种族、体型、标志特征,也可能包含发色、瞳色等默认外观):\n${fixedTags || '(无)'}\n\n输出要求:\n- 只输出一个 JSON 对象,不要 Markdown、不要解释、不要其他任何文字:\n{\n "stage_tags": "一行英文 Danbooru-style tags,用于本阶段画面,英文逗号分隔;不含 masterpiece/best quality 等质量前缀,不含画师 tags",\n "drop_fixed": ["要从固定 tags 中移除的标签列表;仅当本阶段描述明确改变了该外观时才列出"]\n}\n- 身份一致性:若用户描述的就是固定角色,stage_tags 必须包含该角色的角色名标签(如 kokkoro_(princess_connect!)),并保留种族、体型、标志特征等「不变的底层身份」。\n- 覆盖规则:本阶段描述明确提到的变化(发色、瞳色、表情、眼神、气质、种族变化等)必须体现在 stage_tags 中,并在 drop_fixed 中列出被替换掉的固定标签(措辞与固定 tags 一致或接近)。\n- 本阶段描述与固定 tags 无冲突时,drop_fixed 为 []。\n\n本阶段描述:\n{theme}`
846
- const rendered = template.replace(/\{theme\}/g, userPrompt)
847
- try {
848
- const text = await llmChat({ system: rendered, user: userPrompt, maxTokens: Math.min(parseInt(cfg.llmMaxTokens) || 700, 900) })
849
- const data = extractSeriesOptimizeJson(text)
850
- if (data && String(data.stage_tags || '').trim()) {
851
- const drops = Array.isArray(data.drop_fixed) ? data.drop_fixed.map(String).filter(Boolean) : []
852
- return { ok: true, prompt: String(data.stage_tags).trim(), drops, reason: '' }
853
- }
854
- // JSON 解析失败:把整段文本当作阶段 tags,不剔除固定标签
855
- return { ok: true, prompt: text, drops: [], reason: '' }
856
- } catch (e) {
857
- const reason = String(e && e.message || e)
858
- logger.warn(`连续图阶段优化失败:${reason}`)
859
- return { ok: false, prompt: userPrompt, drops: [], reason }
860
- }
861
- }
862
-
863
848
  // ---------------- 提示词组装 ----------------
864
849
  // fixedOverride:undefined=按名称自动匹配固定角色;'skip'=不注入固定角色;字符串=直接使用该字符串作为固定角色 tags
865
- function composePrompt(userPrompt, raw, fixedOverride) {
850
+ async function composePrompt(userPrompt, raw, fixedOverride) {
866
851
  if (raw) return { prompt: userPrompt, degraded: false }
867
852
  const parts = []
868
853
  if (cfg.qualityPrefix) parts.push(String(cfg.qualityPrefix).trim())
@@ -871,7 +856,7 @@ exports.apply = async function apply(ctx, cfg) {
871
856
  } else if (typeof fixedOverride === 'string') {
872
857
  if (String(fixedOverride).trim()) parts.push(String(fixedOverride).trim())
873
858
  } else {
874
- for (const [name, tags] of Object.entries(parsePresetList(cfg.fixedCharacters))) {
859
+ for (const { name, tags } of await fixedCharacterRows()) {
875
860
  if (name && userPrompt.includes(name)) {
876
861
  parts.push(tags)
877
862
  break
@@ -931,7 +916,7 @@ exports.apply = async function apply(ctx, cfg) {
931
916
  // 多人规划:让 LLM 输出结构化场景 JSON(2-4 人)。
932
917
  async function generateMultiPersonPlan(prompt) {
933
918
  const mentioned = {}
934
- for (const [name, tags] of Object.entries(parsePresetList(cfg.fixedCharacters))) {
919
+ for (const { name, tags } of await fixedCharacterRows()) {
935
920
  if (name && prompt.includes(name)) mentioned[name] = tags
936
921
  }
937
922
  const planPrompt = buildMultiPersonPlanPrompt(prompt, mentioned)
@@ -977,7 +962,7 @@ exports.apply = async function apply(ctx, cfg) {
977
962
  }
978
963
 
979
964
  // 多人最终提示词组装:count/common tags + 角色块 + 互动 + 构图。
980
- function buildMultiPersonFinalPrompt(plan, prompt) {
965
+ async function buildMultiPersonFinalPrompt(plan, prompt) {
981
966
  const aliases = ['Character A', 'Character B', 'Character C', 'Character D']
982
967
  const characterCount = plan.characters.length
983
968
  const characterRoles = []
@@ -993,7 +978,7 @@ exports.apply = async function apply(ctx, cfg) {
993
978
 
994
979
  const usedFixedNames = new Set()
995
980
  const fixedGenders = []
996
- const configuredChars = parsePresetList(cfg.fixedCharacters)
981
+ const configuredChars = Object.fromEntries((await fixedCharacterRows()).map(({ name, tags }) => [name, tags]))
997
982
  for (let index = 0; index < plan.characters.length; index++) {
998
983
  const character = plan.characters[index]
999
984
  let fixedName = ''
@@ -1162,11 +1147,11 @@ exports.apply = async function apply(ctx, cfg) {
1162
1147
 
1163
1148
  // 视觉校验(anima_verify + generation_verifier 移植):对生成的图片跑视觉 LLM,
1164
1149
  // 不合格则用相同提示词重试(最多 multiCandidateCount 张),按多候选规则挑选并返回结果。
1165
- async function verifyGeneratedImages(session, images, userRequest, prompt, size, planCount, unet, negativePrompt) {
1150
+ async function verifyGeneratedImages(session, images, userRequest, prompt, size, planCount, unet, negativePrompt) {
1166
1151
  const verifyBaseUrl = String(cfg.verifyLlmBaseUrl || '').trim()
1167
1152
  const verifyModel = String(cfg.verifyLlmModel || '').trim()
1168
1153
  if (!verifyBaseUrl || !verifyModel) {
1169
- return { ok: true, degraded: true, message: '', verdict: null, outputs: images, prompt, negativePrompt }
1154
+ return { ok: true, degraded: true, message: '', verdict: null, outputs: images, prompt, negativePrompt }
1170
1155
  }
1171
1156
  const passScore = Math.max(0, Math.min(10, parseInt(cfg.multiVerifyPassScore) || 6))
1172
1157
  const candidateCount = Math.max(1, Math.min(3, parseInt(cfg.multiCandidateCount) || 2))
@@ -1174,10 +1159,10 @@ exports.apply = async function apply(ctx, cfg) {
1174
1159
  const systemPrompt = buildVerifySystemPrompt(true, planCount)
1175
1160
  const candidates = []
1176
1161
  let lastVerdict = null
1177
- let retries = 0
1178
- let currentImages = images
1179
- let currentPrompt = prompt
1180
- let currentNegativePrompt = negativePrompt
1162
+ let retries = 0
1163
+ let currentImages = images
1164
+ let currentPrompt = prompt
1165
+ let currentNegativePrompt = negativePrompt
1181
1166
 
1182
1167
  async function verifyOnce(imgs, userReq) {
1183
1168
  const controller = new AbortController()
@@ -1275,15 +1260,15 @@ exports.apply = async function apply(ctx, cfg) {
1275
1260
  data = extractVerifyJson(reply)
1276
1261
  } catch (e) {
1277
1262
  logger.warn(`多人视觉校验失败:${e.message}`)
1278
- return { ok: true, degraded: true, message: session.text('.multi-verify-error', [String(e && e.message || e)]), verdict: null, outputs: images, prompt: currentPrompt, negativePrompt: currentNegativePrompt }
1263
+ return { ok: true, degraded: true, message: session.text('.multi-verify-error', [String(e && e.message || e)]), verdict: null, outputs: images, prompt: currentPrompt, negativePrompt: currentNegativePrompt }
1279
1264
  }
1280
1265
  if (!data) {
1281
1266
  logger.warn(`多人视觉校验返回无法解析:${reply.slice(0, 200)}`)
1282
- return { ok: true, degraded: true, message: '', verdict: null, outputs: images, prompt: currentPrompt, negativePrompt: currentNegativePrompt }
1267
+ return { ok: true, degraded: true, message: '', verdict: null, outputs: images, prompt: currentPrompt, negativePrompt: currentNegativePrompt }
1283
1268
  }
1284
1269
  const verdict = verdictFromData(data)
1285
1270
  verdict.skipped = false
1286
- candidates.push({ outputs: currentImages, verdict, prompt: currentPrompt, negativePrompt: currentNegativePrompt })
1271
+ candidates.push({ outputs: currentImages, verdict, prompt: currentPrompt, negativePrompt: currentNegativePrompt })
1287
1272
  selectedOutputs = currentImages
1288
1273
  selectedVerdict = verdict
1289
1274
 
@@ -1297,13 +1282,13 @@ exports.apply = async function apply(ctx, cfg) {
1297
1282
  if (hint) {
1298
1283
  currentPrompt = `${userRequest}\n【上次问题,请修正】${hint}`
1299
1284
  }
1300
- const regen = await runComfyGenerate(currentPrompt, size, { unet, negativePrompt: currentNegativePrompt })
1285
+ const regen = await runComfyGenerate(currentPrompt, size, { unet, negativePrompt: currentNegativePrompt })
1301
1286
  if (!regen.ok) {
1302
1287
  logger.warn(`多人校验重试生成失败:${regen.message}`)
1303
1288
  break
1304
1289
  }
1305
- currentImages = regen.outputs
1306
- currentNegativePrompt = regen.negativePrompt || currentNegativePrompt
1290
+ currentImages = regen.outputs
1291
+ currentNegativePrompt = regen.negativePrompt || currentNegativePrompt
1307
1292
  }
1308
1293
 
1309
1294
  // 多候选挑选
@@ -1314,7 +1299,7 @@ exports.apply = async function apply(ctx, cfg) {
1314
1299
  selectedOutputs = best.outputs
1315
1300
  selectedVerdict = best.verdict
1316
1301
  if (!multiAccepted && !cfg.multiSendDegradedCandidate) {
1317
- return { ok: false, discarded: true, message: session.text('.multi-verify-discarded'), verdict: selectedVerdict, outputs: [], prompt: best.prompt, negativePrompt: best.negativePrompt }
1302
+ return { ok: false, discarded: true, message: session.text('.multi-verify-discarded'), verdict: selectedVerdict, outputs: [], prompt: best.prompt, negativePrompt: best.negativePrompt }
1318
1303
  }
1319
1304
  const noteParts = []
1320
1305
  if (multiAccepted) {
@@ -1323,7 +1308,7 @@ exports.apply = async function apply(ctx, cfg) {
1323
1308
  noteParts.push(session.text('.multi-verify-degraded', selectedVerdict.issues.length ? '(' + selectedVerdict.issues.join(';').slice(0, 80) + ')' : ''))
1324
1309
  }
1325
1310
  if (retries) noteParts.push(session.text('.multi-verify-failed', [selectedVerdict.issues.length ? ':' + selectedVerdict.issues.join(';').slice(0, 80) : '', retries]))
1326
- return { ok: true, degraded: false, message: noteParts.join('\n'), verdict: selectedVerdict, outputs: selectedOutputs, prompt: best.prompt, negativePrompt: best.negativePrompt }
1311
+ return { ok: true, degraded: false, message: noteParts.join('\n'), verdict: selectedVerdict, outputs: selectedOutputs, prompt: best.prompt, negativePrompt: best.negativePrompt }
1327
1312
  }
1328
1313
 
1329
1314
  function buildVerifySystemPrompt(multiPerson, planCount) {
@@ -1336,7 +1321,7 @@ exports.apply = async function apply(ctx, cfg) {
1336
1321
  }
1337
1322
 
1338
1323
  // 共享批量执行器:一次性扣除总价,逐张生成,单张失败只退该张单价。
1339
- // runOne(i) 需返回 { ok, outputs, seed, prompt, negativePrompt, message? };返回数组为多张输出(如视觉校验候选)。
1324
+ // runOne(i) 需返回 { ok, outputs, seed, prompt, negativePrompt, message? };返回数组为多张输出(如视觉校验候选)。
1340
1325
  async function executeBatch(USERID, isAdmin, count, unitPrice, runOne) {
1341
1326
  const results = []
1342
1327
  let successCount = 0
@@ -1350,7 +1335,7 @@ exports.apply = async function apply(ctx, cfg) {
1350
1335
  const outputs = Array.isArray(item.outputs) ? item.outputs : (item.outputs ? [item.outputs] : [])
1351
1336
  if (item.ok && outputs.length) {
1352
1337
  successCount += 1
1353
- results.push({ i, ok: true, outputs, seed: item.seed, prompt: item.prompt || '', negativePrompt: item.negativePrompt || '', note: item.note || '' })
1338
+ results.push({ i, ok: true, outputs, seed: item.seed, prompt: item.prompt || '', negativePrompt: item.negativePrompt || '', note: item.note || '' })
1354
1339
  } else {
1355
1340
  if (!isAdmin) await refundP(USERID, unitPrice)
1356
1341
  if (cfg.outputLogs) logger.warn(`批量第 ${i + 1} 张生成失败(${USERID}):${item.message || '无输出'}`)
@@ -1407,7 +1392,7 @@ exports.apply = async function apply(ctx, cfg) {
1407
1392
  const plan = planResult.plan
1408
1393
 
1409
1394
  // 组装最终提示词
1410
- const built = buildMultiPersonFinalPrompt(plan, text)
1395
+ const built = await buildMultiPersonFinalPrompt(plan, text)
1411
1396
  if (!built.ok) {
1412
1397
  return session.text('.multi-usage') + '\n(多人规划失败:' + built.error + ')'
1413
1398
  }
@@ -1431,10 +1416,9 @@ exports.apply = async function apply(ctx, cfg) {
1431
1416
  // 即时反馈
1432
1417
  const notice = []
1433
1418
  if (parsedBatch.clamped) notice.push(session.text('.batch-limit', [count]))
1434
- const feedback = feedbackBase(session, { firstPosition, count, totalPrice: count * price, isAdmin })
1435
- notice.push(...feedback.notices)
1436
- await sendNotices(session, notice)
1437
- await sendNotices(session, [feedback.chargeNotice], { quote: true })
1419
+ const feedback = feedbackBase(session, { firstPosition, count })
1420
+ notice.push(...feedback.notices)
1421
+ await sendNotices(session, notice)
1438
1422
 
1439
1423
  // 单张生成 +(可选)视觉校验
1440
1424
  const runOne = async (i) => {
@@ -1444,27 +1428,27 @@ exports.apply = async function apply(ctx, cfg) {
1444
1428
  } else {
1445
1429
  try { result = await runComfyGenerate(finalPrompt, size, { negativePrompt: multiNegative, unet }) } catch (e) { result = { ok: false, message: `生成失败:${e.message}` } }
1446
1430
  }
1447
- if (!result.ok || !result.outputs || !result.outputs.length) return result
1448
- if (!cfg.multiVerifyEnabled) {
1449
- return { ok: true, outputs: result.outputs, seed: result.seed, prompt: finalPrompt, negativePrompt: result.negativePrompt, note: session.text('.multi-degraded', ['(未启用校验或未配置视觉模型)']) }
1450
- }
1451
- const verified = await verifyGeneratedImages(session, result.outputs, text, finalPrompt, size, plan.characters.length, unet, result.negativePrompt || multiNegative)
1452
- if (!verified.ok) return { ok: false, message: verified.message }
1453
- return { ok: true, outputs: verified.outputs, seed: result.seed, prompt: verified.prompt || finalPrompt, negativePrompt: verified.negativePrompt || result.negativePrompt || multiNegative, note: verified.message || '' }
1431
+ if (!result.ok || !result.outputs || !result.outputs.length) return result
1432
+ if (!cfg.multiVerifyEnabled) {
1433
+ return { ok: true, outputs: result.outputs, seed: result.seed, prompt: finalPrompt, negativePrompt: result.negativePrompt, note: session.text('.multi-degraded', ['(未启用校验或未配置视觉模型)']) }
1434
+ }
1435
+ const verified = await verifyGeneratedImages(session, result.outputs, text, finalPrompt, size, plan.characters.length, unet, result.negativePrompt || multiNegative)
1436
+ if (!verified.ok) return { ok: false, message: verified.message }
1437
+ return { ok: true, outputs: verified.outputs, seed: result.seed, prompt: verified.prompt || finalPrompt, negativePrompt: verified.negativePrompt || result.negativePrompt || multiNegative, note: verified.message || '' }
1454
1438
  }
1455
1439
 
1456
1440
  const { results, successCount } = await executeBatch(USERID, isAdmin, count, price, runOne)
1457
1441
 
1458
- // 汇总
1459
- const allOutputs = []
1460
- const forwardOutputs = []
1461
- const notes = []
1442
+ // 汇总
1443
+ const allOutputs = []
1444
+ const forwardOutputs = []
1445
+ const notes = []
1462
1446
  const seeds = []
1463
1447
  const failures = []
1464
1448
  for (const item of results) {
1465
- if (item.ok) {
1466
- allOutputs.push(...item.outputs)
1467
- forwardOutputs.push(...item.outputs.map(src => ({ src, prompt: item.prompt || finalPrompt, negativePrompt: item.negativePrompt })))
1449
+ if (item.ok) {
1450
+ allOutputs.push(...item.outputs)
1451
+ forwardOutputs.push(...item.outputs.map(src => ({ src, prompt: item.prompt || finalPrompt, negativePrompt: item.negativePrompt })))
1468
1452
  if (item.seed != null) seeds.push(item.seed)
1469
1453
  if (item.note) notes.push(item.note)
1470
1454
  } else {
@@ -1472,6 +1456,14 @@ exports.apply = async function apply(ctx, cfg) {
1472
1456
  }
1473
1457
  }
1474
1458
 
1459
+ const chargeNotice = buildChargeNotice({
1460
+ isAdmin,
1461
+ totalPrice: successCount * price,
1462
+ unetName: unet,
1463
+ seeds,
1464
+ })
1465
+ await sendNotices(session, [chargeNotice], { quote: true })
1466
+
1475
1467
  if (!allOutputs.length) {
1476
1468
  if (cfg.outputLogs) logger.warn(`多人生成全部失败(${USERID}),已按张退款`)
1477
1469
  return session.text('.generate-failed', ['全部失败(已按张退款)'])
@@ -1479,185 +1471,10 @@ exports.apply = async function apply(ctx, cfg) {
1479
1471
 
1480
1472
  if (cfg.outputLogs) logger.success(`${USERID} 多人生成成功 ${successCount}/${count} 张`)
1481
1473
 
1482
- // 发图:合并转发,不引用原指令
1483
- await sendImagesAsForward(session, forwardOutputs)
1484
-
1485
- const reply = []
1486
- if (count > 1) {
1487
- reply.push(session.text('.generate-ok-batch', [count * price, successCount, seeds.join(', ') || '-']))
1488
- } else {
1489
- reply.push(session.text('.generate-ok', [price, seeds[0] || '-']))
1490
- }
1491
- if (notes.length) reply.push(notes.join('\n'))
1492
- if (failures.length) reply.push(session.text('.batch-partial', [successCount, count, failures.length, failures.join(';')]))
1493
- return reply.filter(Boolean).join('\n')
1494
- }
1474
+ // 发图:合并转发,不引用原指令
1475
+ await sendImagesAsForward(session, forwardOutputs)
1495
1476
 
1496
- // 连续图/过程图主流程:同一角色多阶段(固定身份 + 阶段描述 + 全阶段共用同一 seed 保证一致性)
1497
- // 语法:连续 <角色>:<阶段1> → <阶段2> → ... 或 连续 <角色>:<阶段1>|<阶段2>|...
1498
- // 询问是否使用 LLM 优化,返回 'yes' / 'no' / 'cancel' / null(无法交互或超时)
1499
- async function askSeriesLLMUse(session) {
1500
- if (typeof session.prompt !== 'function') return null
1501
- await session.send(session.text('.series-llm-ask'))
1502
- const reply = await session.prompt((cfg.seriesAskTimeout || 60) * 1000).catch(() => null)
1503
- const ans = String((reply && (reply.content != null ? reply.content : reply)) || '').trim()
1504
- if (/^(是|1|①|用|使用|使用llm|yes|y)$/i.test(ans)) return 'yes'
1505
- if (/^(否|2|②|不用|不使用|不使用llm|不优化|不用llm|no|n)$/i.test(ans)) return 'no'
1506
- if (/^(取消|3|③|算了|不生成|cancel|c)$/i.test(ans)) return 'cancel'
1507
- if (!ans) return null
1508
- await session.send(session.text('.series-llm-invalid'))
1509
- return askSeriesLLMUse(session)
1510
- }
1511
-
1512
- async function handleGenerateSeries(session, rawText) {
1513
- const USERID = session.userId
1514
- const isAdmin = isAdminUser(session)
1515
- const unet = await resolveUnet(USERID)
1516
- const price = Math.max(0, parseInt(cfg.price) || 500)
1517
-
1518
- // 阶段分隔符:箭头 / 管道
1519
- const STAGE_SEP = /→|➔|➜|←|↔|=>|->|⇒|\|/
1520
-
1521
- // 尺寸解析(连续图默认横图);固定 seed:全阶段共用,支持 --seed 覆盖(含 --seed: 冒号形式)
1522
- const seedInfo = parseSeed(String(rawText || ''))
1523
- const seed = seedInfo.seed != null ? seedInfo.seed : crypto.randomInt(1, 2 ** 32 - 1)
1524
- const allowed = parseAllowedSizes()
1525
- const parsedSize = parseGenerationSize(seedInfo.prompt, allowed)
1526
- if (parsedSize.error) return parsedSize.error
1527
- let size = parsedSize.size
1528
- if (!size && allowed.length) {
1529
- size = allowed.reduce((best, s) => {
1530
- const a = Math.abs(s[0] / s[1] - 16 / 9)
1531
- const b = Math.abs(best[0] / best[1] - 16 / 9)
1532
- return a < b ? s : best
1533
- })
1534
- }
1535
- const sizeCleanedPrompt = parsedSize.prompt
1536
-
1537
- // 提取身份与阶段文本(角色:阶段1 → 阶段2)
1538
- let identity = ''
1539
- let stageText = String(sizeCleanedPrompt || '').trim()
1540
- const colonMatch = stageText.match(/^(.+?)[::]\s*(.+)$/)
1541
- if (colonMatch) {
1542
- identity = colonMatch[1].trim()
1543
- stageText = colonMatch[2].trim()
1544
- }
1545
- const stages = stageText
1546
- .split(STAGE_SEP)
1547
- .map(s => s.trim().replace(/^[\s,,、;;::]+|[\s,,、;;::]+$/g, '').replace(/\s+/g, ' '))
1548
- .filter(Boolean)
1549
- if (!stages.length) return session.text('.series-usage')
1550
-
1551
- const maxStages = Math.max(1, parseInt(cfg.batchMax) || 4)
1552
- const count = Math.min(stages.length, maxStages)
1553
- const clamped = stages.length > count
1554
- const stageList = stages.slice(0, count)
1555
-
1556
- // P 点校验(按总价)
1557
- const pcheck = await precheckPoints(session, USERID, isAdmin, count * price)
1558
- if (!pcheck.ok) return pcheck.message
1559
-
1560
- // ComfyUI 就绪
1561
- const ready = await ensureComfyuiReady()
1562
- if (!ready.ok) return ready.message
1563
-
1564
- // 询问是否使用 LLM 优化:是=用 LLM / 否=直接使用原始描述 / 取消=不生成。
1565
- // 未配置 LLM 或平台不支持交互式询问时,沿用原有「配置了 LLM 就逐阶段优化」行为。
1566
- let useLLM = true
1567
- if (cfg.llmModel && cfg.llmBaseUrl && typeof session.prompt === 'function') {
1568
- const choice = await askSeriesLLMUse(session)
1569
- if (choice === 'cancel') return ''
1570
- if (choice === 'no') useLLM = false
1571
- }
1572
-
1573
- // 组装各阶段提示词:身份(含固定角色 tags)+ 阶段描述。
1574
- // 使用 LLM 时逐阶段优化(不受 promptOptimizeEnabled 限制),因为 anima 是
1575
- // Danbooru-tag 模型,中文阶段描述必须转成 tags 才能体现在画面里;
1576
- // 用户选择「否」或未配置 LLM 时,直接使用各阶段原始描述(不注入身份前缀)。
1577
- const fixedChars = parsePresetList(cfg.fixedCharacters)
1578
- const stagePrompts = []
1579
- let degradedStages = 0
1580
- for (const st of stageList) {
1581
- const base = identity ? `${identity},${st}` : st
1582
- const result = useLLM
1583
- ? await optimizeSeriesStage(base, identity)
1584
- : { ok: true, prompt: st, drops: [], reason: 'user_skipped_llm' }
1585
- if (!result.ok) degradedStages += 1
1586
- const stageTags = result.prompt || base
1587
- const drops = result.drops || []
1588
- // 始终注入固定角色 tags 作为身份锚点(保证角色名/种族/尖耳朵出现),
1589
- // 阶段描述里被明确改变的外观由 drop 列表剔除,避免被固定默认值拉回。
1590
- const identityTags = identity ? filterFixedTags(fixedChars[identity] || '', drops) : ''
1591
- const anchor = stageTags
1592
- const composed = composePrompt(anchor, false, identityTags)
1593
- stagePrompts.push(composed.prompt)
1594
- }
1595
-
1596
- // 扣 P 点(一次性扣除总价)
1597
- if (!isAdmin) {
1598
- const saving = await deductP(USERID, count * price)
1599
- if (cfg.outputLogs) logger.info(`[p-draw] ${USERID} 连续图已扣除 ${count * price} P 点(${count} 阶段 × ${price},seed=${seed}),余额 ${saving - count * price}`)
1600
- }
1601
-
1602
- // 队列:预排队全部阶段(先查容量再入队)
1603
- const queued = await enqueueBatch(count, (i) => runComfyGenerate(stagePrompts[i], size, { seed, unet }), { USERID, isAdmin, totalPrice: count * price })
1604
- if (!queued.ok) return queued.message
1605
- const queuedTasks = queued.tasks
1606
- const firstPosition = queued.firstPosition
1607
-
1608
- // 即时反馈
1609
- const notice = []
1610
- if (clamped) notice.push(session.text('.batch-limit', [count]))
1611
- if (identity && fixedChars[identity]) notice.push(`已固定角色「${identity}」的身份 tags,各阶段外观将保持一致。`)
1612
- if (!useLLM) notice.push(session.text('.series-no-llm'))
1613
- if (degradedStages) notice.push(session.text('.prompt-degraded', ['(连续图阶段优化失败,已使用原始描述)']))
1614
- const feedback = feedbackBase(session, { firstPosition, count, totalPrice: count * price, isAdmin })
1615
- notice.push(...feedback.notices)
1616
- await sendNotices(session, notice)
1617
- await sendNotices(session, [feedback.chargeNotice], { quote: true })
1618
-
1619
- // 单阶段生成(共用 seed)
1620
- const runOne = async (i) => {
1621
- let result
1622
- if (cfg.queueEnabled) {
1623
- try { result = await queuedTasks[i] } catch (e) { result = { ok: false, message: `生成失败:${e.message}` } }
1624
- } else {
1625
- try { result = await runComfyGenerate(stagePrompts[i], size, { seed, unet }) } catch (e) { result = { ok: false, message: `生成失败:${e.message}` } }
1626
- }
1627
- return result
1628
- }
1629
-
1630
- const { results, successCount } = await executeBatch(USERID, isAdmin, count, price, runOne)
1631
-
1632
- // 汇总
1633
- const allOutputs = []
1634
- const forwardOutputs = []
1635
- const seeds = []
1636
- const failures = []
1637
- for (const item of results) {
1638
- if (item.ok) {
1639
- allOutputs.push(...item.outputs)
1640
- forwardOutputs.push(...item.outputs.map(src => ({ src, prompt: item.prompt || stagePrompts[item.i] || '', negativePrompt: item.negativePrompt })))
1641
- if (item.seed != null) seeds.push(item.seed)
1642
- } else {
1643
- failures.push(`第 ${item.i + 1} 阶段:${item.message}`)
1644
- }
1645
- }
1646
-
1647
- if (!allOutputs.length) {
1648
- if (cfg.outputLogs) logger.warn(`连续图全部失败(${USERID}),已按阶段退款`)
1649
- return session.text('.generate-failed', ['全部失败(已按阶段退款)'])
1650
- }
1651
-
1652
- if (cfg.outputLogs) logger.success(`${USERID} 连续图生成成功 ${successCount}/${count} 阶段(seed=${seed})`)
1653
-
1654
- // 发图:合并转发,不引用原指令
1655
- await sendImagesAsForward(session, forwardOutputs)
1656
-
1657
- const reply = []
1658
- reply.push(session.text('.series-ok', [count * price, successCount, seed]))
1659
- if (failures.length) reply.push(session.text('.batch-partial', [successCount, count, failures.length, failures.join(';')]))
1660
- return reply.filter(Boolean).join('\n')
1477
+ return buildGenerationReply(session, { successCount, count, failures, notes })
1661
1478
  }
1662
1479
 
1663
1480
  // ---------------- 权限 ----------------
@@ -1741,7 +1558,6 @@ exports.apply = async function apply(ctx, cfg) {
1741
1558
  // 注意:更新数据里不能带主键 id,否则数据库驱动会报 cannot modify primary key
1742
1559
  function runtimeState() {
1743
1560
  return {
1744
- fixed_characters: cfg.fixedCharacters || [],
1745
1561
  artist_presets: cfg.artistPresets || [],
1746
1562
  active_artist_preset: cfg.activeArtistPreset || '',
1747
1563
  default_artist_tags: cfg.defaultArtistTags || '',
@@ -1749,20 +1565,25 @@ exports.apply = async function apply(ctx, cfg) {
1749
1565
  }
1750
1566
  }
1751
1567
 
1752
- // 启动时把数据库里保存的画师组/固定角色合并进 cfg(数据库覆盖配置,保证运行时新增不被重启丢失)
1568
+ let legacyRuntimeFixedCharacters = []
1569
+ let fixedCharactersMigrated = false
1570
+
1571
+ // 启动时加载 p_draw_config 中仍归属该表的运行时数据。
1572
+ // fixed_characters 只作为旧版本迁移输入,运行时不再覆盖 cfg.fixedCharacters。
1753
1573
  async function loadRuntimeState() {
1754
1574
  try {
1755
1575
  const rows = await ctx.database.get('p_draw_config', { id: 1 })
1756
1576
  const row = rows && rows[0]
1757
1577
  if (!row) return
1758
- if (Array.isArray(row.fixed_characters)) cfg.fixedCharacters = row.fixed_characters
1578
+ if (Array.isArray(row.fixed_characters)) legacyRuntimeFixedCharacters = row.fixed_characters
1579
+ fixedCharactersMigrated = row.fixed_characters_migrated === true
1759
1580
  if (Array.isArray(row.artist_presets)) cfg.artistPresets = row.artist_presets
1760
1581
  if (row.active_artist_preset) cfg.activeArtistPreset = row.active_artist_preset
1761
1582
  if (row.default_artist_tags != null) cfg.defaultArtistTags = row.default_artist_tags
1762
1583
  if (row.user_models && typeof row.user_models === 'object') cfg.userModels = row.user_models
1763
- if (cfg.outputLogs) logger.info(`[p-draw] 已加载运行时配置(画师组 ${(cfg.artistPresets || []).length} 个,固定角色 ${(cfg.fixedCharacters || []).length} 个,模型偏好 ${Object.keys(cfg.userModels || {}).length} 个)`)
1584
+ if (cfg.outputLogs) logger.info(`[p-draw] 已加载运行时配置(画师组 ${(cfg.artistPresets || []).length} 个,模型偏好 ${Object.keys(cfg.userModels || {}).length} 个)`)
1764
1585
  } catch (e) {
1765
- logger.warn(`读取运行时配置失败(画师组/固定角色可能未持久化):${e.message}`)
1586
+ logger.warn(`读取运行时配置失败(画师组或模型偏好可能未持久化):${e.message}`)
1766
1587
  }
1767
1588
  }
1768
1589
 
@@ -1781,6 +1602,29 @@ exports.apply = async function apply(ctx, cfg) {
1781
1602
  }
1782
1603
  }
1783
1604
 
1605
+ async function migrateFixedCharacters() {
1606
+ if (fixedCharactersMigrated) return
1607
+ const existingRows = await fixedCharacterRows()
1608
+ const existingNames = new Set(existingRows.map(row => row.name))
1609
+ const legacyEntries = [...(cfg.fixedCharacters || []), ...legacyRuntimeFixedCharacters]
1610
+ for (const entry of legacyEntries) {
1611
+ const parsed = parseNameTags(entry)
1612
+ if (!parsed || existingNames.has(parsed.name)) continue
1613
+ await ctx.database.create('p_draw_fixed_characters', {
1614
+ name: parsed.name,
1615
+ tags: parsed.tags,
1616
+ })
1617
+ existingNames.add(parsed.name)
1618
+ }
1619
+ const existingConfig = await ctx.database.get('p_draw_config', { id: 1 })
1620
+ if (existingConfig && existingConfig[0]) {
1621
+ await ctx.database.set('p_draw_config', { id: 1 }, { fixed_characters_migrated: true })
1622
+ } else {
1623
+ await ctx.database.create('p_draw_config', { id: 1, ...runtimeState(), fixed_characters_migrated: true })
1624
+ }
1625
+ fixedCharactersMigrated = true
1626
+ }
1627
+
1784
1628
  function normalizeTagText(text) {
1785
1629
  const tags = []
1786
1630
  for (const tag of String(text || '').split(',')) {
@@ -1813,13 +1657,7 @@ exports.apply = async function apply(ctx, cfg) {
1813
1657
  return await diagnoseText(session)
1814
1658
  }
1815
1659
 
1816
- // 连续图指令:p-draw 连续 <角色>:<阶段1> → <阶段2>
1817
- const seriesMatch = text.match(/^连续\s*(.*)$/)
1818
- if (seriesMatch) {
1819
- return await handleGenerateSeries(session, seriesMatch[1].trim())
1820
- }
1821
-
1822
- // 多人指令:p-draw 多人 <描述>
1660
+ // 多人指令:p-draw 多人 <描述>
1823
1661
  const multiMatch = text.match(/^(?:多人|多人生图|双人|三人|群像)\s*(.*)$/)
1824
1662
  if (multiMatch) {
1825
1663
  return await handleGenerateMulti(session, multiMatch[1].trim())
@@ -1904,12 +1742,27 @@ exports.apply = async function apply(ctx, cfg) {
1904
1742
  if (addCharacter) {
1905
1743
  const parsed = parseNameTags(addCharacter[1])
1906
1744
  if (!parsed) return session.text('.character-format')
1907
- const chars = parsePresetList(cfg.fixedCharacters)
1908
- chars[parsed.name] = normalizeTagText(parsed.tags)
1909
- await persistConfig('fixedCharacters', Object.entries(chars).map(([n, t]) => `${n}=${t}`))
1745
+ const tags = normalizeTagText(parsed.tags)
1746
+ const existing = (await fixedCharacterRows({ name: parsed.name }))[0]
1747
+ if (existing) await ctx.database.set('p_draw_fixed_characters', { id: existing.id }, { name: parsed.name, tags })
1748
+ else await ctx.database.create('p_draw_fixed_characters', { name: parsed.name, tags })
1910
1749
  if (cfg.outputLogs) logger.success(`${USERID} 添加固定角色 ${parsed.name}`)
1911
1750
  return session.text('.character-created', [parsed.name, parsed.tags])
1912
1751
  }
1752
+ if (text.match(/^(?:查看|列出|显示)\s*(?:固定)?\s*角色$|^(?:固定)?\s*角色列表$/)) {
1753
+ const rows = await fixedCharacterRows()
1754
+ if (!rows.length) return '固定角色:无'
1755
+ return ['固定角色:', ...rows.map(row => `- ${row.name}:${row.tags}`)].join('\n')
1756
+ }
1757
+ const deleteCharacter = text.match(/^(?:删除|移除)\s*(?:固定)?\s*角色\s*(.*)$/)
1758
+ if (deleteCharacter) {
1759
+ const name = String(deleteCharacter[1]).trim()
1760
+ if (!name) return session.text('.character-delete-format')
1761
+ const existing = (await fixedCharacterRows({ name }))[0]
1762
+ if (!existing) return session.text('.character-not-found', [name])
1763
+ await ctx.database.remove('p_draw_fixed_characters', { id: existing.id })
1764
+ return session.text('.character-deleted', [name])
1765
+ }
1913
1766
 
1914
1767
  // 模型切换:p-draw 模型 <名称>(查看)/ p-draw 模型 默认(重置)
1915
1768
  const modelMatch = text.match(/^(?:切换)?\s*模型\s*(.*)$/)
@@ -1944,10 +1797,10 @@ exports.apply = async function apply(ctx, cfg) {
1944
1797
  return await handleGenerate(session, text)
1945
1798
  })
1946
1799
 
1947
- async function handleGenerate(session, rawText) {
1948
- const USERID = session.userId
1949
- const isAdmin = isAdminUser(session)
1950
- const unet = await resolveUnet(USERID)
1800
+ async function handleGenerate(session, rawText) {
1801
+ const USERID = session.userId
1802
+ const isAdmin = isAdminUser(session)
1803
+ const unet = await resolveUnet(USERID)
1951
1804
  if (cfg.outputLogs) {
1952
1805
  logger.info(`[p-draw] 请求 userId=${USERID} isAdmin=${isAdmin} adminUsers=${JSON.stringify(cfg.adminUsers || [])} normalizeId=${normalizeId(USERID)}`)
1953
1806
  }
@@ -1961,21 +1814,21 @@ exports.apply = async function apply(ctx, cfg) {
1961
1814
  const parsedBatch = parseBatchCount(parsedSize.prompt, cfg.batchMax)
1962
1815
  // 固定种子解析(--seed:xxx / --seed xxx / --seed=xxx),并从提示词中剥离
1963
1816
  const parsedSeed = parseSeed(parsedBatch.prompt)
1964
- const text = parsedSeed.prompt
1965
- const seed = parsedSeed.seed
1966
- const count = parsedBatch.count
1817
+ const text = parsedSeed.prompt
1818
+ const seed = parsedSeed.seed
1819
+ const count = parsedBatch.count
1967
1820
 
1968
1821
  // P 点校验(按总价 = 张数 × 单价)
1969
1822
  // P 点校验(按总价 = 张数 × 单价)
1970
1823
  const pcheck = await precheckPoints(session, USERID, isAdmin, count * cfg.price)
1971
1824
  if (!pcheck.ok) return pcheck.message
1972
1825
 
1973
- // 原样模式
1974
- const stripped = stripRawPrefix(text)
1975
- const raw = stripped.raw
1976
- const promptSections = splitPositiveNegativePrompt(stripped.prompt)
1977
- const userPrompt = promptSections.positive
1978
- const userNegativePrompt = promptSections.negative
1826
+ // 原样模式
1827
+ const stripped = stripRawPrefix(text)
1828
+ const raw = stripped.raw
1829
+ const promptSections = splitPositiveNegativePrompt(stripped.prompt)
1830
+ const userPrompt = promptSections.positive
1831
+ const userNegativePrompt = promptSections.negative
1979
1832
  if (!userPrompt) return session.text('.no-prompt')
1980
1833
 
1981
1834
  // ComfyUI 就绪
@@ -1997,13 +1850,13 @@ exports.apply = async function apply(ctx, cfg) {
1997
1850
  const tokenOpt = !globalOpt && !isAdmin && cfg.llmModel && cfg.llmBaseUrl
1998
1851
  if (globalOpt) {
1999
1852
  // 全局优化开启:一次优化,整批复用同一提示词
2000
- const optimized = await optimizePrompt(session, userPrompt, false)
1853
+ const optimized = await optimizePrompt(session, userPrompt, false)
2001
1854
  finalPrompt = optimized.prompt
2002
1855
  degraded = !optimized.ok
2003
1856
  optimizedReason = optimized.reason || ''
2004
1857
  } else if (adminOpt) {
2005
1858
  // 管理员在全局关闭时也免费优化(不耗券)
2006
- const optimized = await optimizePrompt(session, userPrompt, true)
1859
+ const optimized = await optimizePrompt(session, userPrompt, true)
2007
1860
  finalPrompt = optimized.prompt
2008
1861
  degraded = !optimized.ok
2009
1862
  optimizedReason = optimized.reason || ''
@@ -2044,7 +1897,7 @@ exports.apply = async function apply(ctx, cfg) {
2044
1897
  }
2045
1898
  // 非按张优化模式:直接拼好整批复用的提示词
2046
1899
  if (!perImageOptimize) {
2047
- const composed = composePrompt(finalPrompt, raw)
1900
+ const composed = await composePrompt(finalPrompt, raw)
2048
1901
  finalPrompt = appendInlineProtectedTags(composed.prompt, userPrompt, raw)
2049
1902
  degraded = degraded || composed.degraded
2050
1903
  }
@@ -2055,11 +1908,11 @@ exports.apply = async function apply(ctx, cfg) {
2055
1908
  if (cfg.outputLogs) logger.info(`[p-draw] ${USERID} 已扣除 ${count * cfg.price} P 点(${count} 张 × ${cfg.price}),余额 ${saving - count * cfg.price}`)
2056
1909
  }
2057
1910
 
2058
- // 用户手写了 negative: 区块时,默认负面词仍保留;用户 tag 只补充未出现的部分。
2059
- const generationOverrides = Object.assign(
2060
- { unet, seed },
2061
- userNegativePrompt ? { negativePrompt: mergeNegativePrompts(cfg.negativePrompt, userNegativePrompt) } : {},
2062
- )
1911
+ // 用户手写了 negative: 区块时,默认负面词仍保留;用户 tag 只补充未出现的部分。
1912
+ const generationOverrides = Object.assign(
1913
+ { unet, seed },
1914
+ userNegativePrompt ? { negativePrompt: mergeNegativePrompts(cfg.negativePrompt, userNegativePrompt) } : {},
1915
+ )
2063
1916
 
2064
1917
  // 性能:按张优化(perImageOptimize)时联网搜索只做一次,各图复用同一份结果
2065
1918
  const searchCache = perImageOptimize && wantsWebSearch(userPrompt) ? await webSearch(userPrompt) : null
@@ -2068,11 +1921,11 @@ exports.apply = async function apply(ctx, cfg) {
2068
1921
  const queued = await enqueueBatch(count, async (i) => {
2069
1922
  let p = finalPrompt
2070
1923
  if (perImageOptimize) {
2071
- const optimized = await optimizePrompt(session, userPrompt, true, searchCache)
2072
- p = appendInlineProtectedTags(composePrompt(optimized.prompt || userPrompt, raw).prompt, userPrompt, raw)
1924
+ const optimized = await optimizePrompt(session, userPrompt, true, searchCache)
1925
+ p = appendInlineProtectedTags((await composePrompt(optimized.prompt || userPrompt, raw)).prompt, userPrompt, raw)
2073
1926
  }
2074
- const generated = await runComfyGenerate(p, parsedSize.size, generationOverrides)
2075
- return { ...generated, prompt: p }
1927
+ const generated = await runComfyGenerate(p, parsedSize.size, generationOverrides)
1928
+ return { ...generated, prompt: p }
2076
1929
  }, { USERID, isAdmin, totalPrice: count * cfg.price })
2077
1930
  if (!queued.ok) return queued.message
2078
1931
  const queuedTasks = queued.tasks
@@ -2095,45 +1948,52 @@ exports.apply = async function apply(ctx, cfg) {
2095
1948
  notice.push(session.text('.no-optimize', [reasons[noOptimizeReason] || noOptimizeReason]))
2096
1949
  }
2097
1950
  if (parsedBatch.clamped) notice.push(session.text('.batch-limit', [count]))
2098
- const feedback = feedbackBase(session, { firstPosition, count, totalPrice: count * cfg.price, isAdmin })
2099
- notice.push(...feedback.notices)
2100
- await sendNotices(session, notice)
2101
- await sendNotices(session, [feedback.chargeNotice], { quote: true })
1951
+ const feedback = feedbackBase(session, { firstPosition, count })
1952
+ notice.push(...feedback.notices)
1953
+ await sendNotices(session, notice)
2102
1954
 
2103
1955
  // 单张生成
2104
1956
  const runOne = async (i) => {
2105
1957
  let p = finalPrompt
2106
1958
  if (perImageOptimize) {
2107
- const optimized = await optimizePrompt(session, userPrompt, true, searchCache)
2108
- p = appendInlineProtectedTags(composePrompt(optimized.prompt || userPrompt, raw).prompt, userPrompt, raw)
1959
+ const optimized = await optimizePrompt(session, userPrompt, true, searchCache)
1960
+ p = appendInlineProtectedTags((await composePrompt(optimized.prompt || userPrompt, raw)).prompt, userPrompt, raw)
2109
1961
  }
2110
1962
  let result
2111
1963
  if (cfg.queueEnabled) {
2112
1964
  try { result = await queuedTasks[i] } catch (e) { result = { ok: false, message: `生成失败:${e.message}` } }
2113
- } else {
2114
- try { result = await runComfyGenerate(p, parsedSize.size, generationOverrides) } catch (e) { result = { ok: false, message: `生成失败:${e.message}` } }
2115
- }
2116
- if (!result.prompt) result.prompt = p
2117
- return result
1965
+ } else {
1966
+ try { result = await runComfyGenerate(p, parsedSize.size, generationOverrides) } catch (e) { result = { ok: false, message: `生成失败:${e.message}` } }
1967
+ }
1968
+ if (!result.prompt) result.prompt = p
1969
+ return result
2118
1970
  }
2119
1971
 
2120
1972
  const { results, successCount } = await executeBatch(USERID, isAdmin, count, cfg.price, runOne)
2121
1973
 
2122
- // 汇总
2123
- const allOutputs = []
2124
- const forwardOutputs = []
2125
- const seeds = []
2126
- const failures = []
2127
- for (const item of results) {
2128
- if (item.ok) {
2129
- allOutputs.push(...item.outputs)
2130
- forwardOutputs.push(...item.outputs.map(src => ({ src, prompt: item.prompt || finalPrompt, negativePrompt: item.negativePrompt })))
2131
- if (item.seed != null) seeds.push(item.seed)
1974
+ // 汇总
1975
+ const allOutputs = []
1976
+ const forwardOutputs = []
1977
+ const seeds = []
1978
+ const failures = []
1979
+ for (const item of results) {
1980
+ if (item.ok) {
1981
+ allOutputs.push(...item.outputs)
1982
+ forwardOutputs.push(...item.outputs.map(src => ({ src, prompt: item.prompt || finalPrompt, negativePrompt: item.negativePrompt })))
1983
+ if (item.seed != null) seeds.push(item.seed)
2132
1984
  } else {
2133
1985
  failures.push(`第 ${item.i + 1} 张:${item.message}`)
2134
1986
  }
2135
1987
  }
2136
1988
 
1989
+ const chargeNotice = buildChargeNotice({
1990
+ isAdmin,
1991
+ totalPrice: successCount * cfg.price,
1992
+ unetName: unet,
1993
+ seeds,
1994
+ })
1995
+ await sendNotices(session, [chargeNotice], { quote: true })
1996
+
2137
1997
  if (!allOutputs.length) {
2138
1998
  if (cfg.outputLogs) logger.warn(`生成全部失败(${USERID}),已按张退款`)
2139
1999
  return session.text('.generate-failed', ['全部失败(已按张退款)'])
@@ -2141,17 +2001,10 @@ exports.apply = async function apply(ctx, cfg) {
2141
2001
 
2142
2002
  if (cfg.outputLogs) logger.success(`${USERID} 生成成功 ${successCount}/${count} 张`)
2143
2003
 
2144
- // 发图:合并转发,不引用原指令
2145
- await sendImagesAsForward(session, forwardOutputs)
2004
+ // 发图:合并转发,不引用原指令
2005
+ await sendImagesAsForward(session, forwardOutputs)
2146
2006
 
2147
- const reply = []
2148
- if (count > 1) {
2149
- reply.push(session.text('.generate-ok-batch', [count * cfg.price, successCount, seeds.join(', ') || '-']))
2150
- } else {
2151
- reply.push(session.text('.generate-ok', [cfg.price, seeds[0] || '-']))
2152
- }
2153
- if (failures.length) reply.push(session.text('.batch-partial', [successCount, count, failures.length, failures.join(';')]))
2154
- return reply.filter(Boolean).join('\n')
2007
+ return buildGenerationReply(session, { successCount, count, failures })
2155
2008
  }
2156
2009
  // ---------------- 提示词优化券交互式确认 ----------------
2157
2010
  // 仅全局优化关闭 + 非管理员 + 已配置 LLM(tokenOpt 分支)时进入。
@@ -2281,8 +2134,9 @@ exports.apply = async function apply(ctx, cfg) {
2281
2134
  return price
2282
2135
  }
2283
2136
 
2284
- // 启动时合并数据库里保存的运行时配置(画师组/固定角色)
2137
+ // 启动时加载配置表,并将旧 fixedCharacters 数据迁入专用表。
2285
2138
  await loadRuntimeState()
2139
+ await migrateFixedCharacters()
2286
2140
 
2287
2141
  ctx.on('dispose', () => {
2288
2142
  // 清理临时文件
@@ -2295,5 +2149,5 @@ exports.apply = async function apply(ctx, cfg) {
2295
2149
  })
2296
2150
 
2297
2151
  // 暴露内部接口供自动化测试调用(Koishi 忽略 apply 返回值,不影响生产行为)
2298
- return { couponConfirmFlow, buyCouponsAndConsume, normalizeConfirm, resolveCouponPrice, sendNotices, sendImagesAsForward, executeBatch }
2152
+ return { couponConfirmFlow, buyCouponsAndConsume, normalizeConfirm, resolveCouponPrice, buildChargeNotice, buildGenerationReply, sendNotices, sendImagesAsForward, executeBatch }
2299
2153
  }