koishi-plugin-p-draw 1.2.13 → 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,1050 +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, asTagStream = false,
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
- if (asTagStream) return [label, joined].filter(Boolean).join(', ')
1287
- return `${label}: ${joined}.`
1288
- }
1289
-
1290
- // ------------------------------------------------------------------
1291
- // 多人尺寸自动选择(移植自 anima command_actions multi_person 分支)
1292
- // ------------------------------------------------------------------
1293
- function multiPersonAutoSize(prompt, allowedSizes) {
1294
- if (!Array.isArray(allowedSizes) || !allowedSizes.length) return null
1295
- const promptLower = String(prompt || '').toLowerCase()
1296
- const threeOrMore = /\b(?:三|四|3|4)\s*(?:人|个|名|girls?|boys?|people)\b|\b(?:3|4)(?:girls?|boys?|people)\b/.test(promptLower)
1297
- const verticallyStacked = [
1298
- '骑在肩', '骑肩', '肩膀上', '背着', '抱起', '扑倒', '压在', '上下叠',
1299
- 'on the shoulders', 'piggyback', 'carrying', 'on top of', 'stacked',
1300
- ].some(marker => promptLower.includes(marker))
1301
- const physicalContact = [
1302
- '牵手', '拥抱', '接吻', '搂着', '抱着', '挽着',
1303
- 'holding hands', 'hugging', 'embracing', 'kissing', 'arm around',
1304
- ].some(marker => promptLower.includes(marker))
1305
- const target = threeOrMore
1306
- ? [1216, 832]
1307
- : verticallyStacked
1308
- ? [1024, 1536]
1309
- : physicalContact
1310
- ? [1024, 1024]
1311
- : [1152, 896]
1312
- let best = allowedSizes[0]
1313
- let bestScore = Infinity
1314
- for (const size of allowedSizes) {
1315
- const ratioDiff = Math.abs(size[0] / size[1] - target[0] / target[1])
1316
- const areaDiff = Math.abs(size[0] * size[1] - target[0] * target[1])
1317
- const score = ratioDiff * 10000 + areaDiff
1318
- if (score < bestScore) {
1319
- bestScore = score
1320
- best = size
1321
- }
1322
- }
1323
- return best
1324
- }
1325
-
1326
- // ------------------------------------------------------------------
1327
- // 插件主体
1328
- // ------------------------------------------------------------------
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')
1329
158
  exports.apply = async function apply(ctx, cfg) {
1330
159
  // 注意:不在此处 extend p_system 表 —— 该表由 p-qiandao 等 p 系插件创建。
1331
160
  // 重复声明同一张表可能导致 Koishi 的 schema 迁移冲突,拖垮签到插件。
@@ -1368,83 +197,30 @@ exports.apply = async function apply(ctx, cfg) {
1368
197
  return sizes
1369
198
  }
1370
199
 
1371
- async function comfyGet(apiPath, timeout = 20000) {
1372
- const controller = new AbortController()
1373
- const timer = setTimeout(() => controller.abort(), timeout)
1374
- try {
1375
- const res = await fetch(baseUrl() + apiPath, { signal: controller.signal })
1376
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
1377
- return await res.json()
1378
- } finally {
1379
- clearTimeout(timer)
1380
- }
1381
- }
1382
- async function comfyPost(apiPath, body, timeout = 20000) {
1383
- const controller = new AbortController()
1384
- const timer = setTimeout(() => controller.abort(), timeout)
1385
- try {
1386
- const res = await fetch(baseUrl() + apiPath, {
1387
- method: 'POST',
1388
- headers: { 'Content-Type': 'application/json' },
1389
- body: JSON.stringify(body),
1390
- signal: controller.signal,
1391
- })
1392
- if (!res.ok) {
1393
- // ComfyUI /prompt 校验失败时会返回 node_errors 等详细错误,尽量带出来便于排查
1394
- const text = await res.text().catch(() => '')
1395
- const detail = (() => {
1396
- try {
1397
- const data = JSON.parse(text)
1398
- const nodeErrors = (data && data.node_errors) || (data && data.error && data.error.extra_info && data.error.extra_info.node_errors) || null
1399
- if (nodeErrors && typeof nodeErrors === 'object') {
1400
- const lines = Object.entries(nodeErrors).map(([id, e]) => {
1401
- const cls = (e && e.class_type) || ''
1402
- const errs = (e && Array.isArray(e.errors) && e.errors.length)
1403
- ? e.errors.map(x => `${(x && x.message) || ''}${x && x.details ? ' | ' + x.details : ''}`.trim()).join('; ')
1404
- : JSON.stringify(e)
1405
- return ` #${id} [${cls}]: ${errs}`
1406
- })
1407
- if (lines.length) return `\n${lines.join('\n')}`
1408
- }
1409
- if (data && data.error) {
1410
- return `${data.error.message || ''}${data.error.details ? ' ' + data.error.details : ''}`.trim()
1411
- }
1412
- } catch (e) { /* ignore */ }
1413
- return text.slice(0, 800)
1414
- })()
1415
- throw new Error(`HTTP ${res.status}${detail ? ':' + detail : ''}`)
1416
- }
1417
- return await res.json()
1418
- } finally {
1419
- clearTimeout(timer)
1420
- }
1421
- }
1422
- async function comfyGetBytes(apiPath, timeout = 120000) {
1423
- const controller = new AbortController()
1424
- const timer = setTimeout(() => controller.abort(), timeout)
1425
- try {
1426
- const res = await fetch(baseUrl() + apiPath, { signal: controller.signal })
1427
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
1428
- return Buffer.from(await res.arrayBuffer())
1429
- } finally {
1430
- clearTimeout(timer)
1431
- }
1432
- }
200
+ // ComfyUI HTTP 客户端(统一超时与错误详情提取,见 lib/http.js)
201
+ const { get: comfyGet, post: comfyPost, getBytes: comfyGetBytes } = buildComfyClient({ getBaseUrl: baseUrl })
1433
202
 
1434
203
  // ---------------- 状态 ----------------
1435
204
  let objectInfoCache = null
1436
205
  let objectInfoCacheAt = 0
206
+ let objectInfoInFlight = null
1437
207
 
1438
208
  // /object_info 可能返回体巨大或接口本身很慢(自定义节点多),
1439
209
  // 用短超时 + 10 分钟缓存,避免每次状态检查都干等。
210
+ // 并发请求共用同一个 in-flight promise,避免同一时刻重复请求 /object_info。
1440
211
  async function getObjectInfoCached() {
1441
212
  if (objectInfoCache && Date.now() - objectInfoCacheAt < 10 * 60 * 1000) {
1442
213
  return objectInfoCache
1443
214
  }
1444
- const data = await comfyGet('/object_info', 5000)
1445
- objectInfoCache = data
1446
- objectInfoCacheAt = Date.now()
1447
- 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
1448
224
  }
1449
225
 
1450
226
  async function statusPayload() {
@@ -1627,6 +403,107 @@ exports.apply = async function apply(ctx, cfg) {
1627
403
  return { ok: true }
1628
404
  }
1629
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
+
1630
507
  // ---------------- 用户自选模型 ----------------
1631
508
  // 从 ComfyUI /object_info(10 分钟缓存)读取真实的 UNET 模型列表
1632
509
  async function listUnetModels() {
@@ -1876,7 +753,7 @@ exports.apply = async function apply(ctx, cfg) {
1876
753
  }
1877
754
  }
1878
755
 
1879
- async function optimizePrompt(session, userPrompt, force = false, img2imgRule = '') {
756
+ async function optimizePrompt(session, userPrompt, force = false, img2imgRule = '', precomputedSearch = null) {
1880
757
  if (!cfg.promptOptimizeEnabled && !force) {
1881
758
  return { ok: true, prompt: userPrompt, reason: 'optimize_disabled' }
1882
759
  }
@@ -1885,7 +762,10 @@ exports.apply = async function apply(ctx, cfg) {
1885
762
  return { ok: false, prompt: userPrompt, reason: 'llm_not_configured' }
1886
763
  }
1887
764
  let searchBlock = ''
1888
- if (wantsWebSearch(userPrompt)) {
765
+ if (precomputedSearch != null) {
766
+ // 批量按张优化时由调用方复用同一份搜索结果,避免重复请求 Tavily
767
+ searchBlock = precomputedSearch
768
+ } else if (wantsWebSearch(userPrompt)) {
1889
769
  searchBlock = await webSearch(userPrompt)
1890
770
  }
1891
771
  const characterRule = buildCharacterRule(userPrompt)
@@ -2014,19 +894,10 @@ exports.apply = async function apply(ctx, cfg) {
2014
894
  }
2015
895
  }
2016
896
  }
2017
- const presets = parsePresetList(cfg.artistPresets)
2018
- let artistTags = ''
2019
- if (cfg.activeArtistPreset && presets[cfg.activeArtistPreset]) {
2020
- artistTags = presets[cfg.activeArtistPreset]
2021
- } else if (cfg.defaultArtistTags) {
2022
- artistTags = String(cfg.defaultArtistTags).trim()
2023
- }
2024
- if (artistTags && !/(不用我的风格|不要我的风格|不使用我的风格|不要画师词|不用画师词|不加画师词|no artist)/i.test(userPrompt)) {
2025
- parts.push(artistTags)
2026
- }
2027
- if (cfg.styleTags && !/(不用我的风格|不要我的风格|不使用我的风格)/i.test(userPrompt)) {
2028
- parts.push(String(cfg.styleTags).trim())
2029
- }
897
+ const artistTags = resolveArtistTags(userPrompt)
898
+ if (artistTags) parts.push(artistTags)
899
+ const styleTags = resolveStyleTags(userPrompt)
900
+ if (styleTags) parts.push(styleTags)
2030
901
  parts.push(userPrompt)
2031
902
  return { prompt: joinPromptParts(parts), degraded: false }
2032
903
  }
@@ -2293,15 +1164,11 @@ exports.apply = async function apply(ctx, cfg) {
2293
1164
  const contentClean = cleanContentTags(commonContent, 65, false, [], true)
2294
1165
  const parts = []
2295
1166
  if (cfg.qualityPrefix) parts.push(String(cfg.qualityPrefix).trim())
2296
- const presets = parsePresetList(cfg.artistPresets)
2297
- let artistTags = ''
2298
- if (cfg.activeArtistPreset && presets[cfg.activeArtistPreset]) {
2299
- artistTags = presets[cfg.activeArtistPreset]
2300
- } else if (cfg.defaultArtistTags) {
2301
- artistTags = String(cfg.defaultArtistTags).trim()
2302
- }
1167
+ // 多人同样遵守 no artist 守卫(与单图一致):用户说「不要画师」时跳过画师 tags
1168
+ const artistTags = resolveArtistTags(prompt)
2303
1169
  if (artistTags) parts.push(artistTags)
2304
- if (cfg.styleTags) parts.push(String(cfg.styleTags).trim())
1170
+ const styleTags = resolveStyleTags(prompt)
1171
+ if (styleTags) parts.push(styleTags)
2305
1172
  parts.push(contentClean || commonContent)
2306
1173
  if (characterTagStream.length) parts.push(characterTagStream.join(', '))
2307
1174
  let finalPrompt = joinPromptParts(parts)
@@ -2451,7 +1318,6 @@ exports.apply = async function apply(ctx, cfg) {
2451
1318
  break
2452
1319
  }
2453
1320
  currentImages = regen.outputs
2454
- currentPrompt = regen.finalPrompt || currentPrompt
2455
1321
  }
2456
1322
 
2457
1323
  // 多候选挑选
@@ -2529,13 +1395,8 @@ exports.apply = async function apply(ctx, cfg) {
2529
1395
  const count = parsedBatch.count
2530
1396
 
2531
1397
  // P 点校验(按总价 = 张数 × 单价)
2532
- if (!isAdmin) {
2533
- const notExists = await isAccountExists(USERID)
2534
- if (!notExists) return session.text('.account-notExists')
2535
- const usersdata = await getPUser(USERID)
2536
- const saving = usersdata?.p || 0
2537
- if (saving < count * price) return session.text('.no-enough-p', [count * price])
2538
- }
1398
+ const pcheck = await precheckPoints(session, USERID, isAdmin, count * price)
1399
+ if (!pcheck.ok) return pcheck.message
2539
1400
 
2540
1401
  // 未指定尺寸时按人数/接触关系自动选横图
2541
1402
  let size = parsedSize.size
@@ -2575,40 +1436,17 @@ exports.apply = async function apply(ctx, cfg) {
2575
1436
  if (cfg.outputLogs) logger.info(`[p-draw] ${USERID} 多人已扣除 ${count * price} P 点(${count} 张 × ${price}),余额 ${saving - count * price}`)
2576
1437
  }
2577
1438
 
2578
- // 队列:预排队全部任务,占满即退回总价
2579
- const queuedTasks = []
2580
- let firstPosition = null
2581
- if (cfg.queueEnabled) {
2582
- for (let i = 0; i < count; i++) {
2583
- const q = enqueue(() => runComfyGenerate(finalPrompt, size, { negativePrompt: multiNegative, unet }))
2584
- if (!q.ok) {
2585
- if (!isAdmin) await refundP(USERID, count * price)
2586
- return q.message
2587
- }
2588
- queuedTasks.push(q.task)
2589
- if (firstPosition == null) firstPosition = q.position
2590
- }
2591
- }
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
2592
1444
 
2593
1445
  // 即时反馈
2594
1446
  const notice = []
2595
1447
  if (parsedBatch.clamped) notice.push(session.text('.batch-limit', [count]))
2596
- if (cfg.queueEnabled) {
2597
- notice.push(session.text('.queued', [firstPosition, cfg.queueMaxRequests || '∞']))
2598
- if (count > 1) notice.push(session.text('.batch-count', [count]))
2599
- if (!isAdmin) notice.push(session.text('.charged', [count * price]))
2600
- } else {
2601
- notice.push(session.text('.generating'))
2602
- if (count > 1) notice.push(session.text('.batch-count', [count]))
2603
- if (!isAdmin) notice.push(session.text('.charged', [count * price]))
2604
- }
2605
- if (notice.length) {
2606
- try {
2607
- await session.send(notice.filter(Boolean).join('\n'))
2608
- } catch (e) {
2609
- logger.warn(`发送反馈消息失败:${e.message}`)
2610
- }
2611
- }
1448
+ notice.push(...feedbackBase(session, { firstPosition, count, totalPrice: count * price, isAdmin }))
1449
+ await sendNotices(session, notice)
2612
1450
 
2613
1451
  // 单张生成 +(可选)视觉校验
2614
1452
  const runOne = async (i) => {
@@ -2652,13 +1490,7 @@ exports.apply = async function apply(ctx, cfg) {
2652
1490
  if (cfg.outputLogs) logger.success(`${USERID} 多人生成成功 ${successCount}/${count} 张`)
2653
1491
 
2654
1492
  // 发图:引用用户触发指令的原消息
2655
- const imageElements = allOutputs.map(src => h.image(src))
2656
- try {
2657
- await session.send(h.quote(session.messageId) + imageElements.join(''))
2658
- } catch (e) {
2659
- logger.warn(`发送多人图片失败:${e.message}`)
2660
- await session.send(imageElements)
2661
- }
1493
+ await sendImagesWithQuote(session, allOutputs)
2662
1494
 
2663
1495
  const reply = []
2664
1496
  if (count > 1) {
@@ -2732,13 +1564,8 @@ exports.apply = async function apply(ctx, cfg) {
2732
1564
  const stageList = stages.slice(0, count)
2733
1565
 
2734
1566
  // P 点校验(按总价)
2735
- if (!isAdmin) {
2736
- const notExists = await isAccountExists(USERID)
2737
- if (!notExists) return session.text('.account-notExists')
2738
- const usersdata = await getPUser(USERID)
2739
- const saving = usersdata?.p || 0
2740
- if (saving < count * price) return session.text('.no-enough-p', [count * price])
2741
- }
1567
+ const pcheck = await precheckPoints(session, USERID, isAdmin, count * price)
1568
+ if (!pcheck.ok) return pcheck.message
2742
1569
 
2743
1570
  // ComfyUI 就绪
2744
1571
  const ready = await ensureComfyuiReady()
@@ -2782,20 +1609,11 @@ exports.apply = async function apply(ctx, cfg) {
2782
1609
  if (cfg.outputLogs) logger.info(`[p-draw] ${USERID} 连续图已扣除 ${count * price} P 点(${count} 阶段 × ${price},seed=${seed}),余额 ${saving - count * price}`)
2783
1610
  }
2784
1611
 
2785
- // 队列:预排队全部阶段
2786
- const queuedTasks = []
2787
- let firstPosition = null
2788
- if (cfg.queueEnabled) {
2789
- for (let i = 0; i < count; i++) {
2790
- const q = enqueue(() => runComfyGenerate(stagePrompts[i], size, { seed, unet }))
2791
- if (!q.ok) {
2792
- if (!isAdmin) await refundP(USERID, count * price)
2793
- return q.message
2794
- }
2795
- queuedTasks.push(q.task)
2796
- if (firstPosition == null) firstPosition = q.position
2797
- }
2798
- }
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
2799
1617
 
2800
1618
  // 即时反馈
2801
1619
  const notice = []
@@ -2803,22 +1621,8 @@ exports.apply = async function apply(ctx, cfg) {
2803
1621
  if (identity && fixedChars[identity]) notice.push(`已固定角色「${identity}」的身份 tags,各阶段外观将保持一致。`)
2804
1622
  if (!useLLM) notice.push(session.text('.series-no-llm'))
2805
1623
  if (degradedStages) notice.push(session.text('.prompt-degraded', ['(连续图阶段优化失败,已使用原始描述)']))
2806
- if (cfg.queueEnabled) {
2807
- notice.push(session.text('.queued', [firstPosition, cfg.queueMaxRequests || '∞']))
2808
- if (count > 1) notice.push(session.text('.batch-count', [count]))
2809
- if (!isAdmin) notice.push(session.text('.charged', [count * price]))
2810
- } else {
2811
- notice.push(session.text('.generating'))
2812
- if (count > 1) notice.push(session.text('.batch-count', [count]))
2813
- if (!isAdmin) notice.push(session.text('.charged', [count * price]))
2814
- }
2815
- if (notice.length) {
2816
- try {
2817
- await session.send(notice.filter(Boolean).join('\n'))
2818
- } catch (e) {
2819
- logger.warn(`发送反馈消息失败:${e.message}`)
2820
- }
2821
- }
1624
+ notice.push(...feedbackBase(session, { firstPosition, count, totalPrice: count * price, isAdmin }))
1625
+ await sendNotices(session, notice)
2822
1626
 
2823
1627
  // 单阶段生成(共用 seed)
2824
1628
  const runOne = async (i) => {
@@ -2854,13 +1658,7 @@ exports.apply = async function apply(ctx, cfg) {
2854
1658
  if (cfg.outputLogs) logger.success(`${USERID} 连续图生成成功 ${successCount}/${count} 阶段(seed=${seed})`)
2855
1659
 
2856
1660
  // 发图:引用用户触发指令的原消息
2857
- const imageElements = allOutputs.map(src => h.image(src))
2858
- try {
2859
- await session.send(h.quote(session.messageId) + imageElements.join(''))
2860
- } catch (e) {
2861
- logger.warn(`发送连续图失败:${e.message}`)
2862
- await session.send(imageElements)
2863
- }
1661
+ await sendImagesWithQuote(session, allOutputs)
2864
1662
 
2865
1663
  const reply = []
2866
1664
  reply.push(session.text('.series-ok', [count * price, successCount, seed]))
@@ -2929,16 +1727,20 @@ exports.apply = async function apply(ctx, cfg) {
2929
1727
  }
2930
1728
 
2931
1729
  async function deductP(USERID, amount) {
2932
- const user = await getPUser(USERID)
2933
- const current = user?.p || 0
2934
- await ctx.database.set('p_system', { userid: USERID }, { p: Math.max(0, current - amount) })
2935
- 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
+ })
2936
1736
  }
2937
1737
 
2938
1738
  async function refundP(USERID, amount) {
2939
- const user = await getPUser(USERID)
2940
- const current = user?.p || 0
2941
- 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
+ })
2942
1744
  }
2943
1745
 
2944
1746
  // ---------------- 画师组/角色管理(持久化到数据库,避免 scope.update 触发重载) ----------------
@@ -3260,7 +2062,13 @@ exports.apply = async function apply(ctx, cfg) {
3260
2062
  if (!src) return null
3261
2063
  try {
3262
2064
  if (/^file:\/\//i.test(src)) {
3263
- 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
+ }
3264
2072
  const buffer = await fsp.readFile(filePath)
3265
2073
  return { buffer, ext: path.extname(filePath) || '.png' }
3266
2074
  }
@@ -3393,13 +2201,9 @@ exports.apply = async function apply(ctx, cfg) {
3393
2201
  const count = parsedBatch.count
3394
2202
 
3395
2203
  // P 点校验(按总价 = 张数 × 单价)
3396
- if (!isAdmin) {
3397
- const notExists = await isAccountExists(USERID)
3398
- if (!notExists) return session.text('.account-notExists')
3399
- const usersdata = await getPUser(USERID)
3400
- const saving = usersdata?.p || 0
3401
- if (saving < count * cfg.price) return session.text('.no-enough-p', [count * cfg.price])
3402
- }
2204
+ // P 点校验(按总价 = 张数 × 单价)
2205
+ const pcheck = await precheckPoints(session, USERID, isAdmin, count * cfg.price)
2206
+ if (!pcheck.ok) return pcheck.message
3403
2207
 
3404
2208
  // 原样模式
3405
2209
  const stripped = stripRawPrefix(text)
@@ -3489,27 +2293,21 @@ exports.apply = async function apply(ctx, cfg) {
3489
2293
  ? { i2iImage, i2i: { mode: (i2iOpts && i2iOpts.mode) || 'plain', denoise: denoise != null ? denoise : null, caps: (i2iOpts && i2iOpts.caps) || null } }
3490
2294
  : {}
3491
2295
 
3492
- // 队列:预排队全部任务,占满即退回总价
3493
- const queuedTasks = []
3494
- let firstPosition = null
3495
- if (cfg.queueEnabled) {
3496
- for (let i = 0; i < count; i++) {
3497
- const q = enqueue(async () => {
3498
- let p = finalPrompt
3499
- if (perImageOptimize) {
3500
- const optimized = await optimizePrompt(session, userPrompt, true, i2iRule)
3501
- p = appendInlineProtectedTags(composePrompt(optimized.prompt || userPrompt, raw).prompt, userPrompt, raw)
3502
- }
3503
- return runComfyGenerate(p, parsedSize.size, Object.assign({ unet, seed }, i2iRun))
3504
- })
3505
- if (!q.ok) {
3506
- if (!isAdmin) await refundP(USERID, count * cfg.price)
3507
- return q.message
3508
- }
3509
- queuedTasks.push(q.task)
3510
- 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)
3511
2305
  }
3512
- }
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
3513
2311
 
3514
2312
  // 先发一条即时反馈(扣费结果 / 队列位置 / 降级提示),
3515
2313
  // 确保用户不会以为指令没反应。
@@ -3528,28 +2326,14 @@ exports.apply = async function apply(ctx, cfg) {
3528
2326
  notice.push(session.text('.no-optimize', [reasons[noOptimizeReason] || noOptimizeReason]))
3529
2327
  }
3530
2328
  if (parsedBatch.clamped) notice.push(session.text('.batch-limit', [count]))
3531
- if (cfg.queueEnabled) {
3532
- notice.push(session.text('.queued', [firstPosition, cfg.queueMaxRequests || '∞']))
3533
- if (count > 1) notice.push(session.text('.batch-count', [count]))
3534
- if (!isAdmin) notice.push(session.text('.charged', [count * cfg.price]))
3535
- } else {
3536
- notice.push(session.text('.generating'))
3537
- if (count > 1) notice.push(session.text('.batch-count', [count]))
3538
- if (!isAdmin) notice.push(session.text('.charged', [count * cfg.price]))
3539
- }
3540
- if (notice.length) {
3541
- try {
3542
- await session.send(notice.filter(Boolean).join('\n'))
3543
- } catch (e) {
3544
- logger.warn(`发送反馈消息失败:${e.message}`)
3545
- }
3546
- }
2329
+ notice.push(...feedbackBase(session, { firstPosition, count, totalPrice: count * cfg.price, isAdmin }))
2330
+ await sendNotices(session, notice)
3547
2331
 
3548
2332
  // 单张生成
3549
2333
  const runOne = async (i) => {
3550
2334
  let p = finalPrompt
3551
2335
  if (perImageOptimize) {
3552
- const optimized = await optimizePrompt(session, userPrompt, true, i2iRule)
2336
+ const optimized = await optimizePrompt(session, userPrompt, true, i2iRule, searchCache)
3553
2337
  p = appendInlineProtectedTags(composePrompt(optimized.prompt || userPrompt, raw).prompt, userPrompt, raw)
3554
2338
  }
3555
2339
  let result
@@ -3583,14 +2367,8 @@ exports.apply = async function apply(ctx, cfg) {
3583
2367
 
3584
2368
  if (cfg.outputLogs) logger.success(`${USERID} 生成成功 ${successCount}/${count} 张`)
3585
2369
 
3586
- const imageElements = allOutputs.map(src => h.image(src))
3587
- // 引用用户触发指令的原消息
3588
- try {
3589
- await session.send(h.quote(session.messageId) + imageElements.join(''))
3590
- } catch (e) {
3591
- logger.warn(`发送图片失败(引用):${e.message}`)
3592
- await session.send(imageElements)
3593
- }
2370
+ // 发图:引用用户触发指令的原消息
2371
+ await sendImagesWithQuote(session, allOutputs)
3594
2372
 
3595
2373
  const reply = []
3596
2374
  if (count > 1) {
@@ -3631,13 +2409,13 @@ exports.apply = async function apply(ctx, cfg) {
3631
2409
  // 没券/券不足:问是否购买(显示价格);拒绝一次再警告并问第二次,再拒绝直接生图
3632
2410
  const askBuy = async () => {
3633
2411
  await session.send(session.text('.coupon-ask-buy', [count, tokens, couponPrice, count * couponPrice]))
3634
- const reply = await session.prompt(cfg.couponAskTimeout * 1000)
2412
+ const reply = await session.prompt(cfg.couponAskTimeout * 1000).catch(() => null)
3635
2413
  return normalizeConfirm(reply)
3636
2414
  }
3637
2415
  let ans = await askBuy()
3638
2416
  if (ans === false) {
3639
2417
  await session.send(session.text('.coupon-buy-warn'))
3640
- const reply = await session.prompt(cfg.couponAskTimeout * 1000)
2418
+ const reply = await session.prompt(cfg.couponAskTimeout * 1000).catch(() => null)
3641
2419
  ans = normalizeConfirm(reply)
3642
2420
  if (ans !== true) {
3643
2421
  await session.send(session.text('.coupon-buy-cancelled'))
@@ -3695,8 +2473,15 @@ exports.apply = async function apply(ctx, cfg) {
3695
2473
  return null
3696
2474
  }
3697
2475
 
3698
- // 提示词优化券单价:优先读 data/p-shop.json 里覆盖的价格,否则用配置 couponPrice
2476
+ // 提示词优化券单价:优先读 data/p-shop.json 里覆盖的价格,否则用配置 couponPrice
2477
+ // 加 60 秒 TTL 缓存,避免每次购买询问都同步读盘。
2478
+ let couponPriceCache = null
2479
+ let couponPriceCacheAt = 0
3699
2480
  async function resolveCouponPrice() {
2481
+ if (couponPriceCache != null && Date.now() - couponPriceCacheAt < 60 * 1000) {
2482
+ return couponPriceCache
2483
+ }
2484
+ let price = cfg.couponPrice
3700
2485
  const candidates = [
3701
2486
  path.join(ctx.baseDir, 'data', 'p-shop.json'),
3702
2487
  path.join(process.cwd(), 'data', 'p-shop.json'),
@@ -3707,14 +2492,19 @@ exports.apply = async function apply(ctx, cfg) {
3707
2492
  const data = JSON.parse(fs.readFileSync(f, 'utf-8'))
3708
2493
  if (data && typeof data === 'object') {
3709
2494
  const item = data['提示词优化券']
3710
- 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
+ }
3711
2499
  }
3712
2500
  }
3713
2501
  } catch (e) {
3714
2502
  logger.warn(`读取 p-shop.json 价格失败:${e.message}`)
3715
2503
  }
3716
2504
  }
3717
- return cfg.couponPrice
2505
+ couponPriceCache = price
2506
+ couponPriceCacheAt = Date.now()
2507
+ return price
3718
2508
  }
3719
2509
 
3720
2510
  // 启动时合并数据库里保存的运行时配置(画师组/固定角色)