koishi-plugin-p-draw 1.2.12 → 1.3.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/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, fileURLToPath } = require('url')
7
7
 
8
8
  exports.name = 'p-draw'
9
9
 
@@ -35,155 +35,7 @@ exports.usage = `
35
35
  - 可加 \`--denoise 0.6\` 单独调整强度;未装 ControlNet/IPAdapter 时自动回退普通 img2img。
36
36
  `;
37
37
 
38
- const zhCN = {
39
- comfyuiBaseUrl: { $description: 'ComfyUI 地址' },
40
- workflow: { $description: '工作流类型(内置 anima_t2i)' },
41
- customWorkflowEnabled: { $description: '使用自定义 ComfyUI 工作流 JSON' },
42
- customWorkflowPath: { $description: '自定义工作流 JSON 路径(相对插件目录)' },
43
- customWorkflowOverrideParameters: { $description: '用插件参数覆盖自定义工作流参数' },
44
- timeout: { $description: '单次生成超时(秒)' },
45
- pollInterval: { $description: '生成状态查询间隔(秒)' },
46
- unetName: { $description: '主模型文件名' },
47
- clipName: { $description: '文本编码器文件名' },
48
- vaeName: { $description: 'VAE 文件名' },
49
- width: { $description: '默认宽度' },
50
- height: { $description: '默认高度' },
51
- allowedSizes: { $description: '可用尺寸列表(宽x高)' },
52
- steps: { $description: '采样步数' },
53
- cfg: { $description: 'CFG 强度' },
54
- samplerName: { $description: '采样器' },
55
- scheduler: { $description: '调度器' },
56
- qualityPrefix: { $description: '质量词前缀' },
57
- negativePrompt: { $description: '负面提示词' },
58
- promptOptimizeEnabled: { $description: '启用自然语言优化(需要配置下方 LLM 接口)' },
59
- llmBaseUrl: { $description: 'LLM 接口地址(OpenAI 兼容,例如 https://api.deepseek.com/v1)' },
60
- llmApiKey: { $description: 'LLM API Key' },
61
- llmModel: { $description: 'LLM 模型名(留空则不优化,原样生图)' },
62
- llmMaxTokens: { $description: 'LLM 输出上限' },
63
- webSearchEnabled: { $description: '启用联网搜索(指令里写“联网/搜索/查一下”等触发)' },
64
- tavilyApiKey: { $description: 'Tavily API Key(联网搜索用,https://tavily.com 申请)' },
65
- webSearchMaxResults: { $description: '联网搜索结果数量' },
66
- webSearchDepth: { $description: '搜索深度(basic / advanced)' },
67
- webSearchQueryTemplate: { $description: '搜索词模板({prompt} 代表用户需求)' },
68
- promptOptimizeTemplate: { $description: '自然语言优化模板(支持 {theme} {search_block} 占位符)' },
69
- fixedCharacters: { $description: '固定角色(格式:角色名=tags)' },
70
- artistPresets: { $description: '画师组(格式:名称=tags)' },
71
- activeArtistPreset: { $description: '启用的画师组名称' },
72
- defaultArtistTags: { $description: '备用画师 tags' },
73
- styleTags: { $description: '画风 tags' },
74
- queueEnabled: { $description: '启用生成队列(逐张顺序执行)' },
75
- queueMaxRequests: { $description: '队列最大任务数(0 表示不限制)' },
76
- batchMax: { $description: '单次指令最多生成的张数(支持 x3 / 3张 / --数量 3 等写法)' },
77
- price: { $description: '一张图消耗的 P 点' },
78
- multiPrice: { $description: '多人指令(p-draw 多人)单张消耗的 P 点' },
79
- couponPrice: { $description: '提示词优化券单价(P 点/张,购买询问时显示)' },
80
- couponAskTimeout: { $description: '提示词优化券确认等待时间(秒)' },
81
- img2imgDenoise: { $description: '普通以图生图(p-draw i2i)的去噪强度,越小越接近原图(建议 0.4-0.7)' },
82
- i2iMode: { $description: 'i2i 模式选择方式:ask=每次询问 / style=直接换风格(漫画化)/ ootd=直接换装换姿势 / plain=普通 img2img' },
83
- i2iAskTimeout: { $description: 'i2i 模式询问等待时间(秒)' },
84
- seriesAskTimeout: { $description: '连续图 LLM 使用确认等待时间(秒)' },
85
- i2iStyleDenoise: { $description: '换风格模式(漫画化)的去噪强度,越大风格变化越彻底(建议 0.7-0.85)' },
86
- i2iOotdDenoise: { $description: '换装换姿势模式(保留角色)的去噪强度(建议 0.5-0.6)' },
87
- i2iControlNetStrength: { $description: '换风格模式的 ControlNet 强度,越大构图锁得越死(建议 0.5-0.8)' },
88
- i2iIPAdapterPath: { $description: 'Anima IP-Adapter 模型文件路径(换装换姿势模式保脸用;需安装 comfyui-anima-ipadapter 节点并把模型路径填到这里)' },
89
- i2iIPAdapterWeight: { $description: '换装换姿势模式的 IP-Adapter 权重,越大角色特征保留越强(建议 0.6-1.0)' },
90
- controlNetModel: { $description: 'ControlNet 模型文件名(留空自动检测 Qwen/Anima 系 ControlNet;需放到 ComfyUI/models/controlnet)' },
91
- taggerEnabled: { $description: 'i2i 前自动识图(需 ComfyUI 安装 WD14 Tagger 节点与模型;识别出的标签会注入提示词优化)' },
92
- taggerModel: { $description: '识图模型名(WD14 Tagger 节点里可选模型)' },
93
- taggerThreshold: { $description: '识图标签置信度阈值' },
94
- taggerCharacterThreshold: { $description: '识图角色标签置信度阈值' },
95
- adminUsers: { $description: '免 P 点管理员用户 ID 列表' },
96
- outputLogs: { $description: '是否在控制台输出详细日志' },
97
- multiVerifyEnabled: { $description: '多人图生成后启用视觉校验(需配置下方视觉模型)' },
98
- multiVerifyPassScore: { $description: '多人视觉校验合格分数(0-10)' },
99
- multiCandidateCount: { $description: '多人候选采样数量(校验失败时最多重试 候选数-1 次)' },
100
- multiSendDegradedCandidate: { $description: '多人候选全部不达标时仍发送最优候选' },
101
- verifyLlmBaseUrl: { $description: '视觉校验 LLM 接口地址(OpenAI 兼容;留空则跳过校验)' },
102
- verifyLlmApiKey: { $description: '视觉校验 LLM API Key' },
103
- verifyLlmModel: { $description: '视觉校验 LLM 模型名(需支持图片输入,如 qwen-vl)' },
104
- adminOnly: { $description: '仅管理员可用(adminUsers 中的用户)' },
105
- allowedUserIds: { $description: '用户白名单(QQ 号,留空表示不限制)' },
106
- blockedUserIds: { $description: '用户黑名单(QQ 号,黑名单优先于白名单)' },
107
- allowedGroupIds: { $description: 'QQ 群白名单(群号,留空表示不限制)' },
108
- blockedGroupIds: { $description: 'QQ 群黑名单(群号,黑名单优先于白名单)' },
109
- commands: {
110
- 'p-draw': {
111
- description: '连接本地 ComfyUI 生图,消耗 P 点',
112
- messages: {
113
- 'not-permitted': 'ComfyUI 助手已关闭,或当前用户没有使用权限。',
114
- usage: 'p-draw 帮助(本指令名可自行更换,如 /anm):\n\n【生成】\n p-draw <描述>\n 例:p-draw 一个女孩,白色裙子,立绘,简单背景\n 可加 --seed 数字 固定种子(单张 / 批量 / 连续图均支持)\n\n【多人生成】\n p-draw 多人 <描述>(2-4 人画面)\n 例:p-draw 多人 左边若叶睦抱着吉他,右边千早爱音牵着她的手\n\n【连续图】\n p-draw 连续 <角色>:<阶段1> → <阶段2> → ...\n 例:p-draw 连续 少女:清纯校服 → 换上晚礼服 → 华丽登场\n 全阶段共用同一 seed,角色外观尽量一致;可加 --seed 数字 固定种子\n 阶段分隔:→ / -> / |\n\n【批量张数】\n 描述后加 x3 / ×3 / 3张 / 三张 / --数量 3,例:p-draw 一个女孩 x3\n 多张按 张数×单价 一次性扣 P 点,余额不足则不生成\n\n【尺寸】\n 竖图 / 横图 / 方图 / 长竖图 / 宽屏,或 1024x1536:描述 / --尺寸 1216x832\n 例:p-draw 竖图:狐莉站在梨花树下\n\n【原样模式】\n p-draw 无优化 masterpiece, best quality, 1girl, solo\n\n【提示词优化】\n 配置 LLM(llmBaseUrl/llmModel)后:\n 全局优化开启 → 每次自动把中文描述转成 Danbooru tags\n 全局优化关闭 → 生图时询问是否使用「提示词优化券」(p-shop 购买,每张图扣 1 张):\n 有券 → 确认后消耗券并优化,拒绝则取消本次生图\n 没券 → 询问是否购买(显示价格),确认后购买并消耗、优化生图;\n 拒绝一次会再次警告,再拒绝则直接生图(不优化);\n P 点不足买不起券时,会询问是否仍然生图\n 无优化 <tags> 原样生图,不耗券\n 连续图逐阶段强制优化;多人指令依赖 LLM 规划(未配置会提示)\n\n【画师组】\n 创建画师组 名称=tags / 追加画师组 名称=tags / 切换画师组 名称 / 查看画师组 / 删除画师组 名称\n\n【固定角色】\n 添加角色 名称=tags\n\n【以图生图】\n p-draw i2i <描述>,并在同一条消息里附一张原图(文件/截图/链接均可)\n 例:p-draw i2i 换成晚礼服,背景换成舞台灯光\n 发送后会询问处理模式(可配置 i2iMode 固定模式跳过询问):\n ① 换风格(漫画化):保留原图构图,转成二次元画风(需 ControlNet)\n ② 换装换姿势:保留角色长相,重新设计服装/姿势/场景(需 Anima IP-Adapter)\n ③ 取消:不生成\n 可加 --denoise 0.6 单独调整强度;未装 ControlNet/IPAdapter 时自动回退普通 img2img\n\n【模型】\n p-draw 模型(查看当前与可用模型) / p-draw 模型 名称(切换,支持模糊匹配)/ p-draw 模型 默认(重置)\n 例:p-draw 模型 anima-aesthetic\n\n【状态】\n p-draw 状态(查看 ComfyUI 连接状态与模型可用性)',
115
- 'account-notExists': '君现在还没有 p 点,请先签到哦',
116
- 'no-enough-p': '君的 p 点不够 {0}p 哦,先去签个到吧qwq',
117
- 'no-prompt': '请提供画面描述,例如:p-draw 一个女孩,白色裙子',
118
- generating: '正在生成中,请稍候...',
119
- charged: '已扣除 {0} P 点,出图后余额会再核对。',
120
- queued: '已加入生成队列,当前第 {0} 位(队列上限 {1})。',
121
- 'prompt-degraded': '提示词优化服务不可用{0},本次已使用原始提示词继续生成;结果可能不符合 Danbooru Tag 预期。',
122
- 'token-used': '已消耗 {0} 张提示词优化券,本次每张图都会使用 LLM 提示词优化。',
123
- 'token-short': '提示词优化券不足(需 {0} 张,现有 {1} 张),本次未使用 LLM 优化。',
124
- 'coupon-ask-use': '你有提示词优化券 {0} 张,本次生图需要消耗 {1} 张。\n是否使用提示词优化券进行 LLM 优化?\n(回复「是」使用 / 回复「否」取消本次生图)',
125
- 'coupon-use-confirmed': '已消耗 {0} 张提示词优化券,本次每张图都会使用 LLM 优化。',
126
- 'coupon-use-cancelled': '已取消本次生图(未使用提示词优化券)。',
127
- 'coupon-cancelled': '未收到有效回复,本次操作已取消。',
128
- 'coupon-ask-buy': '提示词优化券不足(需 {0} 张,现有 {1} 张)。\n提示词优化券价格:{2} P/张,本次共需 {3} P。\n是否购买并使用?\n(回复「是」购买 / 回复「否」不购买)',
129
- 'coupon-buy-warn': '不使用提示词优化券的话,生成的图可能不好看。\n是否仍要购买并使用提示词优化券?\n(回复「是」购买 / 回复「否」直接生图)',
130
- 'coupon-bought-used': '已购买 {0} 张提示词优化券(扣除 {1} P),并消耗 {0} 张用于本次 LLM 优化。',
131
- 'coupon-buy-cancelled': '好的,本次不使用提示词优化券,直接生图。',
132
- 'coupon-buy-pshort': 'P 点不足,无法购买提示词优化券(需 {0} P,现有 {1} P)。\n是否仍然生图(不使用 LLM 优化)?\n(回复「是」生图 / 回复「否」取消本次生图)',
133
- 'coupon-consume-fail': '提示词优化券操作失败,本次未使用 LLM 优化。',
134
- 'generate-failed': '生成失败:{0}',
135
- 'generate-ok': '已扣除 {0} P 点,seed={1}',
136
- 'generate-ok-batch': '已扣除 {0} P 点,共 {1} 张(seed:{2})',
137
- 'batch-partial': '本次共生成 {0}/{1} 张,失败 {2} 张:{3}',
138
- 'batch-limit': '每次最多生成 {0} 张,本次已按 {0} 张处理。',
139
- 'batch-count': '本次共生成 {0} 张。',
140
- 'artist-format': '请使用「名称=tags」的格式。例:p-draw 创建画师组 千代风格=@artist_a, @artist_b,',
141
- 'artist-created': '已保存并启用画师组「{0}」:\n{1}',
142
- 'artist-appended': '已追加画师组「{0}」:\n{1}',
143
- 'artist-default-appended': '已追加默认画师 tags:\n{0}',
144
- 'artist-use-format': '请写要启用的画师组名称。例:p-draw 切换画师组 千代风格',
145
- 'artist-default': '已切回默认画师 tags。',
146
- 'artist-not-found': '没有找到画师组「{0}」。',
147
- 'artist-used': '已启用画师组「{0}」:\n{1}',
148
- 'artist-deleted': '已删除画师组「{0}」。',
149
- 'artist-delete-format': '请写要删除的画师组名称。例:p-draw 删除画师组 千代风格',
150
- 'character-format': '请使用「名称=tags」的格式。例:p-draw 添加角色 狐莉=1girl, solo, fox girl',
151
- 'character-created': '已保存角色「{0}」:\n{1}',
152
- 'multi-usage': '多人生图:p-draw 多人 <描述>(2-4 人画面)\n例:p-draw 多人 左边若叶睦抱着吉他,右边千早爱音牵着她的手',
153
- 'multi-verify-passed': '多人图已通过视觉校验({0} 分)。',
154
- 'multi-verify-failed': '多人图未通过视觉校验{0},已重试 {1} 次。',
155
- 'multi-verify-degraded': '多人图校验失败,已发送最优候选{0}。',
156
- 'multi-verify-discarded': '多人图校验失败且未启用降级发送,本次图片不发送。',
157
- 'multi-degraded': '多人视觉校验不可用{0},本次已直接发送生成结果。',
158
- 'multi-verify-error': '多人视觉校验调用失败:{0}',
159
- 'series-usage': '连续图:p-draw 连续 <角色>:<阶段1> → <阶段2> → ...\n例:p-draw 连续 少女:清纯校服 → 换上晚礼服 → 华丽登场\n或用 | 分隔,可加 --seed 固定种子保证角色一致。',
160
- 'series-llm-ask': '是否使用 LLM 优化连续图各阶段提示词?\n1. 是(使用 LLM,自动整理成 Danbooru tags)\n2. 否(不使用 LLM,直接用你输入的内容)\n3. 取消(不生成)\n请回复 1 / 2 / 3',
161
- 'series-llm-invalid': '未识别的回答,请回复 1(使用 LLM)/ 2(不使用 LLM)/ 3(取消)。',
162
- 'series-no-llm': '本次未使用 LLM 优化,直接使用你输入的描述/tags。',
163
- 'series-ok': '已扣除 {0} P 点,共 {1} 张连续图(seed={2})',
164
- 'model-usage': '当前模型:{0}\n可用模型:\n{1}\n用法:p-draw 模型 <名称>(支持模糊匹配,如 anima-aesthetic);p-draw 模型 默认 恢复默认。',
165
- 'model-switched': '已切换为模型「{0}」,对之后的生图生效。',
166
- 'model-reset': '已恢复默认模型「{0}」。',
167
- 'model-not-found': '未找到模型「{0}」。可用模型:\n{1}',
168
- 'model-no-draw': '「模型」只能用来切换/查看模型,不能生图。请先用「p-draw 模型 <名称>」切换,再单独发送要画的内容。',
169
- 'model-ambiguous': '「{0}」匹配到多个模型,请写得更具体些:\n{1}',
170
- 'no-optimize': '提示:本次未使用 LLM 优化({0})。',
171
- 'i2i-no-image': 'i2i(以图生图)需要附一张原图。用法:p-draw i2i <描述>,并在同一条消息里带上图片。',
172
- 'i2i-upload-fail': '原图上传 ComfyUI 失败:{0}',
173
- 'i2i-no-custom-workflow': 'i2i(以图生图)暂不支持自定义工作流(customWorkflowEnabled),请关闭后再试。',
174
- 'i2i-mode-ask': '请选择 i2i 处理模式:\n① 换风格(漫画化)——保留原图构图,转成二次元画风\n② 换装换姿势——保留角色长相,重新设计服装/姿势/场景\n③ 取消——不生成\n回复 1 / 2 / 3 或对应名称即可。',
175
- 'i2i-mode-invalid': '没有理解你的选择。请回复 ①换风格(漫画化) / ②换装换姿势 / ③取消。',
176
- 'i2i-mode-cancelled': '已取消本次 i2i 生图。',
177
- 'i2i-mode-style': '已选择【换风格(漫画化)】:保留原图构图,转成二次元画风。',
178
- 'i2i-mode-ootd': '已选择【换装换姿势】:保留角色长相,重新设计服装/姿势/场景。',
179
- 'i2i-no-controlnet': '未检测到可用的 Anima ControlNet-LLLite,本次「换风格」将使用普通 img2img,构图保留效果会弱一些。',
180
- 'i2i-no-ipadapter': '未检测到可用的 Anima IP-Adapter,本次「换装换姿势」将使用普通 img2img,角色保留效果会弱一些。',
181
- 'multi-no-llm': '多人指令需要 LLM 规划,但当前未配置 llmBaseUrl / llmModel。请管理员在配置中填写后使用。',
182
- },
183
- },
184
- },
185
- }
186
-
38
+ const { zhCN } = require('./lib/i18n')
187
39
  exports.Config = Schema.object({
188
40
  // ComfyUI 连接
189
41
  comfyuiBaseUrl: Schema.string().default('http://127.0.0.1:8188').description('ComfyUI 地址'),
@@ -282,1049 +134,27 @@ exports.Config = Schema.object({
282
134
  'zh-CN': zhCN,
283
135
  })
284
136
 
285
- // ------------------------------------------------------------------
286
- // 地址规范化
287
- // ------------------------------------------------------------------
288
- function normalizeBaseUrl(raw) {
289
- let value = String(raw || '').trim()
290
- if (!value) value = 'http://127.0.0.1:8188'
291
- // 清理重复协议头,例如 http://http://host 或 http://https://host
292
- value = value.replace(/^https?:\/\//i, '')
293
- if (!/^https?:\/\//i.test(value)) value = 'http://' + value
294
- return value.replace(/\/+$/, '')
295
- }
296
-
297
- // ------------------------------------------------------------------
298
- // 尺寸别名与解析(移植自 anima command_router)
299
- // ------------------------------------------------------------------
300
- const SIZE_ALIASES = {
301
- '方图': 1.0,
302
- '正方形': 1.0,
303
- '竖图': 2 / 3,
304
- '竖版': 2 / 3,
305
- '横图': 3 / 2,
306
- '横版': 3 / 2,
307
- '长竖图': 9 / 16,
308
- '手机竖屏': 9 / 16,
309
- '宽屏': 16 / 9,
310
- '超宽图': 16 / 9,
311
- }
312
-
313
- const SIZE_VALUE_PATTERN = String.raw`(?<width>\d{2,5})\s*[xX×**✕✖хХ]\s*(?<height>\d{2,5})`
314
-
315
- function escapeRe(text) {
316
- return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
317
- }
318
-
319
- function parseGenerationSize(text, allowed) {
320
- const prompt = String(text || '').trim()
321
- let sizeMatch = null
322
- const patterns = [
323
- new RegExp(String.raw`(?<!\S)--(?:尺寸|分辨率)\s*(?:=|=|:|:)?\s*${SIZE_VALUE_PATTERN}`, 'i'),
324
- new RegExp(String.raw`(?:尺寸|分辨率)\s*(?:为|是|=|=|:|:)?\s*${SIZE_VALUE_PATTERN}`, 'i'),
325
- new RegExp(String.raw`^\s*${SIZE_VALUE_PATTERN}\s*[::,,]`, 'i'),
326
- ]
327
- for (const pattern of patterns) {
328
- sizeMatch = prompt.match(pattern)
329
- if (sizeMatch) break
330
- }
331
-
332
- let selected = null
333
- if (sizeMatch) {
334
- selected = [parseInt(sizeMatch.groups.width), parseInt(sizeMatch.groups.height)]
335
- } else {
336
- const aliases = Object.keys(SIZE_ALIASES)
337
- .sort((a, b) => b.length - a.length)
338
- .map(escapeRe)
339
- .join('|')
340
- const aliasPatterns = [
341
- new RegExp(String.raw`(?<!\S)--(?:尺寸|分辨率)\s*(?:=|=|:|:)?\s*(?<alias>${aliases})(?=$|\s|[::,,])`, 'i'),
342
- new RegExp(String.raw`(?:尺寸|分辨率)\s*(?:为|是|=|=|:|:)?\s*(?<alias>${aliases})(?=$|\s|[::,,])`, 'i'),
343
- new RegExp(String.raw`^\s*(?<alias>${aliases})(?=$|\s|[::,,])\s*[::,,]?`, 'i'),
344
- ]
345
- for (const pattern of aliasPatterns) {
346
- sizeMatch = prompt.match(pattern)
347
- if (sizeMatch) break
348
- }
349
- if (sizeMatch && allowed.length) {
350
- const targetRatio = SIZE_ALIASES[sizeMatch.groups.alias]
351
- selected = allowed.reduce((best, size) => {
352
- const a = Math.abs(size[0] / size[1] - targetRatio)
353
- const b = Math.abs(size[0] * size[1] - 1024 * 1024)
354
- const ba = Math.abs(best[0] / best[1] - targetRatio)
355
- const bb = Math.abs(best[0] * best[1] - 1024 * 1024)
356
- return a < ba || (a === ba && b < bb) ? size : best
357
- })
358
- }
359
- }
360
-
361
- if (!sizeMatch) return { prompt, size: null, error: null }
362
-
363
- let cleaned = (prompt.slice(0, sizeMatch.index) + ' ' + prompt.slice(sizeMatch.index + sizeMatch[0].length)).trim()
364
- cleaned = cleaned.replace(/^[\s,,;;::]+|[\s,,;;::]+$/g, '')
365
- cleaned = cleaned.replace(/([,,;;])\s*[,,;;]+/g, '$1')
366
- cleaned = cleaned.replace(/\s+/g, ' ')
367
-
368
- if (selected && allowed.length && !allowed.some(s => s[0] === selected[0] && s[1] === selected[1])) {
369
- return {
370
- prompt: cleaned,
371
- size: null,
372
- error: `尺寸 ${selected[0]}x${selected[1]} 不可用。可用尺寸:${allowed.map(s => `${s[0]}x${s[1]}`).join('、')}`,
373
- }
374
- }
375
- if (selected === null) return { prompt: cleaned, size: null, error: '当前没有配置可用尺寸。' }
376
- return { prompt: cleaned, size: selected, error: null }
377
- }
378
-
379
- // 批量张数解析:x3 / ×3 / 3张 / 三张 / --数量 3 / 数量:3
380
- const BATCH_TOKEN_PATTERNS = [
381
- /(?<!\S)(--|——)(?:数量|张数)\s*(?:=|=|:|:)?\s*(?<num>\d+)/i,
382
- /(?:数量|张数)\s*(?:为|是|=|=|:|:)\s*(?<num>\d+)/i,
383
- /(?<!\S)[x×X](?<num>\d+)(?![a-zA-Z0-9])/,
384
- /(?<!\S)(?<num>[一二两三四五六七八九十]+)张/,
385
- /(?<!\S)(?<num>\d+)张(?:图)?/,
386
- ]
387
-
388
- const CN_NUM_MAP = { '一': 1, '两': 2, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '十': 10 }
389
-
390
- function cnNumValue(text) {
391
- if (/^\d+$/.test(text)) return parseInt(text, 10)
392
- if (CN_NUM_MAP[text] != null) return CN_NUM_MAP[text]
393
- if (/^十[一二三四五六七八九]$/.test(text)) return 10 + CN_NUM_MAP[text.slice(1)]
394
- if (text === '十') return 10
395
- return 0
396
- }
397
-
398
- function parseBatchCount(text, max) {
399
- const prompt = String(text || '').trim()
400
- const cap = Math.max(1, parseInt(max) || 1)
401
- let requested = 1
402
- let matched = false
403
- for (const pattern of BATCH_TOKEN_PATTERNS) {
404
- const m = prompt.match(pattern)
405
- if (!m) continue
406
- const numText = m.groups && m.groups.num != null ? m.groups.num : ''
407
- const value = cnNumValue(numText)
408
- if (value >= 1) {
409
- requested = value
410
- matched = true
411
- }
412
- const cleaned = (prompt.slice(0, m.index) + ' ' + prompt.slice(m.index + m[0].length)).trim()
413
- .replace(/^[\s,,;;::]+|[\s,,;;::]+$/g, '')
414
- .replace(/\s+/g, ' ')
415
- return { count: Math.min(requested, cap), requested, prompt: cleaned, matched, clamped: requested > cap }
416
- }
417
- return { count: 1, requested: 1, prompt, matched, clamped: false }
418
- }
419
-
420
- // 固定种子解析:--seed 17021628 / --seed:17021628 / --seed=17021628 / --seed=123
421
- // 从提示词里剥离并返回 { seed, prompt }
422
- function parseSeed(text) {
423
- const prompt = String(text || '').trim()
424
- const m = prompt.match(/(?<!\S)--seed\s*(?:=|=|:|:)?\s*(\d+)/i)
425
- if (!m) return { seed: null, prompt }
426
- const seed = parseInt(m[1], 10) >>> 0
427
- const cleaned = (prompt.slice(0, m.index) + ' ' + prompt.slice(m.index + m[0].length)).trim()
428
- .replace(/^[\s,,;;::]+|[\s,,;;::]+$/g, '')
429
- .replace(/\s+/g, ' ')
430
- return { seed, prompt: cleaned }
431
- }
432
-
433
- // 去噪强度解析:--denoise 0.3 / --denoise=0.6 / --去噪 0.4,并从提示词里剥离
434
- function parseDenoise(text) {
435
- const prompt = String(text || '').trim()
436
- const m = prompt.match(/(?<!\S)--(?:denoise|去噪)\s*(?:=|=|:|:)?\s*(\d+(?:\.\d+)?)/i)
437
- if (!m) return { denoise: null, prompt }
438
- const denoise = Math.min(1, Math.max(0, parseFloat(m[1])))
439
- const cleaned = (prompt.slice(0, m.index) + ' ' + prompt.slice(m.index + m[0].length)).trim()
440
- .replace(/^[\s,,;;::]+|[\s,,;;::]+$/g, '')
441
- .replace(/\s+/g, ' ')
442
- return { denoise, prompt: cleaned }
443
- }
444
-
445
- // ------------------------------------------------------------------
446
- // 提示词辅助(移植自 anima prompt_presets)
447
- // ------------------------------------------------------------------
448
- const RAW_PREFIXES = [
449
- '原样', '原样tags', '原样tag', '原样 tags', '原样 tag',
450
- '直接画', '直接出图', '直接生图', '直接tags', '直接tag', '直接 tags', '直接 tag',
451
- '不优化', '无优化', '无优化tags', '无优化tag', '无优化 tags', '无优化 tag',
452
- '不要优化', '跳过优化', '跳过提示词优化', 'raw tags', 'raw tag', 'raw',
453
- 'no optimize', 'no optimization', '不用优化',
454
- ]
455
-
456
- function stripRawPrefix(prompt) {
457
- const text = String(prompt || '').trim()
458
- const lowered = text.toLowerCase()
459
- for (const prefix of RAW_PREFIXES) {
460
- if (lowered.startsWith(prefix.toLowerCase())) {
461
- return { raw: true, prompt: text.slice(prefix.length).replace(/^[\s,,;;::]+/, '').trim() }
462
- }
463
- }
464
- return { raw: false, prompt: text }
465
- }
466
-
467
- function mergeTagText(existing, addition) {
468
- const tags = []
469
- const seen = new Set()
470
- for (const source of [existing, addition]) {
471
- for (const tag of String(source || '').split(',')) {
472
- const text = tag.trim()
473
- if (!text) continue
474
- const key = text.toLowerCase()
475
- if (seen.has(key)) continue
476
- tags.push(text)
477
- seen.add(key)
478
- }
479
- }
480
- return tags.join(', ') + (tags.length ? ',' : '')
481
- }
482
-
483
- function parseNameTags(text) {
484
- const raw = String(text || '').trim()
485
- const candidates = []
486
- for (const separator of ['=', '=', ':', ':']) {
487
- const index = raw.indexOf(separator)
488
- if (index >= 0) candidates.push([index, separator])
489
- }
490
- candidates.sort((a, b) => a[0] - b[0])
491
- for (const [index, separator] of candidates) {
492
- const name = raw.slice(0, index).trim()
493
- const tags = raw.slice(index + separator.length).trim()
494
- if (!name || !tags) continue
495
- if (name.includes(',') || name.includes('\n')) continue
496
- if (/[@()[\]{}]/.test(name)) continue
497
- if (['artist', 'tag', 'tags', 'prompt', 'positive', 'negative'].includes(name.toLowerCase())) continue
498
- return { name, tags }
499
- }
500
- return null
501
- }
502
-
503
- function parsePresetList(list) {
504
- const result = {}
505
- for (const item of list || []) {
506
- const text = String(item || '').trim()
507
- if (!text) continue
508
- const parsed = parseNameTags(text)
509
- if (parsed) result[parsed.name] = parsed.tags
510
- }
511
- return result
512
- }
513
-
514
- // ------------------------------------------------------------------
515
- // ComfyUI 工作流构建(移植自 anima comfyui_workflows)
516
- // ------------------------------------------------------------------
517
- function animaT2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed) {
518
- return {
519
- '44': { class_type: 'UNETLoader', inputs: { unet_name: cfg.unetName, weight_dtype: 'fp8_e4m3fn' } },
520
- '45': { class_type: 'CLIPLoader', inputs: { clip_name: cfg.clipName, type: 'stable_diffusion', device: 'default' } },
521
- '15': { class_type: 'VAELoader', inputs: { vae_name: cfg.vaeName } },
522
- '28': { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } },
523
- '11': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['45', 0] } },
524
- '12': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt, clip: ['45', 0] } },
525
- '19': {
526
- class_type: 'KSampler',
527
- inputs: {
528
- model: ['44', 0],
529
- positive: ['11', 0],
530
- negative: ['12', 0],
531
- latent_image: ['28', 0],
532
- seed,
533
- steps,
534
- cfg: cfgVal,
535
- sampler_name: cfg.samplerName,
536
- scheduler: cfg.scheduler,
537
- denoise: 1,
538
- },
539
- },
540
- '8': { class_type: 'VAEDecodeTiled', inputs: { samples: ['19', 0], vae: ['15', 0], tile_size: 512, overlap: 64, temporal_size: 64, temporal_overlap: 8 } },
541
- '9': { class_type: 'SaveImage', inputs: { images: ['8', 0], filename_prefix: 'pdraw/anm' } },
542
- }
543
- }
544
-
545
- function buildWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, explicitSize) {
546
- if (cfg.customWorkflowEnabled && cfg.customWorkflowPath) {
547
- return customWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, explicitSize)
548
- }
549
- return animaT2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed)
550
- }
551
-
552
- // 以图生图工作流(p-draw i2i):LoadImage → VAEEncode → KSampler(denoise<1)
553
- // 输入图编码为 latent 作为起点,尺寸保持原图,去噪强度由 cfg.img2imgDenoise 控制。
554
- function animaI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, denoise) {
555
- return {
556
- '44': { class_type: 'UNETLoader', inputs: { unet_name: cfg.unetName, weight_dtype: 'fp8_e4m3fn' } },
557
- '45': { class_type: 'CLIPLoader', inputs: { clip_name: cfg.clipName, type: 'stable_diffusion', device: 'default' } },
558
- '15': { class_type: 'VAELoader', inputs: { vae_name: cfg.vaeName } },
559
- '13': { class_type: 'LoadImage', inputs: { image: inputImage } },
560
- '30': { class_type: 'VAEEncode', inputs: { pixels: ['13', 0], vae: ['15', 0] } },
561
- '11': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['45', 0] } },
562
- '12': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt, clip: ['45', 0] } },
563
- '19': {
564
- class_type: 'KSampler',
565
- inputs: {
566
- model: ['44', 0],
567
- positive: ['11', 0],
568
- negative: ['12', 0],
569
- latent_image: ['30', 0],
570
- seed,
571
- steps,
572
- cfg: cfgVal,
573
- sampler_name: cfg.samplerName,
574
- scheduler: cfg.scheduler,
575
- denoise,
576
- },
577
- },
578
- '8': { class_type: 'VAEDecodeTiled', inputs: { samples: ['19', 0], vae: ['15', 0], tile_size: 512, overlap: 64, temporal_size: 64, temporal_overlap: 8 } },
579
- '9': { class_type: 'SaveImage', inputs: { images: ['8', 0], filename_prefix: 'pdraw/anm_i2i' } },
580
- }
581
- }
582
-
583
- // 「换风格(漫画化)」i2i 工作流:LoadImage → 预处理器(LineArt / Canny)→ AnimaLLLiteApply_sdscripts 锁构图,
584
- // VAEEncode 原图作起点,KSampler 用较高 denoise 整体转二次元风格。
585
- // 注意:Anima 是 MiniTrainDIT 架构(隐藏层 3584 维),与 Qwen-Image 系 ControlNet 不兼容
586
- // (InstantX 控制网期望 3584 维文本嵌入,Anima 只给 1024 维 → LayerNorm 崩溃)。
587
- // 因此这里使用 Anima 原生支持的 ControlNet-LLLite(kohya-ss/ComfyUI-Anima-LLLite),
588
- // 节点直接对 MODEL 打补丁并返回补丁后的模型,交给 KSampler。
589
- // 预处理器:comfyui_controlnet_aux 的 AnimeLineArt/LineArt 节点(需安装该自定义节点);
590
- // 未安装时回退 ComfyUI 内置 Canny。anima-lllite-lineart 权重是按 lineart 训练的,用 LineArt 比 Canny 更贴。
591
- function animaStyleI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, controlNetModel, controlNetStrength, denoise, preprocessor) {
592
- const detectRes = Math.max(width, height)
593
- const preprocessorNode = preprocessor === 'AnimeLineArtPreprocessor' || preprocessor === 'LineArtPreprocessor'
594
- ? { class_type: preprocessor, inputs: { image: ['13', 0], detect_resolution: detectRes, resolution: detectRes } }
595
- : { class_type: 'Canny', inputs: { image: ['13', 0], low_threshold: 0.4, high_threshold: 0.8 } }
596
- return {
597
- '44': { class_type: 'UNETLoader', inputs: { unet_name: cfg.i2iUnetName || cfg.unetName, weight_dtype: 'fp8_e4m3fn' } },
598
- '45': { class_type: 'CLIPLoader', inputs: { clip_name: cfg.clipName, type: 'stable_diffusion', device: 'default' } },
599
- '15': { class_type: 'VAELoader', inputs: { vae_name: cfg.vaeName } },
600
- '13': { class_type: 'LoadImage', inputs: { image: inputImage } },
601
- '30': { class_type: 'VAEEncode', inputs: { pixels: ['13', 0], vae: ['15', 0] } },
602
- '11': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['45', 0] } },
603
- '12': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt, clip: ['45', 0] } },
604
- '70': preprocessorNode,
605
- '71': { class_type: 'AnimaLLLiteApply_sdscripts', inputs: { model: ['44', 0], lllite_name: controlNetModel, image: ['70', 0], strength: controlNetStrength, start_percent: 0, end_percent: 1, preserve_wrapper: true } },
606
- '19': {
607
- class_type: 'KSampler',
608
- inputs: {
609
- model: ['71', 0],
610
- positive: ['11', 0],
611
- negative: ['12', 0],
612
- latent_image: ['30', 0],
613
- seed,
614
- steps,
615
- cfg: cfgVal,
616
- sampler_name: cfg.samplerName,
617
- scheduler: cfg.scheduler,
618
- denoise,
619
- },
620
- },
621
- '8': { class_type: 'VAEDecodeTiled', inputs: { samples: ['19', 0], vae: ['15', 0], tile_size: 512, overlap: 64, temporal_size: 64, temporal_overlap: 8 } },
622
- '9': { class_type: 'SaveImage', inputs: { images: ['8', 0], filename_prefix: 'pdraw/anm_i2i_style' } },
623
- }
624
- }
625
-
626
- // 「换装换姿势」i2i 工作流:LoadImage → AnimaSiglipeEncodeImage → AnimaIPAdapterApply 保角色特征,
627
- // VAEEncode 原图作起点,KSampler 用中等 denoise 重画服装/姿势/场景。
628
- function animaOotdI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, ipAdapterPath, ipAdapterWeight, denoise) {
629
- return {
630
- '44': { class_type: 'UNETLoader', inputs: { unet_name: cfg.unetName, weight_dtype: 'fp8_e4m3fn' } },
631
- '45': { class_type: 'CLIPLoader', inputs: { clip_name: cfg.clipName, type: 'stable_diffusion', device: 'default' } },
632
- '15': { class_type: 'VAELoader', inputs: { vae_name: cfg.vaeName } },
633
- '13': { class_type: 'LoadImage', inputs: { image: inputImage } },
634
- '30': { class_type: 'VAEEncode', inputs: { pixels: ['13', 0], vae: ['15', 0] } },
635
- '11': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['45', 0] } },
636
- '12': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt, clip: ['45', 0] } },
637
- '80': { class_type: 'AnimaIPAdapterLoader', inputs: { ipadapter_path: ipAdapterPath } },
638
- '81': { class_type: 'AnimaSiglipeEncodeImage', inputs: { image: ['13', 0] } },
639
- '82': { class_type: 'AnimaIPAdapterApply', inputs: { model: ['44', 0], ipadapter: ['80', 0], siglip_features: ['81', 0], start_at: 0, end_at: 1, weight: ipAdapterWeight } },
640
- '19': {
641
- class_type: 'KSampler',
642
- inputs: {
643
- model: ['82', 0],
644
- positive: ['11', 0],
645
- negative: ['12', 0],
646
- latent_image: ['30', 0],
647
- seed,
648
- steps,
649
- cfg: cfgVal,
650
- sampler_name: cfg.samplerName,
651
- scheduler: cfg.scheduler,
652
- denoise,
653
- },
654
- },
655
- '8': { class_type: 'VAEDecodeTiled', inputs: { samples: ['19', 0], vae: ['15', 0], tile_size: 512, overlap: 64, temporal_size: 64, temporal_overlap: 8 } },
656
- '9': { class_type: 'SaveImage', inputs: { images: ['8', 0], filename_prefix: 'pdraw/anm_i2i_ootd' } },
657
- }
658
- }
659
-
660
- // 根据 i2i 模式与已检测到的节点能力,决定最终工作流与去噪强度。
661
- // mode:style=换风格(漫画化)/ ootd=换装换姿势 / 其他=基础 img2img
662
- // caps:{ controlNet: { available, model }, ipAdapter: { available } }(由 detectI2ICapabilities 返回)
663
- // 返回 { promptBody, kind, denoise },kind 为 plain / controlnet / ipadapter。
664
- function buildI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, mode, denoiseOverride, caps) {
665
- const fallbackDenoise = Number(cfg.img2imgDenoise) || 0.55
666
- if (mode === 'style') {
667
- if (caps && caps.controlNet && caps.controlNet.available && caps.controlNet.model) {
668
- const denoise = denoiseOverride != null ? denoiseOverride : (Number(cfg.i2iStyleDenoise) || 0.75)
669
- return {
670
- kind: 'controlnet',
671
- denoise,
672
- promptBody: animaStyleI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, caps.controlNet.model, Number(cfg.i2iControlNetStrength) || 0.7, denoise, caps.controlNet.preprocessor),
673
- }
674
- }
675
- // 没有 ControlNet:换风格又不想崩构图,denoise 自动压到 0.5 以内
676
- const denoise = denoiseOverride != null ? denoiseOverride : Math.min(Number(cfg.i2iStyleDenoise) || 0.75, 0.5)
677
- return { kind: 'plain', denoise, promptBody: animaI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, denoise) }
678
- }
679
- if (mode === 'ootd') {
680
- if (caps && caps.ipAdapter && caps.ipAdapter.available && String(cfg.i2iIPAdapterPath || '').trim()) {
681
- const denoise = denoiseOverride != null ? denoiseOverride : (Number(cfg.i2iOotdDenoise) || 0.55)
682
- return {
683
- kind: 'ipadapter',
684
- denoise,
685
- promptBody: animaOotdI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, String(cfg.i2iIPAdapterPath).trim(), Number(cfg.i2iIPAdapterWeight) || 0.8, denoise),
686
- }
687
- }
688
- const denoise = denoiseOverride != null ? denoiseOverride : (Number(cfg.i2iOotdDenoise) || 0.55)
689
- return { kind: 'plain', denoise, promptBody: animaI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, denoise) }
690
- }
691
- const denoise = denoiseOverride != null ? denoiseOverride : fallbackDenoise
692
- return { kind: 'plain', denoise, promptBody: animaI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, denoise) }
693
- }
694
-
695
- function customWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, explicitSize) {
696
- const rawPath = path.resolve(__dirname, cfg.customWorkflowPath)
697
- let raw
698
- try {
699
- raw = JSON.parse(fs.readFileSync(rawPath, 'utf-8'))
700
- } catch (e) {
701
- throw new Error(`自定义工作流加载失败:${e.message}`)
702
- }
703
- const body = raw && typeof raw === 'object' && raw.prompt && typeof raw.prompt === 'object' ? raw.prompt : raw
704
- if (!body || typeof body !== 'object') throw new Error('自定义工作流 JSON 无效')
705
-
706
- const workflow = JSON.parse(JSON.stringify(body))
707
- const textNodes = []
708
- for (const [nodeId, node] of Object.entries(workflow)) {
709
- if (!node || typeof node !== 'object') continue
710
- const classType = String(node.class_type || '')
711
- const inputs = node.inputs
712
- if (classType.includes('TextEncode') && inputs && typeof inputs.text === 'string') {
713
- textNodes.push(String(nodeId))
714
- }
715
- }
716
- const positiveIds = conditioningTextNodeIds(workflow, 'positive', textNodes)
717
- const negativeIds = conditioningTextNodeIds(workflow, 'negative', textNodes)
718
- if (!positiveIds.length) throw new Error('自定义工作流中找不到正面提示词节点')
719
- if (!negativeIds.length) throw new Error('自定义工作流中找不到负面提示词节点')
720
- if (positiveIds.some(id => negativeIds.includes(id))) throw new Error('自定义工作流正负面节点有歧义')
721
-
722
- for (const nodeId of positiveIds) {
723
- if (workflow[nodeId] && workflow[nodeId].inputs) workflow[nodeId].inputs.text = prompt
724
- }
725
- for (const nodeId of negativeIds) {
726
- if (workflow[nodeId] && workflow[nodeId].inputs) workflow[nodeId].inputs.text = negativePrompt
727
- }
728
-
729
- for (const node of Object.values(workflow)) {
730
- if (!node || typeof node !== 'object') continue
731
- const classType = String(node.class_type || '')
732
- const inputs = node.inputs
733
- if (!inputs || typeof inputs !== 'object') continue
734
- if (classType === 'SaveImage' && 'filename_prefix' in inputs) {
735
- inputs.filename_prefix = 'pdraw/anm'
736
- }
737
- const override = Boolean(cfg.customWorkflowOverrideParameters)
738
- if ((override || explicitSize) && classType === 'EmptyLatentImage') {
739
- if ('width' in inputs) inputs.width = width
740
- if ('height' in inputs) inputs.height = height
741
- }
742
- if (override && (classType === 'KSampler' || classType === 'KSamplerAdvanced')) {
743
- if ('steps' in inputs) inputs.steps = steps
744
- if ('cfg' in inputs) inputs.cfg = cfgVal
745
- if (cfg.samplerName && 'sampler_name' in inputs) inputs.sampler_name = cfg.samplerName
746
- if (cfg.scheduler && 'scheduler' in inputs) inputs.scheduler = cfg.scheduler
747
- }
748
- if ('seed' in inputs) inputs.seed = seed
749
- if ('noise_seed' in inputs) inputs.noise_seed = seed
750
- }
751
- return workflow
752
- }
753
-
754
- function conditioningTextNodeIds(workflow, inputName, textNodes) {
755
- const pending = []
756
- for (const node of Object.values(workflow)) {
757
- if (!node || typeof node !== 'object') continue
758
- if (!['KSampler', 'KSamplerAdvanced'].includes(String(node.class_type || ''))) continue
759
- const link = node.inputs && node.inputs[inputName]
760
- if (Array.isArray(link) && link.length) pending.push(String(link[0]))
761
- }
762
- const found = []
763
- const visited = new Set()
764
- while (pending.length) {
765
- const nodeId = pending.pop()
766
- if (visited.has(nodeId)) continue
767
- visited.add(nodeId)
768
- if (textNodes.includes(nodeId)) {
769
- found.push(nodeId)
770
- continue
771
- }
772
- const node = workflow[nodeId]
773
- const inputs = node && node.inputs
774
- if (!inputs || typeof inputs !== 'object') continue
775
- for (const value of Object.values(inputs)) {
776
- if (Array.isArray(value) && value.length) {
777
- const sourceId = String(value[0])
778
- if (workflow[sourceId]) pending.push(sourceId)
779
- }
780
- }
781
- }
782
- return found
783
- }
784
-
785
- function outputImages(history) {
786
- const images = []
787
- const outputs = history.outputs || {}
788
- if (outputs && typeof outputs === 'object') {
789
- for (const nodeOutput of Object.values(outputs)) {
790
- if (!nodeOutput || typeof nodeOutput !== 'object') continue
791
- for (const image of nodeOutput.images || []) {
792
- if (image && typeof image === 'object') images.push(image)
793
- }
794
- }
795
- }
796
- return images
797
- }
798
-
799
- // ------------------------------------------------------------------
800
- // ComfyUI 结果等待:优先 WebSocket 事件,失败/不可用回退轮询
801
- // ------------------------------------------------------------------
802
- function waitViaWebSocket(baseUrl, promptId, clientId, timeoutMs) {
803
- return new Promise((resolve) => {
804
- let socket
805
- let timer = null
806
- let settled = false
807
- const finish = (ok) => {
808
- if (settled) return
809
- settled = true
810
- if (timer) clearTimeout(timer)
811
- try { if (socket) socket.close() } catch (e) { /* ignore */ }
812
- resolve(ok)
813
- }
814
- try {
815
- const wsUrl = baseUrl.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:') + `/ws?clientId=${encodeURIComponent(clientId)}`
816
- socket = new WebSocket(wsUrl)
817
- } catch (e) {
818
- finish(false)
819
- return
820
- }
821
- timer = setTimeout(() => finish(false), timeoutMs)
822
- socket.onmessage = (ev) => {
823
- let msg
824
- try { msg = JSON.parse(String(ev.data)) } catch (e) { return }
825
- if (!msg || typeof msg !== 'object') return
826
- if (msg.type === 'execution_success' && msg.data && msg.data.prompt_id === promptId) { finish(true); return }
827
- if (msg.type === 'execution_error' || msg.type === 'execution_interrupted') { finish(false) }
828
- }
829
- socket.onerror = () => finish(false)
830
- socket.onclose = () => finish(false)
831
- })
832
- }
833
-
834
- async function waitComfyResult(ctx, comfyGet, baseUrl, promptId, clientId, timeoutMs, pollMs) {
835
- const deadline = Date.now() + timeoutMs
836
- if (typeof WebSocket !== 'undefined') {
837
- const remaining = Math.max(0, deadline - Date.now())
838
- try {
839
- const viaWs = await waitViaWebSocket(baseUrl, promptId, clientId, remaining)
840
- if (viaWs) {
841
- try {
842
- const data = await comfyGet(`/history/${promptId}`, 20000)
843
- if (data && data[promptId]) return data[promptId]
844
- } catch (e) { /* fall through */ }
845
- }
846
- } catch (e) { /* fall through to polling */ }
847
- }
848
- while (Date.now() < deadline) {
849
- try {
850
- const data = await comfyGet(`/history/${promptId}`, 20000)
851
- if (data && data[promptId]) return data[promptId]
852
- } catch (e) { /* transient */ }
853
- await ctx.sleep(pollMs)
854
- }
855
- return null
856
- }
857
-
858
- // ------------------------------------------------------------------
859
- // Tag 清洗(移植自 anima tag_cleaner)
860
- // ------------------------------------------------------------------
861
- function splitTags(text) {
862
- let cleaned = String(text || '')
863
- cleaned = cleaned.replace(/```[\s\S]*?```/g, m => m.slice(3, -3).trim())
864
- cleaned = cleaned.replace(/,/g, ',').replace(/、/g, ',').replace(/;/g, ',')
865
- cleaned = cleaned.replace(/\n/g, ',')
866
- cleaned = cleaned.replace(/^(?:positive|prompt|tags|提示词|正向提示词)\s*[::]/i, '')
867
- const parts = cleaned.split(',').map(p => p.trim().replace(/^[\s,.;::]+|[\s,.;::]+$/g, ''))
868
- return parts.filter(Boolean)
869
- }
870
-
871
- function normalizeTagKey(tag) {
872
- let value = String(tag || '').trim().toLowerCase()
873
- if (
874
- value.startsWith('(') && value.endsWith(')') &&
875
- (value.match(/\(/g) || []).length === 1 &&
876
- (value.match(/\)/g) || []).length === 1
877
- ) {
878
- value = value.slice(1, -1).trim()
879
- }
880
- value = value.replace(/:\s*[\d.]+$/, '')
881
- value = value.replace(/\s+/g, ' ')
882
- return value
883
- }
884
-
885
- function stripWrappingBrackets(text) {
886
- let value = String(text || '').trim()
887
- const pairs = { '(': ')', '[': ']', '{': '}' }
888
- let changed = true
889
- while (changed && value.length >= 2) {
890
- changed = false
891
- const left = value[0]
892
- const right = pairs[left]
893
- if (right && value.endsWith(right)) {
894
- value = value.slice(1, -1).trim()
895
- changed = true
896
- }
897
- }
898
- return value
899
- }
900
-
901
- const ARTIST_FUNCTION_RE = /^artist\s*:\s*([^:=()[\]{}]+?)\s*(?:[:=]\s*[-+]?(?:\d+(?:\.\d+)?|\.\d+)\s*)?$/i
902
-
903
- function normalizeAnimaArtistTag(tag) {
904
- const raw = String(tag || '').trim()
905
- if (!raw) return ''
906
- if (raw.startsWith('@')) {
907
- const name = raw.slice(1).trim().replace(/_/g, ' ').replace(/\s+/g, ' ').trim()
908
- return name ? `@${name}` : raw
909
- }
910
- const inner = stripWrappingBrackets(raw)
911
- const match = ARTIST_FUNCTION_RE.exec(inner)
912
- if (!match) return raw
913
- let name = match[1].trim()
914
- if (name.startsWith('@')) name = name.slice(1).trim()
915
- name = name.replace(/_/g, ' ').replace(/\s+/g, ' ').trim()
916
- return name ? `@${name}` : raw
917
- }
918
-
919
- function canonicalTagText(tag) {
920
- const artistTag = normalizeAnimaArtistTag(tag)
921
- if (artistTag.startsWith('@')) return artistTag
922
- const key = normalizeTagKey(tag)
923
- if (key === '1 girl') return '1girl'
924
- if (key === 'punis') return 'penis'
925
- if (['point a sword at the audience', 'point a sword at viewer', 'point sword at the audience', 'point sword at viewer'].includes(key)) return 'sword pointed at viewer'
926
- return String(tag || '').trim()
927
- }
928
-
929
- // 内联画师标签(@name / artist:name)与内联质量词(masterpiece / best quality / score_N 等)。
930
- // 提示词优化会把它们交给 LLM 重写并剥掉,这里在优化后把它们从原始输入中补回,
931
- // 避免用户直接写在消息里的画师标签 / 质量词丢失。
932
- const INLINE_QUALITY_RE = /^(masterpiece|best quality|amazing quality|high quality|good quality|score_\d+)$/i
933
-
934
- function appendInlineProtectedTags(prompt, original, raw) {
935
- if (raw || !original) return prompt
936
- if (/(不用我的风格|不要我的风格|不使用我的风格|不要画师词|不用画师词|不加画师词|no artist)/i.test(original)) return prompt
937
- const tags = []
938
- const seen = new Set(splitTags(prompt).map(t => normalizeTagKey(t)))
939
- for (const token of splitTags(original)) {
940
- const t = String(token || '').trim()
941
- if (!t) continue
942
- const artist = normalizeAnimaArtistTag(t)
943
- if (artist.startsWith('@')) {
944
- const key = normalizeTagKey(artist)
945
- if (!seen.has(key)) {
946
- seen.add(key)
947
- tags.push(artist)
948
- }
949
- continue
950
- }
951
- if (INLINE_QUALITY_RE.test(t)) {
952
- const key = normalizeTagKey(t)
953
- if (!seen.has(key)) {
954
- seen.add(key)
955
- tags.push(t)
956
- }
957
- }
958
- }
959
- if (!tags.length) return prompt
960
- return prompt + ', ' + tags.join(', ')
961
- }
962
-
963
- const QUALITY_BLOCKLIST = new Set([
964
- 'masterpiece', 'best quality', 'score_7', 'score_6', 'score_5', 'score_4', 'score_3', 'score_2', 'score_1',
965
- 'safe', 'worst quality', 'low quality', 'artist name',
966
- ])
967
- const CHARACTER_BLOCKLIST = new Set(['1 girl', '1girl', 'solo'])
968
- const CHARACTER_IDENTITY_EXACT_BLOCKLIST = new Set([
969
- 'girl', 'boy', 'child', 'teenager', 'young adult', 'adult', 'mature', 'loli', 'shota', 'petite', 'aged down', 'age regression',
970
- 'vampire', 'angel', 'demon', 'fox girl', 'cat girl', 'animal girl',
971
- 'ahoge', 'bangs', 'blunt bangs', 'sidelocks', 'hair between eyes', 'long hair', 'short hair', 'medium hair', 'very long hair',
972
- 'twintails', 'low twintails', 'braids', 'side braid', 'ponytail', 'side ponytail', 'one side up', 'hair bun', 'double bun',
973
- 'heterochromia', 'blue eyes', 'red eyes', 'green eyes', 'pink eyes', 'purple eyes', 'yellow eyes', 'golden eyes', 'grey eyes',
974
- 'gray eyes', 'brown eyes', 'black eyes', 'black hair', 'brown hair', 'blonde hair', 'white hair', 'silver hair', 'blue hair',
975
- 'red hair', 'pink hair', 'purple hair', 'green hair', 'grey hair', 'gray hair',
976
- 'fox ears', 'cat ears', 'animal ears', 'pointed ears', 'tail', 'fox tail', 'cat tail', 'wings', 'angel wings', 'demon wings',
977
- 'horns', 'halo', 'fang', 'freckles',
978
- ])
979
- const CHARACTER_IDENTITY_PATTERNS = [
980
- /\b(?:black|brown|blonde|white|silver|blue|red|pink|purple|green|grey|gray|orange|gold|golden|light|dark|ice blue|silver white)\s+hair\b/,
981
- /\b(?:black|brown|blue|red|pink|purple|green|grey|gray|gold|golden|light|dark|ice blue|amber)\s+eyes?\b/,
982
- /\b(?:ears?|tail|wings?|horns?|halo|fangs?|heterochromia)\b/,
983
- /\b(?:vampire|angel|demon|fox girl|cat girl|animal girl)\b/,
984
- /\b(?:loli|shota|teenager|young adult|adult|mature|aged down|age regression)\b/,
985
- ]
986
- const MULTI_CHARACTER_BLOCKLIST = new Set([
987
- '2girls', '3girls', '4girls', '5girls', '6+girls', 'multiple girls',
988
- '2boys', '3boys', '4boys', '5boys', '6+boys', 'multiple boys',
989
- 'multiple people', 'crowd', 'group', 'background characters', 'extra girl', 'extra person', 'clone', 'duplicate', 'twins',
990
- ])
991
- const NON_VISUAL_TAGS = new Set(['holding nothing'])
992
- const EXCLUSIVE_TAG_GROUPS = {
993
- 'looking at viewer': 'gaze_target', 'looking away': 'gaze_target',
994
- 'light rays': 'light_beams', 'sun rays': 'light_beams', 'sunbeams': 'light_beams', 'sunlight rays': 'light_beams',
995
- 'glowing': 'light_intensity', 'illuminated': 'light_intensity', 'bright': 'light_intensity', 'luminous': 'light_intensity', 'radiant': 'light_intensity',
996
- 'backlight': 'backlighting', 'backlighting': 'backlighting',
997
- 'rim light': 'rim_lighting', 'rim lighting': 'rim_lighting',
998
- 'soft light': 'soft_lighting', 'soft lighting': 'soft_lighting',
999
- 'floating particles': 'light_particles', 'light particles': 'light_particles', 'glowing particles': 'light_particles',
1000
- 'flowing dress': 'flowing_dress', 'dress flowing': 'flowing_dress',
1001
- 'hair blowing': 'wind_in_hair', 'wind in hair': 'wind_in_hair',
1002
- 'sad expression': 'sad_expression', 'sorrowful expression': 'sad_expression',
1003
- 'teary eyes': 'tearful_eyes', 'watery eyes': 'tearful_eyes', 'wet eyes': 'tearful_eyes',
1004
- }
1005
- const TAG_GROUP_LIMITS = { light_intensity: 2 }
1006
-
1007
- function isCharacterIdentityTag(key) {
1008
- const compact = normalizeTagKey(key).replace(/_/g, ' ')
1009
- if (!compact) return false
1010
- if (CHARACTER_IDENTITY_EXACT_BLOCKLIST.has(compact)) return true
1011
- return CHARACTER_IDENTITY_PATTERNS.some(pattern => pattern.test(compact))
1012
- }
1013
-
1014
- function cleanContentTags(text, maxTags = 65, stripCharacterTags = true, protectedCoreTags = [], allowMultiCharacter = false) {
1015
- const tags = splitTags(text)
1016
- const seen = new Set()
1017
- const cleaned = []
1018
- const artistRe = /^@\S+/
1019
- const protectedSet = new Set(protectedCoreTags.map(t => normalizeTagKey(t)))
1020
- const parenthesizedCoreRe = /^[a-z0-9_.'-]+_\([a-z0-9_.' -]{2,60}\)$/i
1021
- for (let tag of tags) {
1022
- tag = canonicalTagText(tag)
1023
- const key = normalizeTagKey(tag)
1024
- if (!key) continue
1025
- if (seen.has(key)) continue
1026
- if (QUALITY_BLOCKLIST.has(key)) continue
1027
- if (stripCharacterTags && CHARACTER_BLOCKLIST.has(key)) continue
1028
- if (stripCharacterTags && isCharacterIdentityTag(key)) continue
1029
- if (!allowMultiCharacter && MULTI_CHARACTER_BLOCKLIST.has(key)) continue
1030
- if (protectedSet.size && parenthesizedCoreRe.test(key) && !protectedSet.has(key)) continue
1031
- if (artistRe.test(tag.trim())) continue
1032
- if (tag.length > 80) continue
1033
- seen.add(key)
1034
- cleaned.push(tag)
1035
- }
1036
- const semanticKeys = cleaned.map(tag => normalizeTagKey(stripWrappingBrackets(tag)))
1037
- const fullNudityKey = semanticKeys.includes('nude') ? 'nude' : 'naked'
1038
- const hasFullNudity = semanticKeys.includes(fullNudityKey)
1039
- const hasSpecificMist = semanticKeys.includes('morning mist')
1040
- const hasClosedEyes = semanticKeys.some(k => k === 'closed eyes' || k === 'eyes closed')
1041
- const hasSheerFabric = semanticKeys.includes('sheer fabric')
1042
- const groupCounts = {}
1043
- const semanticCleaned = []
1044
- cleaned.forEach((tag, i) => {
1045
- const key = semanticKeys[i]
1046
- if (NON_VISUAL_TAGS.has(key)) return
1047
- if (hasFullNudity && ['nude', 'naked', 'topless', 'bottomless'].includes(key)) {
1048
- if (key !== fullNudityKey) return
1049
- }
1050
- if (hasSpecificMist && key === 'mist') return
1051
- if (hasClosedEyes && key.includes('looking') && key.includes('viewer')) return
1052
- if (hasSheerFabric && key === 'translucent fabric') return
1053
- const group = EXCLUSIVE_TAG_GROUPS[key.replace(/_/g, ' ')]
1054
- if (group) {
1055
- const count = groupCounts[group] || 0
1056
- if (count >= (TAG_GROUP_LIMITS[group] != null ? TAG_GROUP_LIMITS[group] : 1)) return
1057
- groupCounts[group] = count + 1
1058
- }
1059
- semanticCleaned.push(tag)
1060
- })
1061
- return semanticCleaned.slice(0, maxTags).join(', ')
1062
- }
1063
-
1064
- function joinPromptParts(parts) {
1065
- const tags = []
1066
- const seen = new Set()
1067
- for (const part of parts) {
1068
- for (const tag of splitTags(part)) {
1069
- const canonical = canonicalTagText(tag)
1070
- const key = normalizeTagKey(canonical)
1071
- if (!key || seen.has(key)) continue
1072
- seen.add(key)
1073
- tags.push(canonical)
1074
- }
1075
- }
1076
- return tags.join(', ')
1077
- }
1078
-
1079
- // ------------------------------------------------------------------
1080
- // 多人规划(移植自 anima multi_person_prompt)
1081
- // ------------------------------------------------------------------
1082
- const MULTI_PERSON_NEGATIVE_TAGS = [
1083
- 'split screen', 'comic panels', 'multiple views', 'character sheet',
1084
- 'duplicate characters', 'cloned character', 'extra person', 'extra girl', 'extra boy',
1085
- 'twins', 'merged bodies', 'fused characters',
1086
- ]
1087
-
1088
- const MULTI_SAFE_SLOTS = new Set(['left', 'right', 'center', 'foreground', 'background', 'far left', 'far right'])
1089
- const MULTI_UNSAFE_COMPOSITION_MARKERS = [
1090
- 'split screen', 'panel', 'multiple views', 'alternate views', 'character sheet',
1091
- 'top left', 'top right', 'bottom left', 'bottom right',
1092
- ]
1093
- const MULTI_SAFE_SPATIAL_MODES = new Set(['shared_contact', 'shared_scene', 'explicit_positions'])
1094
-
1095
- function buildMultiPersonPlanPrompt(userPrompt, fixedCharacters = {}) {
1096
- const fixedNote = Object.keys(fixedCharacters).length
1097
- ? `Locally saved characters explicitly mentioned by the user:\n${JSON.stringify(fixedCharacters, null, 2)}`
1098
- : 'No locally saved character name was detected.'
1099
- return `Plan one coherent Anima image containing 2 to 4 people.
1100
-
1101
- Use the user's requested identities, count, clothing, expressions, props, positions, and relationships. You may freely design compatible mutable details, background, lighting, and atmosphere when the user leaves them open.
1102
-
1103
- Separate every person into an independent semantic block. Position slots are bookkeeping only and must never describe separate regions, panels, views, or sides of the image. Prefer one shared central group. Use explicit positions only when the user directly asks for left/right or foreground/background placement. Never use top_left, top_right, bottom_left, bottom_right, upper, lower, panel, or "side of the image".
1104
-
1105
- For an existing named character, preserve the user's written name in "name" and provide the most likely Danbooru character tag in "danbooru_candidate". For an original or generic person, leave "danbooru_candidate" empty.
1106
-
1107
- When a person matches one of the locally saved characters below, their saved tags are authoritative. Leave "appearance" empty and do not restate or alter their hair, eyes, species, ears, tail, body type, age, or fixed accessories. Only plan mutable clothing, expression, pose, and props.
1108
-
1109
- Return JSON only with this exact shape:
1110
- {
1111
- "count_tags": ["2girls"],
1112
- "common_tags": ["medium shot", "outdoors"],
1113
- "characters": [
1114
- {
1115
- "slot": "left",
1116
- "name": "character name from the user",
1117
- "danbooru_candidate": "romanized_character_tag",
1118
- "role": "short semantic role such as rider or supporting girl",
1119
- "visual_label": "distinctive visible label such as white-haired fox girl",
1120
- "identity_anchors": ["3 to 6 short appearance tags"],
1121
- "emphasized_anchors": ["0 to 3 explicitly requested unusual traits"],
1122
- "appearance": "Visible identity traits for a non-fixed character only; empty for a locally saved character.",
1123
- "clothing": "One concise English clothing phrase.",
1124
- "expression": "One concise English expression phrase.",
1125
- "pose": "One concise English body pose that does not repeat the interaction.",
1126
- "props": ["visible prop held or worn by this person"]
1127
- },
1128
- {
1129
- "slot": "right",
1130
- "name": "second character name from the user",
1131
- "danbooru_candidate": "romanized_character_tag",
1132
- "appearance": "",
1133
- "clothing": "One concise English clothing phrase.",
1134
- "expression": "One concise English expression phrase.",
1135
- "pose": "One concise English body pose.",
1136
- "props": []
1137
- }
1138
- ],
1139
- "relationship_tag": "holding hands",
1140
- "interactions": [
1141
- "Character A is holding Character B's hand."
1142
- ],
1143
- "spatial_mode": "shared_contact",
1144
- "composition": "A single unified full-frame composition using one camera view."
1145
- }
1146
-
1147
- Rules:
1148
- - Include exactly 2 to 4 character objects.
1149
- - count_tags must agree with the number and genders requested by the user.
1150
- - common_tags contain only shared scene, framing, camera, lighting, atmosphere, and count tags.
1151
- - relationship_tag is one short Danbooru-style relationship or action tag and appears immediately after the count tags in the final prompt.
1152
- - Do not put character names or character-specific appearance in common_tags.
1153
- - role is optional semantic bookkeeping and is not used to identify a person in the final interaction sentence.
1154
- - visual_label must be a unique 2 to 6 word visible description derived from identity_anchors, such as "white-haired fox girl" or "silver-haired vampire girl". Do not use names, ordinal labels, rider, supporter, top, bottom, left, or right as visual_label.
1155
- - identity_anchors must contain only 3 to 6 concise visible identity traits. For locally saved characters, select them only from the saved defining tags.
1156
- - emphasized_anchors may contain at most 3 identity_anchors that the user explicitly requested and that are unusual, contrastive, or likely to be confused between people. Never invent emphasis.
1157
- - For locally saved characters, appearance must be empty and saved defining tags must never be contradicted.
1158
- - Do not output quality tags, safety tags, artist tags, Markdown, or explanations.
1159
- - Keep character fields and relationships visually concrete.
1160
- - Preserve the user's explicit interaction direction and gaze direction.
1161
- - Put the complete directed relationship in exactly one interactions entry. Character pose fields must not repeat the relationship.
1162
- - Refer to people inside interactions exclusively as Character A, Character B, Character C, or Character D. Never use their names, translated names, or Danbooru tags there.
1163
- - spatial_mode must be shared_contact for physical interaction, shared_scene for a non-contact group, or explicit_positions only when the user explicitly requests relative positions.
1164
- - Prefer a single coherent moment rather than multiple competing actions.
1165
- - composition must use affirmative language to request one unified full-frame camera view.
1166
-
1167
- ${fixedNote}
1168
-
1169
- User request:
1170
- ${userPrompt}
1171
- `
1172
- }
1173
-
1174
- function cleanMultiText(value, limit) {
1175
- return String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit).trim()
1176
- }
1177
-
1178
- function multiStringTuple(value, limit, itemLimit) {
1179
- if (!Array.isArray(value)) return []
1180
- const result = []
1181
- for (const item of value) {
1182
- const text = cleanMultiText(item, itemLimit)
1183
- if (text) result.push(text)
1184
- }
1185
- return result.slice(0, limit)
1186
- }
1187
-
1188
- function normalizeMultiSlot(value) {
1189
- const slot = cleanMultiText(value, 40).toLowerCase().replace(/_/g, ' ').replace(/-/g, ' ').replace(/\s+/g, ' ').trim()
1190
- return MULTI_SAFE_SLOTS.has(slot) ? slot : ''
1191
- }
1192
-
1193
- function parseMultiPersonPlan(text) {
1194
- let raw = String(text || '').trim()
1195
- raw = raw.replace(/^```(?:json)?\s*/i, '')
1196
- raw = raw.replace(/\s*```$/, '')
1197
- const match = raw.match(/\{[\s\S]*\}/)
1198
- if (match) raw = match[0]
1199
- let data
1200
- try {
1201
- data = JSON.parse(raw)
1202
- } catch (e) {
1203
- return null
1204
- }
1205
- if (!data || typeof data !== 'object') return null
1206
- const rawCharacters = data.characters
1207
- if (!Array.isArray(rawCharacters) || rawCharacters.length < 2 || rawCharacters.length > 4) return null
1208
- if (rawCharacters.some(item => !item || typeof item !== 'object')) return null
1209
-
1210
- const defaultSlots = {
1211
- 2: ['left', 'right'],
1212
- 3: ['left', 'center', 'right'],
1213
- 4: ['far left', 'left', 'right', 'far right'],
1214
- }[rawCharacters.length]
1215
- const proposedSlots = rawCharacters.map(item => normalizeMultiSlot(item.slot))
1216
- if (
1217
- proposedSlots.some(slot => !slot) ||
1218
- new Set(proposedSlots).size !== proposedSlots.length ||
1219
- (rawCharacters.length === 2 && !(new Set(proposedSlots).size === 2 && ['left', 'right'].every(s => proposedSlots.includes(s)) || ['foreground', 'background'].every(s => proposedSlots.includes(s))))
1220
- ) {
1221
- proposedSlots.splice(0, proposedSlots.length, ...defaultSlots)
1222
- }
1223
-
1224
- const characters = proposedSlots.map((slot, index) => {
1225
- const item = rawCharacters[index]
1226
- return {
1227
- slot,
1228
- name: cleanMultiText(item.name, 120),
1229
- danbooru_candidate: cleanMultiText(item.danbooru_candidate, 160),
1230
- appearance: cleanMultiText(item.appearance, 500),
1231
- clothing: cleanMultiText(item.clothing, 400),
1232
- expression: cleanMultiText(item.expression, 240),
1233
- pose: cleanMultiText(item.pose, 400),
1234
- props: multiStringTuple(item.props, 12, 120),
1235
- role: cleanMultiText(item.role, 80),
1236
- visual_label: cleanMultiText(item.visual_label, 100),
1237
- identity_anchors: multiStringTuple(item.identity_anchors, 6, 100),
1238
- emphasized_anchors: multiStringTuple(item.emphasized_anchors, 3, 100),
1239
- }
1240
- })
1241
-
1242
- const countTags = multiStringTuple(data.count_tags, 8, 80)
1243
- const commonTags = multiStringTuple(data.common_tags, 50, 100)
1244
- const interactions = multiStringTuple(data.interactions, 1, 500)
1245
- const composition = cleanMultiText(data.composition, 700)
1246
- let spatialMode = cleanMultiText(data.spatial_mode, 40).toLowerCase()
1247
- const relationshipTag = cleanMultiText(data.relationship_tag, 120)
1248
- if (!MULTI_SAFE_SPATIAL_MODES.has(spatialMode)) spatialMode = interactions.length ? 'shared_contact' : 'shared_scene'
1249
- let compositionSafe = composition
1250
- if (MULTI_UNSAFE_COMPOSITION_MARKERS.some(marker => compositionSafe.toLowerCase().includes(marker))) compositionSafe = ''
1251
- return {
1252
- count_tags: countTags.length ? countTags : [`${characters.length}people`],
1253
- common_tags: commonTags,
1254
- characters,
1255
- interactions,
1256
- composition: compositionSafe,
1257
- spatial_mode: spatialMode,
1258
- relationship_tag: relationshipTag,
1259
- }
1260
- }
1261
-
1262
- function renderMultiPersonCharacter(character, opts = {}) {
1263
- const {
1264
- alias = '', resolvedIdentity = '', fixedTags = '', groupedContact = false,
1265
- explicitPositions = false, identityAnchors = [], includePose = true,
1266
- } = opts
1267
- let label = String(alias || character.visual_label || character.role || '').trim()
1268
- if (explicitPositions && character.slot) label = `${character.slot} ${label}`
1269
- const identity = String(resolvedIdentity || character.danbooru_candidate || '').trim()
1270
- const details = []
1271
- if (identity && !fixedTags) details.push(identity)
1272
- if (identityAnchors.length) {
1273
- details.push(...identityAnchors)
1274
- } else if (fixedTags) {
1275
- for (const part of fixedTags.split(',')) {
1276
- const t = part.trim().replace(/^ +| +$/g, '').replace(/^\(|\)$/g, '')
1277
- if (t) details.push(t)
1278
- }
1279
- }
1280
- if (!identityAnchors.length && !fixedTags && character.appearance) details.push(character.appearance)
1281
- if (character.clothing) details.push(character.clothing)
1282
- if (character.expression) details.push(character.expression)
1283
- if (includePose && character.pose) details.push(character.pose)
1284
- if (character.props && character.props.length) details.push(...character.props)
1285
- const joined = details.filter(Boolean).join(', ')
1286
- return `${label}: ${joined}.`
1287
- }
1288
-
1289
- // ------------------------------------------------------------------
1290
- // 多人尺寸自动选择(移植自 anima command_actions multi_person 分支)
1291
- // ------------------------------------------------------------------
1292
- function multiPersonAutoSize(prompt, allowedSizes) {
1293
- if (!Array.isArray(allowedSizes) || !allowedSizes.length) return null
1294
- const promptLower = String(prompt || '').toLowerCase()
1295
- const threeOrMore = /\b(?:三|四|3|4)\s*(?:人|个|名|girls?|boys?|people)\b|\b(?:3|4)(?:girls?|boys?|people)\b/.test(promptLower)
1296
- const verticallyStacked = [
1297
- '骑在肩', '骑肩', '肩膀上', '背着', '抱起', '扑倒', '压在', '上下叠',
1298
- 'on the shoulders', 'piggyback', 'carrying', 'on top of', 'stacked',
1299
- ].some(marker => promptLower.includes(marker))
1300
- const physicalContact = [
1301
- '牵手', '拥抱', '接吻', '搂着', '抱着', '挽着',
1302
- 'holding hands', 'hugging', 'embracing', 'kissing', 'arm around',
1303
- ].some(marker => promptLower.includes(marker))
1304
- const target = threeOrMore
1305
- ? [1216, 832]
1306
- : verticallyStacked
1307
- ? [1024, 1536]
1308
- : physicalContact
1309
- ? [1024, 1024]
1310
- : [1152, 896]
1311
- let best = allowedSizes[0]
1312
- let bestScore = Infinity
1313
- for (const size of allowedSizes) {
1314
- const ratioDiff = Math.abs(size[0] / size[1] - target[0] / target[1])
1315
- const areaDiff = Math.abs(size[0] * size[1] - target[0] * target[1])
1316
- const score = ratioDiff * 10000 + areaDiff
1317
- if (score < bestScore) {
1318
- bestScore = score
1319
- best = size
1320
- }
1321
- }
1322
- return best
1323
- }
1324
-
1325
- // ------------------------------------------------------------------
1326
- // 插件主体
1327
- // ------------------------------------------------------------------
137
+ // ------------------------------------------------------------------
138
+ // 纯函数库已拆分到 lib/(解析 / tag 清洗 / 工作流 / Comfy 等待 / 多人规划 / HTTP 客户端)
139
+ // ------------------------------------------------------------------
140
+ const {
141
+ normalizeBaseUrl, escapeRe, parseGenerationSize, parseBatchCount, parseSeed, parseDenoise,
142
+ stripRawPrefix, parseNameTags, parsePresetList, mergeTagText,
143
+ } = require('./lib/parse')
144
+ const {
145
+ splitTags, canonicalTagText, joinPromptParts, cleanContentTags, appendInlineProtectedTags,
146
+ NO_ARTIST_RE, NO_STYLE_RE,
147
+ } = require('./lib/tags')
148
+ const {
149
+ animaT2IWorkflow, buildWorkflow, animaI2IWorkflow, animaStyleI2IWorkflow,
150
+ animaOotdI2IWorkflow, buildI2IWorkflow, customWorkflow,
151
+ } = require('./lib/workflows')
152
+ const { outputImages, waitComfyResult } = require('./lib/comfy')
153
+ const {
154
+ MULTI_PERSON_NEGATIVE_TAGS, buildMultiPersonPlanPrompt, parseMultiPersonPlan,
155
+ renderMultiPersonCharacter, multiPersonAutoSize,
156
+ } = require('./lib/multi')
157
+ const { buildComfyClient } = require('./lib/http')
1328
158
  exports.apply = async function apply(ctx, cfg) {
1329
159
  // 注意:不在此处 extend p_system 表 —— 该表由 p-qiandao 等 p 系插件创建。
1330
160
  // 重复声明同一张表可能导致 Koishi 的 schema 迁移冲突,拖垮签到插件。
@@ -1367,83 +197,30 @@ exports.apply = async function apply(ctx, cfg) {
1367
197
  return sizes
1368
198
  }
1369
199
 
1370
- async function comfyGet(apiPath, timeout = 20000) {
1371
- const controller = new AbortController()
1372
- const timer = setTimeout(() => controller.abort(), timeout)
1373
- try {
1374
- const res = await fetch(baseUrl() + apiPath, { signal: controller.signal })
1375
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
1376
- return await res.json()
1377
- } finally {
1378
- clearTimeout(timer)
1379
- }
1380
- }
1381
- async function comfyPost(apiPath, body, timeout = 20000) {
1382
- const controller = new AbortController()
1383
- const timer = setTimeout(() => controller.abort(), timeout)
1384
- try {
1385
- const res = await fetch(baseUrl() + apiPath, {
1386
- method: 'POST',
1387
- headers: { 'Content-Type': 'application/json' },
1388
- body: JSON.stringify(body),
1389
- signal: controller.signal,
1390
- })
1391
- if (!res.ok) {
1392
- // ComfyUI /prompt 校验失败时会返回 node_errors 等详细错误,尽量带出来便于排查
1393
- const text = await res.text().catch(() => '')
1394
- const detail = (() => {
1395
- try {
1396
- const data = JSON.parse(text)
1397
- const nodeErrors = (data && data.node_errors) || (data && data.error && data.error.extra_info && data.error.extra_info.node_errors) || null
1398
- if (nodeErrors && typeof nodeErrors === 'object') {
1399
- const lines = Object.entries(nodeErrors).map(([id, e]) => {
1400
- const cls = (e && e.class_type) || ''
1401
- const errs = (e && Array.isArray(e.errors) && e.errors.length)
1402
- ? e.errors.map(x => `${(x && x.message) || ''}${x && x.details ? ' | ' + x.details : ''}`.trim()).join('; ')
1403
- : JSON.stringify(e)
1404
- return ` #${id} [${cls}]: ${errs}`
1405
- })
1406
- if (lines.length) return `\n${lines.join('\n')}`
1407
- }
1408
- if (data && data.error) {
1409
- return `${data.error.message || ''}${data.error.details ? ' ' + data.error.details : ''}`.trim()
1410
- }
1411
- } catch (e) { /* ignore */ }
1412
- return text.slice(0, 800)
1413
- })()
1414
- throw new Error(`HTTP ${res.status}${detail ? ':' + detail : ''}`)
1415
- }
1416
- return await res.json()
1417
- } finally {
1418
- clearTimeout(timer)
1419
- }
1420
- }
1421
- async function comfyGetBytes(apiPath, timeout = 120000) {
1422
- const controller = new AbortController()
1423
- const timer = setTimeout(() => controller.abort(), timeout)
1424
- try {
1425
- const res = await fetch(baseUrl() + apiPath, { signal: controller.signal })
1426
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
1427
- return Buffer.from(await res.arrayBuffer())
1428
- } finally {
1429
- clearTimeout(timer)
1430
- }
1431
- }
200
+ // ComfyUI HTTP 客户端(统一超时与错误详情提取,见 lib/http.js)
201
+ const { get: comfyGet, post: comfyPost, getBytes: comfyGetBytes } = buildComfyClient({ getBaseUrl: baseUrl })
1432
202
 
1433
203
  // ---------------- 状态 ----------------
1434
204
  let objectInfoCache = null
1435
205
  let objectInfoCacheAt = 0
206
+ let objectInfoInFlight = null
1436
207
 
1437
208
  // /object_info 可能返回体巨大或接口本身很慢(自定义节点多),
1438
209
  // 用短超时 + 10 分钟缓存,避免每次状态检查都干等。
210
+ // 并发请求共用同一个 in-flight promise,避免同一时刻重复请求 /object_info。
1439
211
  async function getObjectInfoCached() {
1440
212
  if (objectInfoCache && Date.now() - objectInfoCacheAt < 10 * 60 * 1000) {
1441
213
  return objectInfoCache
1442
214
  }
1443
- const data = await comfyGet('/object_info', 5000)
1444
- objectInfoCache = data
1445
- objectInfoCacheAt = Date.now()
1446
- return data
215
+ if (objectInfoInFlight) return objectInfoInFlight
216
+ objectInfoInFlight = comfyGet('/object_info', 5000)
217
+ .then((data) => {
218
+ objectInfoCache = data
219
+ objectInfoCacheAt = Date.now()
220
+ return data
221
+ })
222
+ .finally(() => { objectInfoInFlight = null })
223
+ return objectInfoInFlight
1447
224
  }
1448
225
 
1449
226
  async function statusPayload() {
@@ -1626,6 +403,107 @@ exports.apply = async function apply(ctx, cfg) {
1626
403
  return { ok: true }
1627
404
  }
1628
405
 
406
+ // ---------------- 共享辅助(三大 handler 共用骨架,去重) ----------------
407
+
408
+ // 画师 tags 解析 + no artist 守卫(用户明确「不要画师/不要风格」时跳过)。
409
+ // 语义:与负面词里的 artist name(去签名/水印)无关,只响应用户的显式拒绝。
410
+ function resolveArtistTags(userPrompt, opts = {}) {
411
+ const presets = parsePresetList(cfg.artistPresets)
412
+ let artistTags = ''
413
+ if (cfg.activeArtistPreset && presets[cfg.activeArtistPreset]) {
414
+ artistTags = presets[cfg.activeArtistPreset]
415
+ } else if (cfg.defaultArtistTags) {
416
+ artistTags = String(cfg.defaultArtistTags).trim()
417
+ }
418
+ if (artistTags && !opts.skipGuard && NO_ARTIST_RE.test(String(userPrompt || ''))) return ''
419
+ return artistTags
420
+ }
421
+
422
+ function resolveStyleTags(userPrompt, opts = {}) {
423
+ if (!cfg.styleTags) return ''
424
+ if (!opts.skipGuard && NO_STYLE_RE.test(String(userPrompt || ''))) return ''
425
+ return String(cfg.styleTags).trim()
426
+ }
427
+
428
+ // P 点余额预检(单图/多人/连续共用)
429
+ async function precheckPoints(session, USERID, isAdmin, totalPrice) {
430
+ if (isAdmin) return { ok: true }
431
+ const notExists = await isAccountExists(USERID)
432
+ if (!notExists) return { ok: false, message: session.text('.account-notExists') }
433
+ const usersdata = await getPUser(USERID)
434
+ const saving = usersdata?.p || 0
435
+ if (saving < totalPrice) return { ok: false, message: session.text('.no-enough-p', [totalPrice]) }
436
+ return { ok: true }
437
+ }
438
+
439
+ // 预排队整批任务:先检查队列容量再入队,杜绝「部分入队后满、退款但任务仍执行」。
440
+ // 任一失败路径都按原逻辑退款(P 已在入队前扣除)。
441
+ async function enqueueBatch(count, work, { USERID, isAdmin, totalPrice }) {
442
+ const tasks = []
443
+ let firstPosition = null
444
+ if (!cfg.queueEnabled) return { ok: true, tasks, firstPosition }
445
+ const maxQueue = queueMax()
446
+ // 容量预检与入队循环之间没有 await,单线程内是原子的
447
+ if (maxQueue && queueInFlight + queueSize + count > maxQueue) {
448
+ if (!isAdmin) await refundP(USERID, totalPrice)
449
+ return { ok: false, message: `生成队列已满(最多 ${maxQueue} 个),本次请求已丢弃,请稍后再试。` }
450
+ }
451
+ for (let i = 0; i < count; i++) {
452
+ const q = enqueue(() => work(i))
453
+ if (!q.ok) {
454
+ if (!isAdmin) await refundP(USERID, totalPrice)
455
+ return { ok: false, message: q.message }
456
+ }
457
+ tasks.push(q.task)
458
+ if (firstPosition == null) firstPosition = q.position
459
+ }
460
+ return { ok: true, tasks, firstPosition }
461
+ }
462
+
463
+ // 即时反馈的公共部分(队列/生成中 + 张数 + 扣费),返回待发送的行
464
+ function feedbackBase(session, { firstPosition, count, totalPrice, isAdmin }) {
465
+ const rows = []
466
+ if (cfg.queueEnabled) {
467
+ rows.push(session.text('.queued', [firstPosition, cfg.queueMaxRequests || '∞']))
468
+ if (count > 1) rows.push(session.text('.batch-count', [count]))
469
+ if (!isAdmin) rows.push(session.text('.charged', [totalPrice]))
470
+ } else {
471
+ rows.push(session.text('.generating'))
472
+ if (count > 1) rows.push(session.text('.batch-count', [count]))
473
+ if (!isAdmin) rows.push(session.text('.charged', [totalPrice]))
474
+ }
475
+ return rows
476
+ }
477
+
478
+ async function sendNotices(session, notices) {
479
+ if (!notices.length) return
480
+ try {
481
+ await session.send(notices.filter(Boolean).join('\n'))
482
+ } catch (e) {
483
+ logger.warn(`发送反馈消息失败:${e.message}`)
484
+ }
485
+ }
486
+
487
+ // 发图:引用用户触发指令的原消息,失败回退普通发送
488
+ async function sendImagesWithQuote(session, outputs) {
489
+ const imageElements = outputs.map(src => h.image(src))
490
+ try {
491
+ await session.send(h.quote(session.messageId) + imageElements.join(''))
492
+ } catch (e) {
493
+ logger.warn(`发送图片失败(引用):${e.message}`)
494
+ await session.send(imageElements)
495
+ }
496
+ }
497
+
498
+ // P 点读改写按用户串行化,避免并发指令互相覆盖余额
499
+ const userLocks = new Map()
500
+ function withUserLock(USERID, fn) {
501
+ const prev = userLocks.get(USERID) || Promise.resolve()
502
+ const next = prev.then(fn, fn)
503
+ userLocks.set(USERID, next.catch(() => {}))
504
+ return next
505
+ }
506
+
1629
507
  // ---------------- 用户自选模型 ----------------
1630
508
  // 从 ComfyUI /object_info(10 分钟缓存)读取真实的 UNET 模型列表
1631
509
  async function listUnetModels() {
@@ -1875,7 +753,7 @@ exports.apply = async function apply(ctx, cfg) {
1875
753
  }
1876
754
  }
1877
755
 
1878
- async function optimizePrompt(session, userPrompt, force = false, img2imgRule = '') {
756
+ async function optimizePrompt(session, userPrompt, force = false, img2imgRule = '', precomputedSearch = null) {
1879
757
  if (!cfg.promptOptimizeEnabled && !force) {
1880
758
  return { ok: true, prompt: userPrompt, reason: 'optimize_disabled' }
1881
759
  }
@@ -1884,7 +762,10 @@ exports.apply = async function apply(ctx, cfg) {
1884
762
  return { ok: false, prompt: userPrompt, reason: 'llm_not_configured' }
1885
763
  }
1886
764
  let searchBlock = ''
1887
- if (wantsWebSearch(userPrompt)) {
765
+ if (precomputedSearch != null) {
766
+ // 批量按张优化时由调用方复用同一份搜索结果,避免重复请求 Tavily
767
+ searchBlock = precomputedSearch
768
+ } else if (wantsWebSearch(userPrompt)) {
1888
769
  searchBlock = await webSearch(userPrompt)
1889
770
  }
1890
771
  const characterRule = buildCharacterRule(userPrompt)
@@ -2013,19 +894,10 @@ exports.apply = async function apply(ctx, cfg) {
2013
894
  }
2014
895
  }
2015
896
  }
2016
- const presets = parsePresetList(cfg.artistPresets)
2017
- let artistTags = ''
2018
- if (cfg.activeArtistPreset && presets[cfg.activeArtistPreset]) {
2019
- artistTags = presets[cfg.activeArtistPreset]
2020
- } else if (cfg.defaultArtistTags) {
2021
- artistTags = String(cfg.defaultArtistTags).trim()
2022
- }
2023
- if (artistTags && !/(不用我的风格|不要我的风格|不使用我的风格|不要画师词|不用画师词|不加画师词|no artist)/i.test(userPrompt)) {
2024
- parts.push(artistTags)
2025
- }
2026
- if (cfg.styleTags && !/(不用我的风格|不要我的风格|不使用我的风格)/i.test(userPrompt)) {
2027
- parts.push(String(cfg.styleTags).trim())
2028
- }
897
+ const artistTags = resolveArtistTags(userPrompt)
898
+ if (artistTags) parts.push(artistTags)
899
+ const styleTags = resolveStyleTags(userPrompt)
900
+ if (styleTags) parts.push(styleTags)
2029
901
  parts.push(userPrompt)
2030
902
  return { prompt: joinPromptParts(parts), degraded: false }
2031
903
  }
@@ -2125,7 +997,7 @@ exports.apply = async function apply(ctx, cfg) {
2125
997
  const aliases = ['Character A', 'Character B', 'Character C', 'Character D']
2126
998
  const characterCount = plan.characters.length
2127
999
  const characterRoles = []
2128
- const characterBlocks = []
1000
+ const characterTagStream = []
2129
1001
  const characterEntityNames = []
2130
1002
  let groupedContact = plan.spatial_mode === 'shared_contact'
2131
1003
  const explicitPositionRequested = /左边|右边|左侧|右侧|前景|后方|前后站位|\bon\s+the\s+(?:left|right)\b|\bforeground\b|\bbackground\b/i.test(prompt)
@@ -2193,7 +1065,7 @@ exports.apply = async function apply(ctx, cfg) {
2193
1065
  )
2194
1066
 
2195
1067
  characterEntityNames.push(new Set([character.name, character.danbooru_candidate, fixedName, resolvedIdentity].filter(Boolean)))
2196
- characterBlocks.push(renderMultiPersonCharacter(character, {
1068
+ characterTagStream.push(renderMultiPersonCharacter(character, {
2197
1069
  alias: visualLabel,
2198
1070
  resolvedIdentity,
2199
1071
  fixedTags: fixedName ? fixedTags : '',
@@ -2201,6 +1073,7 @@ exports.apply = async function apply(ctx, cfg) {
2201
1073
  explicitPositions: spatialMode === 'explicit_positions',
2202
1074
  identityAnchors: renderedIdentityTags,
2203
1075
  includePose: !groupedContact,
1076
+ asTagStream: true,
2204
1077
  }))
2205
1078
  }
2206
1079
 
@@ -2274,7 +1147,6 @@ exports.apply = async function apply(ctx, cfg) {
2274
1147
  return displayed
2275
1148
  })
2276
1149
 
2277
- const sceneGuard = 'The composition shows one shared continuous moment.'
2278
1150
  let relativePosition = ''
2279
1151
  if (spatialMode === 'explicit_positions' && characterCount === 2) {
2280
1152
  const slotAliases = {}
@@ -2286,22 +1158,19 @@ exports.apply = async function apply(ctx, cfg) {
2286
1158
  }
2287
1159
  }
2288
1160
 
2289
- const narrativeBlocks = [...characterBlocks, ...displayInteractions, relativePosition, sceneGuard].filter(Boolean)
1161
+ const narrativeBlocks = [...displayInteractions, relativePosition].filter(Boolean)
2290
1162
 
2291
- // 组装最终提示词:质量词 + 画师组 + content + narrative
1163
+ // 组装最终提示词:质量词 + 画师组 + tag 流(含角色外观)+ narrative 块(仅互动/站位)
2292
1164
  const contentClean = cleanContentTags(commonContent, 65, false, [], true)
2293
1165
  const parts = []
2294
1166
  if (cfg.qualityPrefix) parts.push(String(cfg.qualityPrefix).trim())
2295
- const presets = parsePresetList(cfg.artistPresets)
2296
- let artistTags = ''
2297
- if (cfg.activeArtistPreset && presets[cfg.activeArtistPreset]) {
2298
- artistTags = presets[cfg.activeArtistPreset]
2299
- } else if (cfg.defaultArtistTags) {
2300
- artistTags = String(cfg.defaultArtistTags).trim()
2301
- }
1167
+ // 多人同样遵守 no artist 守卫(与单图一致):用户说「不要画师」时跳过画师 tags
1168
+ const artistTags = resolveArtistTags(prompt)
2302
1169
  if (artistTags) parts.push(artistTags)
2303
- if (cfg.styleTags) parts.push(String(cfg.styleTags).trim())
1170
+ const styleTags = resolveStyleTags(prompt)
1171
+ if (styleTags) parts.push(styleTags)
2304
1172
  parts.push(contentClean || commonContent)
1173
+ if (characterTagStream.length) parts.push(characterTagStream.join(', '))
2305
1174
  let finalPrompt = joinPromptParts(parts)
2306
1175
  if (narrativeBlocks.length) finalPrompt += '\n\n' + narrativeBlocks.join('\n\n')
2307
1176
  return { ok: true, prompt: finalPrompt }
@@ -2449,7 +1318,6 @@ exports.apply = async function apply(ctx, cfg) {
2449
1318
  break
2450
1319
  }
2451
1320
  currentImages = regen.outputs
2452
- currentPrompt = regen.finalPrompt || currentPrompt
2453
1321
  }
2454
1322
 
2455
1323
  // 多候选挑选
@@ -2527,13 +1395,8 @@ exports.apply = async function apply(ctx, cfg) {
2527
1395
  const count = parsedBatch.count
2528
1396
 
2529
1397
  // P 点校验(按总价 = 张数 × 单价)
2530
- if (!isAdmin) {
2531
- const notExists = await isAccountExists(USERID)
2532
- if (!notExists) return session.text('.account-notExists')
2533
- const usersdata = await getPUser(USERID)
2534
- const saving = usersdata?.p || 0
2535
- if (saving < count * price) return session.text('.no-enough-p', [count * price])
2536
- }
1398
+ const pcheck = await precheckPoints(session, USERID, isAdmin, count * price)
1399
+ if (!pcheck.ok) return pcheck.message
2537
1400
 
2538
1401
  // 未指定尺寸时按人数/接触关系自动选横图
2539
1402
  let size = parsedSize.size
@@ -2573,40 +1436,17 @@ exports.apply = async function apply(ctx, cfg) {
2573
1436
  if (cfg.outputLogs) logger.info(`[p-draw] ${USERID} 多人已扣除 ${count * price} P 点(${count} 张 × ${price}),余额 ${saving - count * price}`)
2574
1437
  }
2575
1438
 
2576
- // 队列:预排队全部任务,占满即退回总价
2577
- const queuedTasks = []
2578
- let firstPosition = null
2579
- if (cfg.queueEnabled) {
2580
- for (let i = 0; i < count; i++) {
2581
- const q = enqueue(() => runComfyGenerate(finalPrompt, size, { negativePrompt: multiNegative, unet }))
2582
- if (!q.ok) {
2583
- if (!isAdmin) await refundP(USERID, count * price)
2584
- return q.message
2585
- }
2586
- queuedTasks.push(q.task)
2587
- if (firstPosition == null) firstPosition = q.position
2588
- }
2589
- }
1439
+ // 队列:预排队全部任务(先查容量再入队,占满整体退回总价)
1440
+ const queued = await enqueueBatch(count, (i) => runComfyGenerate(finalPrompt, size, { negativePrompt: multiNegative, unet }), { USERID, isAdmin, totalPrice: count * price })
1441
+ if (!queued.ok) return queued.message
1442
+ const queuedTasks = queued.tasks
1443
+ const firstPosition = queued.firstPosition
2590
1444
 
2591
1445
  // 即时反馈
2592
1446
  const notice = []
2593
1447
  if (parsedBatch.clamped) notice.push(session.text('.batch-limit', [count]))
2594
- if (cfg.queueEnabled) {
2595
- notice.push(session.text('.queued', [firstPosition, cfg.queueMaxRequests || '∞']))
2596
- if (count > 1) notice.push(session.text('.batch-count', [count]))
2597
- if (!isAdmin) notice.push(session.text('.charged', [count * price]))
2598
- } else {
2599
- notice.push(session.text('.generating'))
2600
- if (count > 1) notice.push(session.text('.batch-count', [count]))
2601
- if (!isAdmin) notice.push(session.text('.charged', [count * price]))
2602
- }
2603
- if (notice.length) {
2604
- try {
2605
- await session.send(notice.filter(Boolean).join('\n'))
2606
- } catch (e) {
2607
- logger.warn(`发送反馈消息失败:${e.message}`)
2608
- }
2609
- }
1448
+ notice.push(...feedbackBase(session, { firstPosition, count, totalPrice: count * price, isAdmin }))
1449
+ await sendNotices(session, notice)
2610
1450
 
2611
1451
  // 单张生成 +(可选)视觉校验
2612
1452
  const runOne = async (i) => {
@@ -2650,13 +1490,7 @@ exports.apply = async function apply(ctx, cfg) {
2650
1490
  if (cfg.outputLogs) logger.success(`${USERID} 多人生成成功 ${successCount}/${count} 张`)
2651
1491
 
2652
1492
  // 发图:引用用户触发指令的原消息
2653
- const imageElements = allOutputs.map(src => h.image(src))
2654
- try {
2655
- await session.send(h.quote(session.messageId) + imageElements.join(''))
2656
- } catch (e) {
2657
- logger.warn(`发送多人图片失败:${e.message}`)
2658
- await session.send(imageElements)
2659
- }
1493
+ await sendImagesWithQuote(session, allOutputs)
2660
1494
 
2661
1495
  const reply = []
2662
1496
  if (count > 1) {
@@ -2730,13 +1564,8 @@ exports.apply = async function apply(ctx, cfg) {
2730
1564
  const stageList = stages.slice(0, count)
2731
1565
 
2732
1566
  // P 点校验(按总价)
2733
- if (!isAdmin) {
2734
- const notExists = await isAccountExists(USERID)
2735
- if (!notExists) return session.text('.account-notExists')
2736
- const usersdata = await getPUser(USERID)
2737
- const saving = usersdata?.p || 0
2738
- if (saving < count * price) return session.text('.no-enough-p', [count * price])
2739
- }
1567
+ const pcheck = await precheckPoints(session, USERID, isAdmin, count * price)
1568
+ if (!pcheck.ok) return pcheck.message
2740
1569
 
2741
1570
  // ComfyUI 就绪
2742
1571
  const ready = await ensureComfyuiReady()
@@ -2780,20 +1609,11 @@ exports.apply = async function apply(ctx, cfg) {
2780
1609
  if (cfg.outputLogs) logger.info(`[p-draw] ${USERID} 连续图已扣除 ${count * price} P 点(${count} 阶段 × ${price},seed=${seed}),余额 ${saving - count * price}`)
2781
1610
  }
2782
1611
 
2783
- // 队列:预排队全部阶段
2784
- const queuedTasks = []
2785
- let firstPosition = null
2786
- if (cfg.queueEnabled) {
2787
- for (let i = 0; i < count; i++) {
2788
- const q = enqueue(() => runComfyGenerate(stagePrompts[i], size, { seed, unet }))
2789
- if (!q.ok) {
2790
- if (!isAdmin) await refundP(USERID, count * price)
2791
- return q.message
2792
- }
2793
- queuedTasks.push(q.task)
2794
- if (firstPosition == null) firstPosition = q.position
2795
- }
2796
- }
1612
+ // 队列:预排队全部阶段(先查容量再入队)
1613
+ const queued = await enqueueBatch(count, (i) => runComfyGenerate(stagePrompts[i], size, { seed, unet }), { USERID, isAdmin, totalPrice: count * price })
1614
+ if (!queued.ok) return queued.message
1615
+ const queuedTasks = queued.tasks
1616
+ const firstPosition = queued.firstPosition
2797
1617
 
2798
1618
  // 即时反馈
2799
1619
  const notice = []
@@ -2801,22 +1621,8 @@ exports.apply = async function apply(ctx, cfg) {
2801
1621
  if (identity && fixedChars[identity]) notice.push(`已固定角色「${identity}」的身份 tags,各阶段外观将保持一致。`)
2802
1622
  if (!useLLM) notice.push(session.text('.series-no-llm'))
2803
1623
  if (degradedStages) notice.push(session.text('.prompt-degraded', ['(连续图阶段优化失败,已使用原始描述)']))
2804
- if (cfg.queueEnabled) {
2805
- notice.push(session.text('.queued', [firstPosition, cfg.queueMaxRequests || '∞']))
2806
- if (count > 1) notice.push(session.text('.batch-count', [count]))
2807
- if (!isAdmin) notice.push(session.text('.charged', [count * price]))
2808
- } else {
2809
- notice.push(session.text('.generating'))
2810
- if (count > 1) notice.push(session.text('.batch-count', [count]))
2811
- if (!isAdmin) notice.push(session.text('.charged', [count * price]))
2812
- }
2813
- if (notice.length) {
2814
- try {
2815
- await session.send(notice.filter(Boolean).join('\n'))
2816
- } catch (e) {
2817
- logger.warn(`发送反馈消息失败:${e.message}`)
2818
- }
2819
- }
1624
+ notice.push(...feedbackBase(session, { firstPosition, count, totalPrice: count * price, isAdmin }))
1625
+ await sendNotices(session, notice)
2820
1626
 
2821
1627
  // 单阶段生成(共用 seed)
2822
1628
  const runOne = async (i) => {
@@ -2852,13 +1658,7 @@ exports.apply = async function apply(ctx, cfg) {
2852
1658
  if (cfg.outputLogs) logger.success(`${USERID} 连续图生成成功 ${successCount}/${count} 阶段(seed=${seed})`)
2853
1659
 
2854
1660
  // 发图:引用用户触发指令的原消息
2855
- const imageElements = allOutputs.map(src => h.image(src))
2856
- try {
2857
- await session.send(h.quote(session.messageId) + imageElements.join(''))
2858
- } catch (e) {
2859
- logger.warn(`发送连续图失败:${e.message}`)
2860
- await session.send(imageElements)
2861
- }
1661
+ await sendImagesWithQuote(session, allOutputs)
2862
1662
 
2863
1663
  const reply = []
2864
1664
  reply.push(session.text('.series-ok', [count * price, successCount, seed]))
@@ -2927,16 +1727,20 @@ exports.apply = async function apply(ctx, cfg) {
2927
1727
  }
2928
1728
 
2929
1729
  async function deductP(USERID, amount) {
2930
- const user = await getPUser(USERID)
2931
- const current = user?.p || 0
2932
- await ctx.database.set('p_system', { userid: USERID }, { p: Math.max(0, current - amount) })
2933
- return current
1730
+ return withUserLock(USERID, async () => {
1731
+ const user = await getPUser(USERID)
1732
+ const current = user?.p || 0
1733
+ await ctx.database.set('p_system', { userid: USERID }, { p: Math.max(0, current - amount) })
1734
+ return current
1735
+ })
2934
1736
  }
2935
1737
 
2936
1738
  async function refundP(USERID, amount) {
2937
- const user = await getPUser(USERID)
2938
- const current = user?.p || 0
2939
- await ctx.database.set('p_system', { userid: USERID }, { p: current + amount })
1739
+ return withUserLock(USERID, async () => {
1740
+ const user = await getPUser(USERID)
1741
+ const current = user?.p || 0
1742
+ await ctx.database.set('p_system', { userid: USERID }, { p: current + amount })
1743
+ })
2940
1744
  }
2941
1745
 
2942
1746
  // ---------------- 画师组/角色管理(持久化到数据库,避免 scope.update 触发重载) ----------------
@@ -3258,7 +2062,13 @@ exports.apply = async function apply(ctx, cfg) {
3258
2062
  if (!src) return null
3259
2063
  try {
3260
2064
  if (/^file:\/\//i.test(src)) {
3261
- const filePath = src.replace(/^file:\/\//i, '')
2065
+ // 修复:Windows file:///K:/... 用 replace 会得到 /K:/...(无法读取),用 fileURLToPath 解析
2066
+ let filePath
2067
+ try {
2068
+ filePath = fileURLToPath(src)
2069
+ } catch (e) {
2070
+ filePath = src.replace(/^file:\/\//i, '')
2071
+ }
3262
2072
  const buffer = await fsp.readFile(filePath)
3263
2073
  return { buffer, ext: path.extname(filePath) || '.png' }
3264
2074
  }
@@ -3391,13 +2201,9 @@ exports.apply = async function apply(ctx, cfg) {
3391
2201
  const count = parsedBatch.count
3392
2202
 
3393
2203
  // P 点校验(按总价 = 张数 × 单价)
3394
- if (!isAdmin) {
3395
- const notExists = await isAccountExists(USERID)
3396
- if (!notExists) return session.text('.account-notExists')
3397
- const usersdata = await getPUser(USERID)
3398
- const saving = usersdata?.p || 0
3399
- if (saving < count * cfg.price) return session.text('.no-enough-p', [count * cfg.price])
3400
- }
2204
+ // P 点校验(按总价 = 张数 × 单价)
2205
+ const pcheck = await precheckPoints(session, USERID, isAdmin, count * cfg.price)
2206
+ if (!pcheck.ok) return pcheck.message
3401
2207
 
3402
2208
  // 原样模式
3403
2209
  const stripped = stripRawPrefix(text)
@@ -3487,27 +2293,21 @@ exports.apply = async function apply(ctx, cfg) {
3487
2293
  ? { i2iImage, i2i: { mode: (i2iOpts && i2iOpts.mode) || 'plain', denoise: denoise != null ? denoise : null, caps: (i2iOpts && i2iOpts.caps) || null } }
3488
2294
  : {}
3489
2295
 
3490
- // 队列:预排队全部任务,占满即退回总价
3491
- const queuedTasks = []
3492
- let firstPosition = null
3493
- if (cfg.queueEnabled) {
3494
- for (let i = 0; i < count; i++) {
3495
- const q = enqueue(async () => {
3496
- let p = finalPrompt
3497
- if (perImageOptimize) {
3498
- const optimized = await optimizePrompt(session, userPrompt, true, i2iRule)
3499
- p = appendInlineProtectedTags(composePrompt(optimized.prompt || userPrompt, raw).prompt, userPrompt, raw)
3500
- }
3501
- return runComfyGenerate(p, parsedSize.size, Object.assign({ unet, seed }, i2iRun))
3502
- })
3503
- if (!q.ok) {
3504
- if (!isAdmin) await refundP(USERID, count * cfg.price)
3505
- return q.message
3506
- }
3507
- queuedTasks.push(q.task)
3508
- if (firstPosition == null) firstPosition = q.position
2296
+ // 性能:按张优化(perImageOptimize)时联网搜索只做一次,各图复用同一份结果
2297
+ const searchCache = perImageOptimize && wantsWebSearch(userPrompt) ? await webSearch(userPrompt) : null
2298
+
2299
+ // 队列:预排队全部任务(先查容量再入队,占满整体退回总价)
2300
+ const queued = await enqueueBatch(count, async (i) => {
2301
+ let p = finalPrompt
2302
+ if (perImageOptimize) {
2303
+ const optimized = await optimizePrompt(session, userPrompt, true, i2iRule, searchCache)
2304
+ p = appendInlineProtectedTags(composePrompt(optimized.prompt || userPrompt, raw).prompt, userPrompt, raw)
3509
2305
  }
3510
- }
2306
+ return runComfyGenerate(p, parsedSize.size, Object.assign({ unet, seed }, i2iRun))
2307
+ }, { USERID, isAdmin, totalPrice: count * cfg.price })
2308
+ if (!queued.ok) return queued.message
2309
+ const queuedTasks = queued.tasks
2310
+ const firstPosition = queued.firstPosition
3511
2311
 
3512
2312
  // 先发一条即时反馈(扣费结果 / 队列位置 / 降级提示),
3513
2313
  // 确保用户不会以为指令没反应。
@@ -3526,28 +2326,14 @@ exports.apply = async function apply(ctx, cfg) {
3526
2326
  notice.push(session.text('.no-optimize', [reasons[noOptimizeReason] || noOptimizeReason]))
3527
2327
  }
3528
2328
  if (parsedBatch.clamped) notice.push(session.text('.batch-limit', [count]))
3529
- if (cfg.queueEnabled) {
3530
- notice.push(session.text('.queued', [firstPosition, cfg.queueMaxRequests || '∞']))
3531
- if (count > 1) notice.push(session.text('.batch-count', [count]))
3532
- if (!isAdmin) notice.push(session.text('.charged', [count * cfg.price]))
3533
- } else {
3534
- notice.push(session.text('.generating'))
3535
- if (count > 1) notice.push(session.text('.batch-count', [count]))
3536
- if (!isAdmin) notice.push(session.text('.charged', [count * cfg.price]))
3537
- }
3538
- if (notice.length) {
3539
- try {
3540
- await session.send(notice.filter(Boolean).join('\n'))
3541
- } catch (e) {
3542
- logger.warn(`发送反馈消息失败:${e.message}`)
3543
- }
3544
- }
2329
+ notice.push(...feedbackBase(session, { firstPosition, count, totalPrice: count * cfg.price, isAdmin }))
2330
+ await sendNotices(session, notice)
3545
2331
 
3546
2332
  // 单张生成
3547
2333
  const runOne = async (i) => {
3548
2334
  let p = finalPrompt
3549
2335
  if (perImageOptimize) {
3550
- const optimized = await optimizePrompt(session, userPrompt, true, i2iRule)
2336
+ const optimized = await optimizePrompt(session, userPrompt, true, i2iRule, searchCache)
3551
2337
  p = appendInlineProtectedTags(composePrompt(optimized.prompt || userPrompt, raw).prompt, userPrompt, raw)
3552
2338
  }
3553
2339
  let result
@@ -3581,14 +2367,8 @@ exports.apply = async function apply(ctx, cfg) {
3581
2367
 
3582
2368
  if (cfg.outputLogs) logger.success(`${USERID} 生成成功 ${successCount}/${count} 张`)
3583
2369
 
3584
- const imageElements = allOutputs.map(src => h.image(src))
3585
- // 引用用户触发指令的原消息
3586
- try {
3587
- await session.send(h.quote(session.messageId) + imageElements.join(''))
3588
- } catch (e) {
3589
- logger.warn(`发送图片失败(引用):${e.message}`)
3590
- await session.send(imageElements)
3591
- }
2370
+ // 发图:引用用户触发指令的原消息
2371
+ await sendImagesWithQuote(session, allOutputs)
3592
2372
 
3593
2373
  const reply = []
3594
2374
  if (count > 1) {
@@ -3629,13 +2409,13 @@ exports.apply = async function apply(ctx, cfg) {
3629
2409
  // 没券/券不足:问是否购买(显示价格);拒绝一次再警告并问第二次,再拒绝直接生图
3630
2410
  const askBuy = async () => {
3631
2411
  await session.send(session.text('.coupon-ask-buy', [count, tokens, couponPrice, count * couponPrice]))
3632
- const reply = await session.prompt(cfg.couponAskTimeout * 1000)
2412
+ const reply = await session.prompt(cfg.couponAskTimeout * 1000).catch(() => null)
3633
2413
  return normalizeConfirm(reply)
3634
2414
  }
3635
2415
  let ans = await askBuy()
3636
2416
  if (ans === false) {
3637
2417
  await session.send(session.text('.coupon-buy-warn'))
3638
- const reply = await session.prompt(cfg.couponAskTimeout * 1000)
2418
+ const reply = await session.prompt(cfg.couponAskTimeout * 1000).catch(() => null)
3639
2419
  ans = normalizeConfirm(reply)
3640
2420
  if (ans !== true) {
3641
2421
  await session.send(session.text('.coupon-buy-cancelled'))
@@ -3693,8 +2473,15 @@ exports.apply = async function apply(ctx, cfg) {
3693
2473
  return null
3694
2474
  }
3695
2475
 
3696
- // 提示词优化券单价:优先读 data/p-shop.json 里覆盖的价格,否则用配置 couponPrice
2476
+ // 提示词优化券单价:优先读 data/p-shop.json 里覆盖的价格,否则用配置 couponPrice
2477
+ // 加 60 秒 TTL 缓存,避免每次购买询问都同步读盘。
2478
+ let couponPriceCache = null
2479
+ let couponPriceCacheAt = 0
3697
2480
  async function resolveCouponPrice() {
2481
+ if (couponPriceCache != null && Date.now() - couponPriceCacheAt < 60 * 1000) {
2482
+ return couponPriceCache
2483
+ }
2484
+ let price = cfg.couponPrice
3698
2485
  const candidates = [
3699
2486
  path.join(ctx.baseDir, 'data', 'p-shop.json'),
3700
2487
  path.join(process.cwd(), 'data', 'p-shop.json'),
@@ -3705,14 +2492,19 @@ exports.apply = async function apply(ctx, cfg) {
3705
2492
  const data = JSON.parse(fs.readFileSync(f, 'utf-8'))
3706
2493
  if (data && typeof data === 'object') {
3707
2494
  const item = data['提示词优化券']
3708
- if (item && typeof item.price === 'number' && item.price > 0) return item.price
2495
+ if (item && typeof item.price === 'number' && item.price > 0) {
2496
+ price = item.price
2497
+ break
2498
+ }
3709
2499
  }
3710
2500
  }
3711
2501
  } catch (e) {
3712
2502
  logger.warn(`读取 p-shop.json 价格失败:${e.message}`)
3713
2503
  }
3714
2504
  }
3715
- return cfg.couponPrice
2505
+ couponPriceCache = price
2506
+ couponPriceCacheAt = Date.now()
2507
+ return price
3716
2508
  }
3717
2509
 
3718
2510
  // 启动时合并数据库里保存的运行时配置(画师组/固定角色)