koishi-plugin-p-draw 1.0.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.
Files changed (3) hide show
  1. package/index.js +3287 -0
  2. package/package.json +29 -0
  3. package/readme.md +104 -0
package/index.js ADDED
@@ -0,0 +1,3287 @@
1
+ const { Schema, h } = require('koishi')
2
+ const fs = require('fs')
3
+ const fsp = require('fs/promises')
4
+ const path = require('path')
5
+ const crypto = require('crypto')
6
+ const { pathToFileURL } = require('url')
7
+
8
+ exports.name = 'p-draw'
9
+
10
+ exports.inject = {
11
+ required: ['database'],
12
+ optional: [],
13
+ }
14
+
15
+ exports.usage = `
16
+ - **指令:p-draw [描述]**
17
+ 别名:画图,生图,绘图,画画
18
+ 消耗 P 点并连接本地 ComfyUI 生图,结果图会发回本群。
19
+ 例:\`画图 一个女孩,白色裙子,立绘,简单背景\`
20
+ - **指令:p-draw 状态**
21
+ 别名:绘图状态
22
+ 查看 ComfyUI 连接状态与模型可用性。
23
+ - **指令:p-draw 诊断**
24
+ 别名:部署诊断
25
+ 输出更详细的配置与连通性诊断。
26
+ - **尺寸:** 支持 \`竖图\` \`横图\` \`方图\` \`长竖图\` \`宽屏\` 或在描述中写 \`1024x1536:描述\` / \`--尺寸 1216x832\`。
27
+ - **原样模式:** 描述前加 \`无优化\` 直接提交写好的 tags。
28
+ - **联网搜索:** 描述中带 \`联网\` / \`搜索\` / \`查一下\` 等词时,会先联网搜索补充角色设定(需配置 Tavily Key)。
29
+ - **画师组:** \`创建画师组 名称=tags\` \`切换画师组 名称\` \`查看画师组\` \`删除画师组 名称\`
30
+ - **固定角色:** \`添加角色 名称=tags\`
31
+ - **以图生图:** \`p-draw i2i <描述>\`,并在同一条消息里附一张原图(文件/截图/链接均可),保留原图构图与主体,按描述调整风格与细节(去噪强度由 \`img2imgDenoise\` 控制,默认 0.55)。
32
+ `;
33
+
34
+ const zhCN = {
35
+ comfyuiBaseUrl: { $description: 'ComfyUI 地址' },
36
+ workflow: { $description: '工作流类型(内置 anima_t2i)' },
37
+ customWorkflowEnabled: { $description: '使用自定义 ComfyUI 工作流 JSON' },
38
+ customWorkflowPath: { $description: '自定义工作流 JSON 路径(相对插件目录)' },
39
+ customWorkflowOverrideParameters: { $description: '用插件参数覆盖自定义工作流参数' },
40
+ timeout: { $description: '单次生成超时(秒)' },
41
+ pollInterval: { $description: '生成状态查询间隔(秒)' },
42
+ unetName: { $description: '主模型文件名' },
43
+ clipName: { $description: '文本编码器文件名' },
44
+ vaeName: { $description: 'VAE 文件名' },
45
+ width: { $description: '默认宽度' },
46
+ height: { $description: '默认高度' },
47
+ allowedSizes: { $description: '可用尺寸列表(宽x高)' },
48
+ steps: { $description: '采样步数' },
49
+ cfg: { $description: 'CFG 强度' },
50
+ samplerName: { $description: '采样器' },
51
+ scheduler: { $description: '调度器' },
52
+ qualityPrefix: { $description: '质量词前缀' },
53
+ negativePrompt: { $description: '负面提示词' },
54
+ promptOptimizeEnabled: { $description: '启用自然语言优化(需要配置下方 LLM 接口)' },
55
+ llmBaseUrl: { $description: 'LLM 接口地址(OpenAI 兼容,例如 https://api.deepseek.com/v1)' },
56
+ llmApiKey: { $description: 'LLM API Key' },
57
+ llmModel: { $description: 'LLM 模型名(留空则不优化,原样生图)' },
58
+ llmMaxTokens: { $description: 'LLM 输出上限' },
59
+ webSearchEnabled: { $description: '启用联网搜索(指令里写“联网/搜索/查一下”等触发)' },
60
+ tavilyApiKey: { $description: 'Tavily API Key(联网搜索用,https://tavily.com 申请)' },
61
+ webSearchMaxResults: { $description: '联网搜索结果数量' },
62
+ webSearchDepth: { $description: '搜索深度(basic / advanced)' },
63
+ webSearchQueryTemplate: { $description: '搜索词模板({prompt} 代表用户需求)' },
64
+ promptOptimizeTemplate: { $description: '自然语言优化模板(支持 {theme} {search_block} 占位符)' },
65
+ fixedCharacters: { $description: '固定角色(格式:角色名=tags)' },
66
+ artistPresets: { $description: '画师组(格式:名称=tags)' },
67
+ activeArtistPreset: { $description: '启用的画师组名称' },
68
+ defaultArtistTags: { $description: '备用画师 tags' },
69
+ styleTags: { $description: '画风 tags' },
70
+ queueEnabled: { $description: '启用生成队列(逐张顺序执行)' },
71
+ queueMaxRequests: { $description: '队列最大任务数(0 表示不限制)' },
72
+ batchMax: { $description: '单次指令最多生成的张数(支持 x3 / 3张 / --数量 3 等写法)' },
73
+ price: { $description: '一张图消耗的 P 点' },
74
+ multiPrice: { $description: '多人指令(p-draw 多人)单张消耗的 P 点' },
75
+ couponPrice: { $description: '提示词优化券单价(P 点/张,购买询问时显示)' },
76
+ couponAskTimeout: { $description: '提示词优化券确认等待时间(秒)' },
77
+ img2imgDenoise: { $description: '以图生图(p-draw i2i)的去噪强度,越小越接近原图(建议 0.4-0.7)' },
78
+ adminUsers: { $description: '免 P 点管理员用户 ID 列表' },
79
+ outputLogs: { $description: '是否在控制台输出详细日志' },
80
+ multiVerifyEnabled: { $description: '多人图生成后启用视觉校验(需配置下方视觉模型)' },
81
+ multiVerifyPassScore: { $description: '多人视觉校验合格分数(0-10)' },
82
+ multiCandidateCount: { $description: '多人候选采样数量(校验失败时最多重试 候选数-1 次)' },
83
+ multiSendDegradedCandidate: { $description: '多人候选全部不达标时仍发送最优候选' },
84
+ verifyLlmBaseUrl: { $description: '视觉校验 LLM 接口地址(OpenAI 兼容;留空则跳过校验)' },
85
+ verifyLlmApiKey: { $description: '视觉校验 LLM API Key' },
86
+ verifyLlmModel: { $description: '视觉校验 LLM 模型名(需支持图片输入,如 qwen-vl)' },
87
+ adminOnly: { $description: '仅管理员可用(adminUsers 中的用户)' },
88
+ allowedUserIds: { $description: '用户白名单(QQ 号,留空表示不限制)' },
89
+ blockedUserIds: { $description: '用户黑名单(QQ 号,黑名单优先于白名单)' },
90
+ allowedGroupIds: { $description: 'QQ 群白名单(群号,留空表示不限制)' },
91
+ blockedGroupIds: { $description: 'QQ 群黑名单(群号,黑名单优先于白名单)' },
92
+ commands: {
93
+ 'p-draw': {
94
+ description: '连接本地 ComfyUI 生图,消耗 P 点',
95
+ messages: {
96
+ 'not-permitted': 'ComfyUI 助手已关闭,或当前用户没有使用权限。',
97
+ 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 保留原图构图与主体,按描述调整风格与细节\n\n【模型】\n p-draw 模型(查看当前与可用模型) / p-draw 模型 名称(切换,支持模糊匹配)/ p-draw 模型 默认(重置)\n 例:p-draw 模型 anima-aesthetic\n\n【状态】\n p-draw 状态(查看 ComfyUI 连接状态与模型可用性)',
98
+ 'account-notExists': '君现在还没有 p 点,请先签到哦',
99
+ 'no-enough-p': '君的 p 点不够 {0}p 哦,先去签个到吧qwq',
100
+ 'no-prompt': '请提供画面描述,例如:p-draw 一个女孩,白色裙子',
101
+ generating: '正在生成中,请稍候...',
102
+ charged: '已扣除 {0} P 点,出图后余额会再核对。',
103
+ queued: '已加入生成队列,当前第 {0} 位(队列上限 {1})。',
104
+ 'prompt-degraded': '提示词优化服务不可用{0},本次已使用原始提示词继续生成;结果可能不符合 Danbooru Tag 预期。',
105
+ 'token-used': '已消耗 {0} 张提示词优化券,本次每张图都会使用 LLM 提示词优化。',
106
+ 'token-short': '提示词优化券不足(需 {0} 张,现有 {1} 张),本次未使用 LLM 优化。',
107
+ 'coupon-ask-use': '你有提示词优化券 {0} 张,本次生图需要消耗 {1} 张。\n是否使用提示词优化券进行 LLM 优化?\n(回复「是」使用 / 回复「否」取消本次生图)',
108
+ 'coupon-use-confirmed': '已消耗 {0} 张提示词优化券,本次每张图都会使用 LLM 优化。',
109
+ 'coupon-use-cancelled': '已取消本次生图(未使用提示词优化券)。',
110
+ 'coupon-cancelled': '未收到有效回复,本次操作已取消。',
111
+ 'coupon-ask-buy': '提示词优化券不足(需 {0} 张,现有 {1} 张)。\n提示词优化券价格:{2} P/张,本次共需 {3} P。\n是否购买并使用?\n(回复「是」购买 / 回复「否」不购买)',
112
+ 'coupon-buy-warn': '不使用提示词优化券的话,生成的图可能不好看。\n是否仍要购买并使用提示词优化券?\n(回复「是」购买 / 回复「否」直接生图)',
113
+ 'coupon-bought-used': '已购买 {0} 张提示词优化券(扣除 {1} P),并消耗 {0} 张用于本次 LLM 优化。',
114
+ 'coupon-buy-cancelled': '好的,本次不使用提示词优化券,直接生图。',
115
+ 'coupon-buy-pshort': 'P 点不足,无法购买提示词优化券(需 {0} P,现有 {1} P)。\n是否仍然生图(不使用 LLM 优化)?\n(回复「是」生图 / 回复「否」取消本次生图)',
116
+ 'coupon-consume-fail': '提示词优化券操作失败,本次未使用 LLM 优化。',
117
+ 'generate-failed': '生成失败:{0}',
118
+ 'generate-ok': '已扣除 {0} P 点,seed={1}',
119
+ 'generate-ok-batch': '已扣除 {0} P 点,共 {1} 张(seed:{2})',
120
+ 'batch-partial': '本次共生成 {0}/{1} 张,失败 {2} 张:{3}',
121
+ 'batch-limit': '每次最多生成 {0} 张,本次已按 {0} 张处理。',
122
+ 'batch-count': '本次共生成 {0} 张。',
123
+ 'artist-format': '请使用「名称=tags」的格式。例:p-draw 创建画师组 千代风格=@artist_a, @artist_b,',
124
+ 'artist-created': '已保存并启用画师组「{0}」:\n{1}',
125
+ 'artist-appended': '已追加画师组「{0}」:\n{1}',
126
+ 'artist-default-appended': '已追加默认画师 tags:\n{0}',
127
+ 'artist-use-format': '请写要启用的画师组名称。例:p-draw 切换画师组 千代风格',
128
+ 'artist-default': '已切回默认画师 tags。',
129
+ 'artist-not-found': '没有找到画师组「{0}」。',
130
+ 'artist-used': '已启用画师组「{0}」:\n{1}',
131
+ 'artist-deleted': '已删除画师组「{0}」。',
132
+ 'artist-delete-format': '请写要删除的画师组名称。例:p-draw 删除画师组 千代风格',
133
+ 'character-format': '请使用「名称=tags」的格式。例:p-draw 添加角色 狐莉=1girl, solo, fox girl',
134
+ 'character-created': '已保存角色「{0}」:\n{1}',
135
+ 'multi-usage': '多人生图:p-draw 多人 <描述>(2-4 人画面)\n例:p-draw 多人 左边若叶睦抱着吉他,右边千早爱音牵着她的手',
136
+ 'multi-verify-passed': '多人图已通过视觉校验({0} 分)。',
137
+ 'multi-verify-failed': '多人图未通过视觉校验{0},已重试 {1} 次。',
138
+ 'multi-verify-degraded': '多人图校验失败,已发送最优候选{0}。',
139
+ 'multi-verify-discarded': '多人图校验失败且未启用降级发送,本次图片不发送。',
140
+ 'multi-degraded': '多人视觉校验不可用{0},本次已直接发送生成结果。',
141
+ 'multi-verify-error': '多人视觉校验调用失败:{0}',
142
+ 'series-usage': '连续图:p-draw 连续 <角色>:<阶段1> → <阶段2> → ...\n例:p-draw 连续 少女:清纯校服 → 换上晚礼服 → 华丽登场\n或用 | 分隔,可加 --seed 固定种子保证角色一致。',
143
+ 'series-ok': '已扣除 {0} P 点,共 {1} 张连续图(seed={2})',
144
+ 'model-usage': '当前模型:{0}\n可用模型:\n{1}\n用法:p-draw 模型 <名称>(支持模糊匹配,如 anima-aesthetic);p-draw 模型 默认 恢复默认。',
145
+ 'model-switched': '已切换为模型「{0}」,对之后的生图生效。',
146
+ 'model-reset': '已恢复默认模型「{0}」。',
147
+ 'model-not-found': '未找到模型「{0}」。可用模型:\n{1}',
148
+ 'model-no-draw': '「模型」只能用来切换/查看模型,不能生图。请先用「p-draw 模型 <名称>」切换,再单独发送要画的内容。',
149
+ 'model-ambiguous': '「{0}」匹配到多个模型,请写得更具体些:\n{1}',
150
+ 'no-optimize': '提示:本次未使用 LLM 优化({0})。',
151
+ 'i2i-no-image': 'i2i(以图生图)需要附一张原图。用法:p-draw i2i <描述>,并在同一条消息里带上图片。',
152
+ 'i2i-upload-fail': '原图上传 ComfyUI 失败:{0}',
153
+ 'i2i-no-custom-workflow': 'i2i(以图生图)暂不支持自定义工作流(customWorkflowEnabled),请关闭后再试。',
154
+ 'multi-no-llm': '多人指令需要 LLM 规划,但当前未配置 llmBaseUrl / llmModel。请管理员在配置中填写后使用。',
155
+ },
156
+ },
157
+ },
158
+ }
159
+
160
+ exports.Config = Schema.object({
161
+ // ComfyUI 连接
162
+ comfyuiBaseUrl: Schema.string().default('http://127.0.0.1:8188').description('ComfyUI 地址'),
163
+ workflow: Schema.string().default('anima_t2i').description('工作流类型(内置 anima_t2i)'),
164
+ customWorkflowEnabled: Schema.boolean().default(false).description('使用自定义 ComfyUI 工作流 JSON'),
165
+ customWorkflowPath: Schema.string().default('').description('自定义工作流 JSON 路径(相对插件目录)'),
166
+ customWorkflowOverrideParameters: Schema.boolean().default(false).description('用插件参数覆盖自定义工作流参数'),
167
+ timeout: Schema.number().default(300).description('单次生成超时(秒)'),
168
+ pollInterval: Schema.number().default(2).description('生成状态查询间隔(秒)'),
169
+
170
+ // 模型文件
171
+ unetName: Schema.string().default('anima_baseV10.safetensors').description('主模型文件名'),
172
+ clipName: Schema.string().default('qwen_3_06b_base.safetensors').description('文本编码器文件名'),
173
+ vaeName: Schema.string().default('qwen_image_vae.safetensors').description('VAE 文件名'),
174
+
175
+ // 出图参数
176
+ width: Schema.number().default(832).description('默认宽度'),
177
+ height: Schema.number().default(1216).description('默认高度'),
178
+ allowedSizes: Schema.array(Schema.string()).default(['832x1216', '896x1152', '1024x1024', '1152x896', '1216x832', '768x1344', '1344x768', '1024x1536']).description('可用尺寸列表(宽x高)'),
179
+ steps: Schema.number().default(30).description('采样步数'),
180
+ cfg: Schema.number().default(4.5).description('CFG 强度'),
181
+ samplerName: Schema.string().default('er_sde').description('采样器'),
182
+ scheduler: Schema.string().default('simple').description('调度器'),
183
+
184
+ // 提示词
185
+ qualityPrefix: Schema.string().default('masterpiece, best quality, score_7, safe,').description('质量词前缀'),
186
+ negativePrompt: Schema.string().default('worst quality, low quality, score_1, score_2, score_3, artist name').description('负面提示词'),
187
+ promptOptimizeEnabled: Schema.boolean().default(false).description('启用自然语言优化(需要配置下方 LLM 接口)'),
188
+ llmBaseUrl: Schema.string().default('').description('LLM 接口地址(OpenAI 兼容,例如 https://api.deepseek.com/v1)'),
189
+ llmApiKey: Schema.string().role('secret').default('').description('LLM API Key'),
190
+ llmModel: Schema.string().default('').description('LLM 模型名(留空则不优化,原样生图)'),
191
+ llmMaxTokens: Schema.number().default(1000).description('LLM 输出上限'),
192
+ webSearchEnabled: Schema.boolean().default(false).description('启用联网搜索(指令里写“联网/搜索/查一下”等触发)'),
193
+ tavilyApiKey: Schema.string().role('secret').default('').description('Tavily API Key(联网搜索用,https://tavily.com 申请)'),
194
+ webSearchMaxResults: Schema.number().default(5).description('联网搜索结果数量'),
195
+ webSearchDepth: Schema.string().default('basic').description('搜索深度(basic / advanced)'),
196
+ webSearchQueryTemplate: Schema.string().default('{prompt} 角色 外观 立绘 服装 配色 武器 官方图 official art character design outfit appearance wiki fandom').description('搜索词模板({prompt} 代表用户需求)'),
197
+ promptOptimizeTemplate: Schema.string().description('自然语言优化模板(支持 {theme} {search_block} 占位符)').default('你是为 Anima 图像生成模型编写正面提示词的 AI 画师。\n\n请根据用户的原始要求设计一幅完整、协调、具有视觉吸引力的画面,并将结果输出为英文 Danbooru-style tags。\n\n输出要求:\n- 只输出一行英文 tags,使用英文逗号分隔。\n- 不要输出解释、分析、标题、编号、Markdown、代码块或中文。\n- 不要输出 masterpiece、best quality、score 等质量前缀。\n- 不要输出画师 tags;质量词和画师组会由程序另行拼接。\n- 尽量使用模型容易理解的可见画面描述。\n- 保持用户明确指定的角色、主体、人数、关键服装、动作、表情和道具。\n- 除上述明确要求外,可以自由决定服装细节、姿态、构图、镜头、背景、环境、光影、色彩、氛围、前景和特效。\n- 以最终图像协调、精致、有表现力和好看为优先,不需要机械追求固定 Tag 数量。\n- 不要为了数量重复同义词;画面已经完整时即可停止。\n- 请自行解决明显冲突,直接输出你认为最适合生成最终画面的版本。\n\n角色和动态上下文:\n{character_rule}\n{search_block}\n\n用户原始要求:\n{theme}'),
198
+ artistPresets: Schema.array(Schema.string()).default([]).description('画师组(格式:名称=tags)'),
199
+ activeArtistPreset: Schema.string().default('').description('启用的画师组名称'),
200
+ defaultArtistTags: Schema.string().default('').description('备用画师 tags'),
201
+ styleTags: Schema.string().default('').description('画风 tags'),
202
+
203
+ // 队列
204
+ queueEnabled: Schema.boolean().default(true).description('启用生成队列(逐张顺序执行)'),
205
+ queueMaxRequests: Schema.number().default(5).description('队列最大任务数(0 表示不限制)'),
206
+ batchMax: Schema.number().default(4).description('单次指令最多生成的张数(支持 x3 / 3张 / --数量 3 等写法)'),
207
+
208
+ // P 点
209
+ price: Schema.number().default(500).description('一张图消耗的 P 点'),
210
+ multiPrice: Schema.number().default(900).description('多人指令(p-draw 多人)单张消耗的 P 点'),
211
+ couponPrice: Schema.number().default(3000).description('提示词优化券单价(P 点/张,购买询问时显示;可自动读取 data/p-shop.json 里的价格覆盖)'),
212
+ couponAskTimeout: Schema.number().default(60).description('提示词优化券确认等待时间(秒)'),
213
+ img2imgDenoise: Schema.number().default(0.55).description('以图生图(p-draw i2i)的去噪强度,越小越接近原图(建议 0.4-0.7)'),
214
+ adminUsers: Schema.array(Schema.string()).default([]).description('免 P 点管理员用户 ID 列表'),
215
+ outputLogs: Schema.boolean().default(true).description('是否在控制台输出详细日志'),
216
+
217
+ // 多人(移植自 anima /anm 多人)
218
+ multiVerifyEnabled: Schema.boolean().default(true).description('多人图生成后启用视觉校验(需配置下方视觉模型)'),
219
+ multiVerifyPassScore: Schema.number().default(6).description('多人视觉校验合格分数(0-10)'),
220
+ multiCandidateCount: Schema.number().default(2).description('多人候选采样数量(校验失败时最多重试 候选数-1 次)'),
221
+ multiSendDegradedCandidate: Schema.boolean().default(true).description('多人候选全部不达标时仍发送最优候选(false 则丢弃)'),
222
+ verifyLlmBaseUrl: Schema.string().default('').description('视觉校验 LLM 接口地址(OpenAI 兼容;留空则跳过校验)'),
223
+ verifyLlmApiKey: Schema.string().role('secret').default('').description('视觉校验 LLM API Key'),
224
+ verifyLlmModel: Schema.string().default('').description('视觉校验 LLM 模型名(需支持图片输入,如 qwen-vl)'),
225
+
226
+ // 权限
227
+ adminOnly: Schema.boolean().default(false).description('仅管理员可用(adminUsers 中的用户)'),
228
+ allowedUserIds: Schema.array(Schema.string()).default([]).description('用户白名单(QQ 号,留空表示不限制)'),
229
+ blockedUserIds: Schema.array(Schema.string()).default([]).description('用户黑名单(QQ 号,黑名单优先于白名单)'),
230
+ allowedGroupIds: Schema.array(Schema.string()).default([]).description('QQ 群白名单(群号,留空表示不限制)'),
231
+ blockedGroupIds: Schema.array(Schema.string()).default([]).description('QQ 群黑名单(群号,黑名单优先于白名单)'),
232
+ }).i18n({
233
+ 'zh-CN': zhCN,
234
+ })
235
+
236
+ // ------------------------------------------------------------------
237
+ // 地址规范化
238
+ // ------------------------------------------------------------------
239
+ function normalizeBaseUrl(raw) {
240
+ let value = String(raw || '').trim()
241
+ if (!value) value = 'http://127.0.0.1:8188'
242
+ // 清理重复协议头,例如 http://http://host 或 http://https://host
243
+ value = value.replace(/^https?:\/\//i, '')
244
+ if (!/^https?:\/\//i.test(value)) value = 'http://' + value
245
+ return value.replace(/\/+$/, '')
246
+ }
247
+
248
+ // ------------------------------------------------------------------
249
+ // 尺寸别名与解析(移植自 anima command_router)
250
+ // ------------------------------------------------------------------
251
+ const SIZE_ALIASES = {
252
+ '方图': 1.0,
253
+ '正方形': 1.0,
254
+ '竖图': 2 / 3,
255
+ '竖版': 2 / 3,
256
+ '横图': 3 / 2,
257
+ '横版': 3 / 2,
258
+ '长竖图': 9 / 16,
259
+ '手机竖屏': 9 / 16,
260
+ '宽屏': 16 / 9,
261
+ '超宽图': 16 / 9,
262
+ }
263
+
264
+ const SIZE_VALUE_PATTERN = String.raw`(?<width>\d{2,5})\s*[xX×**✕✖хХ]\s*(?<height>\d{2,5})`
265
+
266
+ function escapeRe(text) {
267
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
268
+ }
269
+
270
+ function parseGenerationSize(text, allowed) {
271
+ const prompt = String(text || '').trim()
272
+ let sizeMatch = null
273
+ const patterns = [
274
+ new RegExp(String.raw`(?<!\S)--(?:尺寸|分辨率)\s*(?:=|=|:|:)?\s*${SIZE_VALUE_PATTERN}`, 'i'),
275
+ new RegExp(String.raw`(?:尺寸|分辨率)\s*(?:为|是|=|=|:|:)?\s*${SIZE_VALUE_PATTERN}`, 'i'),
276
+ new RegExp(String.raw`^\s*${SIZE_VALUE_PATTERN}\s*[::,,]`, 'i'),
277
+ ]
278
+ for (const pattern of patterns) {
279
+ sizeMatch = prompt.match(pattern)
280
+ if (sizeMatch) break
281
+ }
282
+
283
+ let selected = null
284
+ if (sizeMatch) {
285
+ selected = [parseInt(sizeMatch.groups.width), parseInt(sizeMatch.groups.height)]
286
+ } else {
287
+ const aliases = Object.keys(SIZE_ALIASES)
288
+ .sort((a, b) => b.length - a.length)
289
+ .map(escapeRe)
290
+ .join('|')
291
+ const aliasPatterns = [
292
+ new RegExp(String.raw`(?<!\S)--(?:尺寸|分辨率)\s*(?:=|=|:|:)?\s*(?<alias>${aliases})(?=$|\s|[::,,])`, 'i'),
293
+ new RegExp(String.raw`(?:尺寸|分辨率)\s*(?:为|是|=|=|:|:)?\s*(?<alias>${aliases})(?=$|\s|[::,,])`, 'i'),
294
+ new RegExp(String.raw`^\s*(?<alias>${aliases})(?=$|\s|[::,,])\s*[::,,]?`, 'i'),
295
+ ]
296
+ for (const pattern of aliasPatterns) {
297
+ sizeMatch = prompt.match(pattern)
298
+ if (sizeMatch) break
299
+ }
300
+ if (sizeMatch && allowed.length) {
301
+ const targetRatio = SIZE_ALIASES[sizeMatch.groups.alias]
302
+ selected = allowed.reduce((best, size) => {
303
+ const a = Math.abs(size[0] / size[1] - targetRatio)
304
+ const b = Math.abs(size[0] * size[1] - 1024 * 1024)
305
+ const ba = Math.abs(best[0] / best[1] - targetRatio)
306
+ const bb = Math.abs(best[0] * best[1] - 1024 * 1024)
307
+ return a < ba || (a === ba && b < bb) ? size : best
308
+ })
309
+ }
310
+ }
311
+
312
+ if (!sizeMatch) return { prompt, size: null, error: null }
313
+
314
+ let cleaned = (prompt.slice(0, sizeMatch.index) + ' ' + prompt.slice(sizeMatch.index + sizeMatch[0].length)).trim()
315
+ cleaned = cleaned.replace(/^[\s,,;;::]+|[\s,,;;::]+$/g, '')
316
+ cleaned = cleaned.replace(/([,,;;])\s*[,,;;]+/g, '$1')
317
+ cleaned = cleaned.replace(/\s+/g, ' ')
318
+
319
+ if (selected && allowed.length && !allowed.some(s => s[0] === selected[0] && s[1] === selected[1])) {
320
+ return {
321
+ prompt: cleaned,
322
+ size: null,
323
+ error: `尺寸 ${selected[0]}x${selected[1]} 不可用。可用尺寸:${allowed.map(s => `${s[0]}x${s[1]}`).join('、')}`,
324
+ }
325
+ }
326
+ if (selected === null) return { prompt: cleaned, size: null, error: '当前没有配置可用尺寸。' }
327
+ return { prompt: cleaned, size: selected, error: null }
328
+ }
329
+
330
+ // 批量张数解析:x3 / ×3 / 3张 / 三张 / --数量 3 / 数量:3
331
+ const BATCH_TOKEN_PATTERNS = [
332
+ /(?<!\S)(--|——)(?:数量|张数)\s*(?:=|=|:|:)?\s*(?<num>\d+)/i,
333
+ /(?:数量|张数)\s*(?:为|是|=|=|:|:)\s*(?<num>\d+)/i,
334
+ /(?<!\S)[x×X](?<num>\d+)(?![a-zA-Z0-9])/,
335
+ /(?<!\S)(?<num>[一二两三四五六七八九十]+)张/,
336
+ /(?<!\S)(?<num>\d+)张(?:图)?/,
337
+ ]
338
+
339
+ const CN_NUM_MAP = { '一': 1, '两': 2, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '十': 10 }
340
+
341
+ function cnNumValue(text) {
342
+ if (/^\d+$/.test(text)) return parseInt(text, 10)
343
+ if (CN_NUM_MAP[text] != null) return CN_NUM_MAP[text]
344
+ if (/^十[一二三四五六七八九]$/.test(text)) return 10 + CN_NUM_MAP[text.slice(1)]
345
+ if (text === '十') return 10
346
+ return 0
347
+ }
348
+
349
+ function parseBatchCount(text, max) {
350
+ const prompt = String(text || '').trim()
351
+ const cap = Math.max(1, parseInt(max) || 1)
352
+ let requested = 1
353
+ let matched = false
354
+ for (const pattern of BATCH_TOKEN_PATTERNS) {
355
+ const m = prompt.match(pattern)
356
+ if (!m) continue
357
+ const numText = m.groups && m.groups.num != null ? m.groups.num : ''
358
+ const value = cnNumValue(numText)
359
+ if (value >= 1) {
360
+ requested = value
361
+ matched = true
362
+ }
363
+ const cleaned = (prompt.slice(0, m.index) + ' ' + prompt.slice(m.index + m[0].length)).trim()
364
+ .replace(/^[\s,,;;::]+|[\s,,;;::]+$/g, '')
365
+ .replace(/\s+/g, ' ')
366
+ return { count: Math.min(requested, cap), requested, prompt: cleaned, matched, clamped: requested > cap }
367
+ }
368
+ return { count: 1, requested: 1, prompt, matched, clamped: false }
369
+ }
370
+
371
+ // 固定种子解析:--seed 17021628 / --seed:17021628 / --seed=17021628 / --seed=123
372
+ // 从提示词里剥离并返回 { seed, prompt }
373
+ function parseSeed(text) {
374
+ const prompt = String(text || '').trim()
375
+ const m = prompt.match(/(?<!\S)--seed\s*(?:=|=|:|:)?\s*(\d+)/i)
376
+ if (!m) return { seed: null, prompt }
377
+ const seed = parseInt(m[1], 10) >>> 0
378
+ const cleaned = (prompt.slice(0, m.index) + ' ' + prompt.slice(m.index + m[0].length)).trim()
379
+ .replace(/^[\s,,;;::]+|[\s,,;;::]+$/g, '')
380
+ .replace(/\s+/g, ' ')
381
+ return { seed, prompt: cleaned }
382
+ }
383
+
384
+ // ------------------------------------------------------------------
385
+ // 提示词辅助(移植自 anima prompt_presets)
386
+ // ------------------------------------------------------------------
387
+ const RAW_PREFIXES = [
388
+ '原样', '原样tags', '原样tag', '原样 tags', '原样 tag',
389
+ '直接画', '直接出图', '直接生图', '直接tags', '直接tag', '直接 tags', '直接 tag',
390
+ '不优化', '无优化', '无优化tags', '无优化tag', '无优化 tags', '无优化 tag',
391
+ '不要优化', '跳过优化', '跳过提示词优化', 'raw tags', 'raw tag', 'raw',
392
+ 'no optimize', 'no optimization', '不用优化',
393
+ ]
394
+
395
+ function stripRawPrefix(prompt) {
396
+ const text = String(prompt || '').trim()
397
+ const lowered = text.toLowerCase()
398
+ for (const prefix of RAW_PREFIXES) {
399
+ if (lowered.startsWith(prefix.toLowerCase())) {
400
+ return { raw: true, prompt: text.slice(prefix.length).replace(/^[\s,,;;::]+/, '').trim() }
401
+ }
402
+ }
403
+ return { raw: false, prompt: text }
404
+ }
405
+
406
+ function mergeTagText(existing, addition) {
407
+ const tags = []
408
+ const seen = new Set()
409
+ for (const source of [existing, addition]) {
410
+ for (const tag of String(source || '').split(',')) {
411
+ const text = tag.trim()
412
+ if (!text) continue
413
+ const key = text.toLowerCase()
414
+ if (seen.has(key)) continue
415
+ tags.push(text)
416
+ seen.add(key)
417
+ }
418
+ }
419
+ return tags.join(', ') + (tags.length ? ',' : '')
420
+ }
421
+
422
+ function parseNameTags(text) {
423
+ const raw = String(text || '').trim()
424
+ const candidates = []
425
+ for (const separator of ['=', '=', ':', ':']) {
426
+ const index = raw.indexOf(separator)
427
+ if (index >= 0) candidates.push([index, separator])
428
+ }
429
+ candidates.sort((a, b) => a[0] - b[0])
430
+ for (const [index, separator] of candidates) {
431
+ const name = raw.slice(0, index).trim()
432
+ const tags = raw.slice(index + separator.length).trim()
433
+ if (!name || !tags) continue
434
+ if (name.includes(',') || name.includes('\n')) continue
435
+ if (/[@()[\]{}]/.test(name)) continue
436
+ if (['artist', 'tag', 'tags', 'prompt', 'positive', 'negative'].includes(name.toLowerCase())) continue
437
+ return { name, tags }
438
+ }
439
+ return null
440
+ }
441
+
442
+ function parsePresetList(list) {
443
+ const result = {}
444
+ for (const item of list || []) {
445
+ const text = String(item || '').trim()
446
+ if (!text) continue
447
+ const parsed = parseNameTags(text)
448
+ if (parsed) result[parsed.name] = parsed.tags
449
+ }
450
+ return result
451
+ }
452
+
453
+ // ------------------------------------------------------------------
454
+ // ComfyUI 工作流构建(移植自 anima comfyui_workflows)
455
+ // ------------------------------------------------------------------
456
+ function animaT2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed) {
457
+ return {
458
+ '44': { class_type: 'UNETLoader', inputs: { unet_name: cfg.unetName, weight_dtype: 'fp8_e4m3fn' } },
459
+ '45': { class_type: 'CLIPLoader', inputs: { clip_name: cfg.clipName, type: 'stable_diffusion', device: 'default' } },
460
+ '15': { class_type: 'VAELoader', inputs: { vae_name: cfg.vaeName } },
461
+ '28': { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } },
462
+ '11': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['45', 0] } },
463
+ '12': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt, clip: ['45', 0] } },
464
+ '19': {
465
+ class_type: 'KSampler',
466
+ inputs: {
467
+ model: ['44', 0],
468
+ positive: ['11', 0],
469
+ negative: ['12', 0],
470
+ latent_image: ['28', 0],
471
+ seed,
472
+ steps,
473
+ cfg: cfgVal,
474
+ sampler_name: cfg.samplerName,
475
+ scheduler: cfg.scheduler,
476
+ denoise: 1,
477
+ },
478
+ },
479
+ '8': { class_type: 'VAEDecodeTiled', inputs: { samples: ['19', 0], vae: ['15', 0], tile_size: 512, overlap: 64, temporal_size: 64, temporal_overlap: 8 } },
480
+ '9': { class_type: 'SaveImage', inputs: { images: ['8', 0], filename_prefix: 'pdraw/anm' } },
481
+ }
482
+ }
483
+
484
+ function buildWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, explicitSize) {
485
+ if (cfg.customWorkflowEnabled && cfg.customWorkflowPath) {
486
+ return customWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, explicitSize)
487
+ }
488
+ return animaT2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed)
489
+ }
490
+
491
+ // 以图生图工作流(p-draw i2i):LoadImage → VAEEncode → KSampler(denoise<1)
492
+ // 输入图编码为 latent 作为起点,尺寸保持原图,去噪强度由 cfg.img2imgDenoise 控制。
493
+ function animaI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, denoise) {
494
+ return {
495
+ '44': { class_type: 'UNETLoader', inputs: { unet_name: cfg.unetName, weight_dtype: 'fp8_e4m3fn' } },
496
+ '45': { class_type: 'CLIPLoader', inputs: { clip_name: cfg.clipName, type: 'stable_diffusion', device: 'default' } },
497
+ '15': { class_type: 'VAELoader', inputs: { vae_name: cfg.vaeName } },
498
+ '13': { class_type: 'LoadImage', inputs: { image: inputImage } },
499
+ '30': { class_type: 'VAEEncode', inputs: { pixels: ['13', 0], vae: ['15', 0] } },
500
+ '11': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['45', 0] } },
501
+ '12': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt, clip: ['45', 0] } },
502
+ '19': {
503
+ class_type: 'KSampler',
504
+ inputs: {
505
+ model: ['44', 0],
506
+ positive: ['11', 0],
507
+ negative: ['12', 0],
508
+ latent_image: ['30', 0],
509
+ seed,
510
+ steps,
511
+ cfg: cfgVal,
512
+ sampler_name: cfg.samplerName,
513
+ scheduler: cfg.scheduler,
514
+ denoise,
515
+ },
516
+ },
517
+ '8': { class_type: 'VAEDecodeTiled', inputs: { samples: ['19', 0], vae: ['15', 0], tile_size: 512, overlap: 64, temporal_size: 64, temporal_overlap: 8 } },
518
+ '9': { class_type: 'SaveImage', inputs: { images: ['8', 0], filename_prefix: 'pdraw/anm_i2i' } },
519
+ }
520
+ }
521
+
522
+ function customWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, explicitSize) {
523
+ const rawPath = path.resolve(__dirname, cfg.customWorkflowPath)
524
+ let raw
525
+ try {
526
+ raw = JSON.parse(fs.readFileSync(rawPath, 'utf-8'))
527
+ } catch (e) {
528
+ throw new Error(`自定义工作流加载失败:${e.message}`)
529
+ }
530
+ const body = raw && typeof raw === 'object' && raw.prompt && typeof raw.prompt === 'object' ? raw.prompt : raw
531
+ if (!body || typeof body !== 'object') throw new Error('自定义工作流 JSON 无效')
532
+
533
+ const workflow = JSON.parse(JSON.stringify(body))
534
+ const textNodes = []
535
+ for (const [nodeId, node] of Object.entries(workflow)) {
536
+ if (!node || typeof node !== 'object') continue
537
+ const classType = String(node.class_type || '')
538
+ const inputs = node.inputs
539
+ if (classType.includes('TextEncode') && inputs && typeof inputs.text === 'string') {
540
+ textNodes.push(String(nodeId))
541
+ }
542
+ }
543
+ const positiveIds = conditioningTextNodeIds(workflow, 'positive', textNodes)
544
+ const negativeIds = conditioningTextNodeIds(workflow, 'negative', textNodes)
545
+ if (!positiveIds.length) throw new Error('自定义工作流中找不到正面提示词节点')
546
+ if (!negativeIds.length) throw new Error('自定义工作流中找不到负面提示词节点')
547
+ if (positiveIds.some(id => negativeIds.includes(id))) throw new Error('自定义工作流正负面节点有歧义')
548
+
549
+ for (const nodeId of positiveIds) {
550
+ if (workflow[nodeId] && workflow[nodeId].inputs) workflow[nodeId].inputs.text = prompt
551
+ }
552
+ for (const nodeId of negativeIds) {
553
+ if (workflow[nodeId] && workflow[nodeId].inputs) workflow[nodeId].inputs.text = negativePrompt
554
+ }
555
+
556
+ for (const node of Object.values(workflow)) {
557
+ if (!node || typeof node !== 'object') continue
558
+ const classType = String(node.class_type || '')
559
+ const inputs = node.inputs
560
+ if (!inputs || typeof inputs !== 'object') continue
561
+ if (classType === 'SaveImage' && 'filename_prefix' in inputs) {
562
+ inputs.filename_prefix = 'pdraw/anm'
563
+ }
564
+ const override = Boolean(cfg.customWorkflowOverrideParameters)
565
+ if ((override || explicitSize) && classType === 'EmptyLatentImage') {
566
+ if ('width' in inputs) inputs.width = width
567
+ if ('height' in inputs) inputs.height = height
568
+ }
569
+ if (override && (classType === 'KSampler' || classType === 'KSamplerAdvanced')) {
570
+ if ('steps' in inputs) inputs.steps = steps
571
+ if ('cfg' in inputs) inputs.cfg = cfgVal
572
+ if (cfg.samplerName && 'sampler_name' in inputs) inputs.sampler_name = cfg.samplerName
573
+ if (cfg.scheduler && 'scheduler' in inputs) inputs.scheduler = cfg.scheduler
574
+ }
575
+ if ('seed' in inputs) inputs.seed = seed
576
+ if ('noise_seed' in inputs) inputs.noise_seed = seed
577
+ }
578
+ return workflow
579
+ }
580
+
581
+ function conditioningTextNodeIds(workflow, inputName, textNodes) {
582
+ const pending = []
583
+ for (const node of Object.values(workflow)) {
584
+ if (!node || typeof node !== 'object') continue
585
+ if (!['KSampler', 'KSamplerAdvanced'].includes(String(node.class_type || ''))) continue
586
+ const link = node.inputs && node.inputs[inputName]
587
+ if (Array.isArray(link) && link.length) pending.push(String(link[0]))
588
+ }
589
+ const found = []
590
+ const visited = new Set()
591
+ while (pending.length) {
592
+ const nodeId = pending.pop()
593
+ if (visited.has(nodeId)) continue
594
+ visited.add(nodeId)
595
+ if (textNodes.includes(nodeId)) {
596
+ found.push(nodeId)
597
+ continue
598
+ }
599
+ const node = workflow[nodeId]
600
+ const inputs = node && node.inputs
601
+ if (!inputs || typeof inputs !== 'object') continue
602
+ for (const value of Object.values(inputs)) {
603
+ if (Array.isArray(value) && value.length) {
604
+ const sourceId = String(value[0])
605
+ if (workflow[sourceId]) pending.push(sourceId)
606
+ }
607
+ }
608
+ }
609
+ return found
610
+ }
611
+
612
+ function outputImages(history) {
613
+ const images = []
614
+ const outputs = history.outputs || {}
615
+ if (outputs && typeof outputs === 'object') {
616
+ for (const nodeOutput of Object.values(outputs)) {
617
+ if (!nodeOutput || typeof nodeOutput !== 'object') continue
618
+ for (const image of nodeOutput.images || []) {
619
+ if (image && typeof image === 'object') images.push(image)
620
+ }
621
+ }
622
+ }
623
+ return images
624
+ }
625
+
626
+ // ------------------------------------------------------------------
627
+ // ComfyUI 结果等待:优先 WebSocket 事件,失败/不可用回退轮询
628
+ // ------------------------------------------------------------------
629
+ function waitViaWebSocket(baseUrl, promptId, clientId, timeoutMs) {
630
+ return new Promise((resolve) => {
631
+ let socket
632
+ let timer = null
633
+ let settled = false
634
+ const finish = (ok) => {
635
+ if (settled) return
636
+ settled = true
637
+ if (timer) clearTimeout(timer)
638
+ try { if (socket) socket.close() } catch (e) { /* ignore */ }
639
+ resolve(ok)
640
+ }
641
+ try {
642
+ const wsUrl = baseUrl.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:') + `/ws?clientId=${encodeURIComponent(clientId)}`
643
+ socket = new WebSocket(wsUrl)
644
+ } catch (e) {
645
+ finish(false)
646
+ return
647
+ }
648
+ timer = setTimeout(() => finish(false), timeoutMs)
649
+ socket.onmessage = (ev) => {
650
+ let msg
651
+ try { msg = JSON.parse(String(ev.data)) } catch (e) { return }
652
+ if (!msg || typeof msg !== 'object') return
653
+ if (msg.type === 'execution_success' && msg.data && msg.data.prompt_id === promptId) { finish(true); return }
654
+ if (msg.type === 'execution_error' || msg.type === 'execution_interrupted') { finish(false) }
655
+ }
656
+ socket.onerror = () => finish(false)
657
+ socket.onclose = () => finish(false)
658
+ })
659
+ }
660
+
661
+ async function waitComfyResult(ctx, comfyGet, baseUrl, promptId, clientId, timeoutMs, pollMs) {
662
+ const deadline = Date.now() + timeoutMs
663
+ if (typeof WebSocket !== 'undefined') {
664
+ const remaining = Math.max(0, deadline - Date.now())
665
+ try {
666
+ const viaWs = await waitViaWebSocket(baseUrl, promptId, clientId, remaining)
667
+ if (viaWs) {
668
+ try {
669
+ const data = await comfyGet(`/history/${promptId}`, 20000)
670
+ if (data && data[promptId]) return data[promptId]
671
+ } catch (e) { /* fall through */ }
672
+ }
673
+ } catch (e) { /* fall through to polling */ }
674
+ }
675
+ while (Date.now() < deadline) {
676
+ try {
677
+ const data = await comfyGet(`/history/${promptId}`, 20000)
678
+ if (data && data[promptId]) return data[promptId]
679
+ } catch (e) { /* transient */ }
680
+ await ctx.sleep(pollMs)
681
+ }
682
+ return null
683
+ }
684
+
685
+ // ------------------------------------------------------------------
686
+ // Tag 清洗(移植自 anima tag_cleaner)
687
+ // ------------------------------------------------------------------
688
+ function splitTags(text) {
689
+ let cleaned = String(text || '')
690
+ cleaned = cleaned.replace(/```[\s\S]*?```/g, m => m.slice(3, -3).trim())
691
+ cleaned = cleaned.replace(/,/g, ',').replace(/、/g, ',').replace(/;/g, ',')
692
+ cleaned = cleaned.replace(/\n/g, ',')
693
+ cleaned = cleaned.replace(/^(?:positive|prompt|tags|提示词|正向提示词)\s*[::]/i, '')
694
+ const parts = cleaned.split(',').map(p => p.trim().replace(/^[\s,.;::]+|[\s,.;::]+$/g, ''))
695
+ return parts.filter(Boolean)
696
+ }
697
+
698
+ function normalizeTagKey(tag) {
699
+ let value = String(tag || '').trim().toLowerCase()
700
+ if (
701
+ value.startsWith('(') && value.endsWith(')') &&
702
+ (value.match(/\(/g) || []).length === 1 &&
703
+ (value.match(/\)/g) || []).length === 1
704
+ ) {
705
+ value = value.slice(1, -1).trim()
706
+ }
707
+ value = value.replace(/:\s*[\d.]+$/, '')
708
+ value = value.replace(/\s+/g, ' ')
709
+ return value
710
+ }
711
+
712
+ function stripWrappingBrackets(text) {
713
+ let value = String(text || '').trim()
714
+ const pairs = { '(': ')', '[': ']', '{': '}' }
715
+ let changed = true
716
+ while (changed && value.length >= 2) {
717
+ changed = false
718
+ const left = value[0]
719
+ const right = pairs[left]
720
+ if (right && value.endsWith(right)) {
721
+ value = value.slice(1, -1).trim()
722
+ changed = true
723
+ }
724
+ }
725
+ return value
726
+ }
727
+
728
+ const ARTIST_FUNCTION_RE = /^artist\s*:\s*([^:=()[\]{}]+?)\s*(?:[:=]\s*[-+]?(?:\d+(?:\.\d+)?|\.\d+)\s*)?$/i
729
+
730
+ function normalizeAnimaArtistTag(tag) {
731
+ const raw = String(tag || '').trim()
732
+ if (!raw) return ''
733
+ if (raw.startsWith('@')) {
734
+ const name = raw.slice(1).trim().replace(/_/g, ' ').replace(/\s+/g, ' ').trim()
735
+ return name ? `@${name}` : raw
736
+ }
737
+ const inner = stripWrappingBrackets(raw)
738
+ const match = ARTIST_FUNCTION_RE.exec(inner)
739
+ if (!match) return raw
740
+ let name = match[1].trim()
741
+ if (name.startsWith('@')) name = name.slice(1).trim()
742
+ name = name.replace(/_/g, ' ').replace(/\s+/g, ' ').trim()
743
+ return name ? `@${name}` : raw
744
+ }
745
+
746
+ function canonicalTagText(tag) {
747
+ const artistTag = normalizeAnimaArtistTag(tag)
748
+ if (artistTag.startsWith('@')) return artistTag
749
+ const key = normalizeTagKey(tag)
750
+ if (key === '1 girl') return '1girl'
751
+ if (key === 'punis') return 'penis'
752
+ 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'
753
+ return String(tag || '').trim()
754
+ }
755
+
756
+ const QUALITY_BLOCKLIST = new Set([
757
+ 'masterpiece', 'best quality', 'score_7', 'score_6', 'score_5', 'score_4', 'score_3', 'score_2', 'score_1',
758
+ 'safe', 'worst quality', 'low quality', 'artist name',
759
+ ])
760
+ const CHARACTER_BLOCKLIST = new Set(['1 girl', '1girl', 'solo'])
761
+ const CHARACTER_IDENTITY_EXACT_BLOCKLIST = new Set([
762
+ 'girl', 'boy', 'child', 'teenager', 'young adult', 'adult', 'mature', 'loli', 'shota', 'petite', 'aged down', 'age regression',
763
+ 'vampire', 'angel', 'demon', 'fox girl', 'cat girl', 'animal girl',
764
+ 'ahoge', 'bangs', 'blunt bangs', 'sidelocks', 'hair between eyes', 'long hair', 'short hair', 'medium hair', 'very long hair',
765
+ 'twintails', 'low twintails', 'braids', 'side braid', 'ponytail', 'side ponytail', 'one side up', 'hair bun', 'double bun',
766
+ 'heterochromia', 'blue eyes', 'red eyes', 'green eyes', 'pink eyes', 'purple eyes', 'yellow eyes', 'golden eyes', 'grey eyes',
767
+ 'gray eyes', 'brown eyes', 'black eyes', 'black hair', 'brown hair', 'blonde hair', 'white hair', 'silver hair', 'blue hair',
768
+ 'red hair', 'pink hair', 'purple hair', 'green hair', 'grey hair', 'gray hair',
769
+ 'fox ears', 'cat ears', 'animal ears', 'pointed ears', 'tail', 'fox tail', 'cat tail', 'wings', 'angel wings', 'demon wings',
770
+ 'horns', 'halo', 'fang', 'freckles',
771
+ ])
772
+ const CHARACTER_IDENTITY_PATTERNS = [
773
+ /\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/,
774
+ /\b(?:black|brown|blue|red|pink|purple|green|grey|gray|gold|golden|light|dark|ice blue|amber)\s+eyes?\b/,
775
+ /\b(?:ears?|tail|wings?|horns?|halo|fangs?|heterochromia)\b/,
776
+ /\b(?:vampire|angel|demon|fox girl|cat girl|animal girl)\b/,
777
+ /\b(?:loli|shota|teenager|young adult|adult|mature|aged down|age regression)\b/,
778
+ ]
779
+ const MULTI_CHARACTER_BLOCKLIST = new Set([
780
+ '2girls', '3girls', '4girls', '5girls', '6+girls', 'multiple girls',
781
+ '2boys', '3boys', '4boys', '5boys', '6+boys', 'multiple boys',
782
+ 'multiple people', 'crowd', 'group', 'background characters', 'extra girl', 'extra person', 'clone', 'duplicate', 'twins',
783
+ ])
784
+ const NON_VISUAL_TAGS = new Set(['holding nothing'])
785
+ const EXCLUSIVE_TAG_GROUPS = {
786
+ 'looking at viewer': 'gaze_target', 'looking away': 'gaze_target',
787
+ 'light rays': 'light_beams', 'sun rays': 'light_beams', 'sunbeams': 'light_beams', 'sunlight rays': 'light_beams',
788
+ 'glowing': 'light_intensity', 'illuminated': 'light_intensity', 'bright': 'light_intensity', 'luminous': 'light_intensity', 'radiant': 'light_intensity',
789
+ 'backlight': 'backlighting', 'backlighting': 'backlighting',
790
+ 'rim light': 'rim_lighting', 'rim lighting': 'rim_lighting',
791
+ 'soft light': 'soft_lighting', 'soft lighting': 'soft_lighting',
792
+ 'floating particles': 'light_particles', 'light particles': 'light_particles', 'glowing particles': 'light_particles',
793
+ 'flowing dress': 'flowing_dress', 'dress flowing': 'flowing_dress',
794
+ 'hair blowing': 'wind_in_hair', 'wind in hair': 'wind_in_hair',
795
+ 'sad expression': 'sad_expression', 'sorrowful expression': 'sad_expression',
796
+ 'teary eyes': 'tearful_eyes', 'watery eyes': 'tearful_eyes', 'wet eyes': 'tearful_eyes',
797
+ }
798
+ const TAG_GROUP_LIMITS = { light_intensity: 2 }
799
+
800
+ function isCharacterIdentityTag(key) {
801
+ const compact = normalizeTagKey(key).replace(/_/g, ' ')
802
+ if (!compact) return false
803
+ if (CHARACTER_IDENTITY_EXACT_BLOCKLIST.has(compact)) return true
804
+ return CHARACTER_IDENTITY_PATTERNS.some(pattern => pattern.test(compact))
805
+ }
806
+
807
+ function cleanContentTags(text, maxTags = 65, stripCharacterTags = true, protectedCoreTags = [], allowMultiCharacter = false) {
808
+ const tags = splitTags(text)
809
+ const seen = new Set()
810
+ const cleaned = []
811
+ const artistRe = /^@\S+/
812
+ const protectedSet = new Set(protectedCoreTags.map(t => normalizeTagKey(t)))
813
+ const parenthesizedCoreRe = /^[a-z0-9_.'-]+_\([a-z0-9_.' -]{2,60}\)$/i
814
+ for (let tag of tags) {
815
+ tag = canonicalTagText(tag)
816
+ const key = normalizeTagKey(tag)
817
+ if (!key) continue
818
+ if (seen.has(key)) continue
819
+ if (QUALITY_BLOCKLIST.has(key)) continue
820
+ if (stripCharacterTags && CHARACTER_BLOCKLIST.has(key)) continue
821
+ if (stripCharacterTags && isCharacterIdentityTag(key)) continue
822
+ if (!allowMultiCharacter && MULTI_CHARACTER_BLOCKLIST.has(key)) continue
823
+ if (protectedSet.size && parenthesizedCoreRe.test(key) && !protectedSet.has(key)) continue
824
+ if (artistRe.test(tag.trim())) continue
825
+ if (tag.length > 80) continue
826
+ seen.add(key)
827
+ cleaned.push(tag)
828
+ }
829
+ const semanticKeys = cleaned.map(tag => normalizeTagKey(stripWrappingBrackets(tag)))
830
+ const fullNudityKey = semanticKeys.includes('nude') ? 'nude' : 'naked'
831
+ const hasFullNudity = semanticKeys.includes(fullNudityKey)
832
+ const hasSpecificMist = semanticKeys.includes('morning mist')
833
+ const hasClosedEyes = semanticKeys.some(k => k === 'closed eyes' || k === 'eyes closed')
834
+ const hasSheerFabric = semanticKeys.includes('sheer fabric')
835
+ const groupCounts = {}
836
+ const semanticCleaned = []
837
+ cleaned.forEach((tag, i) => {
838
+ const key = semanticKeys[i]
839
+ if (NON_VISUAL_TAGS.has(key)) return
840
+ if (hasFullNudity && ['nude', 'naked', 'topless', 'bottomless'].includes(key)) {
841
+ if (key !== fullNudityKey) return
842
+ }
843
+ if (hasSpecificMist && key === 'mist') return
844
+ if (hasClosedEyes && key.includes('looking') && key.includes('viewer')) return
845
+ if (hasSheerFabric && key === 'translucent fabric') return
846
+ const group = EXCLUSIVE_TAG_GROUPS[key.replace(/_/g, ' ')]
847
+ if (group) {
848
+ const count = groupCounts[group] || 0
849
+ if (count >= (TAG_GROUP_LIMITS[group] != null ? TAG_GROUP_LIMITS[group] : 1)) return
850
+ groupCounts[group] = count + 1
851
+ }
852
+ semanticCleaned.push(tag)
853
+ })
854
+ return semanticCleaned.slice(0, maxTags).join(', ')
855
+ }
856
+
857
+ function joinPromptParts(parts) {
858
+ const tags = []
859
+ const seen = new Set()
860
+ for (const part of parts) {
861
+ for (const tag of splitTags(part)) {
862
+ const canonical = canonicalTagText(tag)
863
+ const key = normalizeTagKey(canonical)
864
+ if (!key || seen.has(key)) continue
865
+ seen.add(key)
866
+ tags.push(canonical)
867
+ }
868
+ }
869
+ return tags.join(', ')
870
+ }
871
+
872
+ // ------------------------------------------------------------------
873
+ // 多人规划(移植自 anima multi_person_prompt)
874
+ // ------------------------------------------------------------------
875
+ const MULTI_PERSON_NEGATIVE_TAGS = [
876
+ 'split screen', 'comic panels', 'multiple views', 'character sheet',
877
+ 'duplicate characters', 'cloned character', 'extra person', 'extra girl', 'extra boy',
878
+ 'twins', 'merged bodies', 'fused characters',
879
+ ]
880
+
881
+ const MULTI_SAFE_SLOTS = new Set(['left', 'right', 'center', 'foreground', 'background', 'far left', 'far right'])
882
+ const MULTI_UNSAFE_COMPOSITION_MARKERS = [
883
+ 'split screen', 'panel', 'multiple views', 'alternate views', 'character sheet',
884
+ 'top left', 'top right', 'bottom left', 'bottom right',
885
+ ]
886
+ const MULTI_SAFE_SPATIAL_MODES = new Set(['shared_contact', 'shared_scene', 'explicit_positions'])
887
+
888
+ function buildMultiPersonPlanPrompt(userPrompt, fixedCharacters = {}) {
889
+ const fixedNote = Object.keys(fixedCharacters).length
890
+ ? `Locally saved characters explicitly mentioned by the user:\n${JSON.stringify(fixedCharacters, null, 2)}`
891
+ : 'No locally saved character name was detected.'
892
+ return `Plan one coherent Anima image containing 2 to 4 people.
893
+
894
+ 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.
895
+
896
+ 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".
897
+
898
+ 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.
899
+
900
+ 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.
901
+
902
+ Return JSON only with this exact shape:
903
+ {
904
+ "count_tags": ["2girls"],
905
+ "common_tags": ["medium shot", "outdoors"],
906
+ "characters": [
907
+ {
908
+ "slot": "left",
909
+ "name": "character name from the user",
910
+ "danbooru_candidate": "romanized_character_tag",
911
+ "role": "short semantic role such as rider or supporting girl",
912
+ "visual_label": "distinctive visible label such as white-haired fox girl",
913
+ "identity_anchors": ["3 to 6 short appearance tags"],
914
+ "emphasized_anchors": ["0 to 3 explicitly requested unusual traits"],
915
+ "appearance": "Visible identity traits for a non-fixed character only; empty for a locally saved character.",
916
+ "clothing": "One concise English clothing phrase.",
917
+ "expression": "One concise English expression phrase.",
918
+ "pose": "One concise English body pose that does not repeat the interaction.",
919
+ "props": ["visible prop held or worn by this person"]
920
+ },
921
+ {
922
+ "slot": "right",
923
+ "name": "second character name from the user",
924
+ "danbooru_candidate": "romanized_character_tag",
925
+ "appearance": "",
926
+ "clothing": "One concise English clothing phrase.",
927
+ "expression": "One concise English expression phrase.",
928
+ "pose": "One concise English body pose.",
929
+ "props": []
930
+ }
931
+ ],
932
+ "relationship_tag": "holding hands",
933
+ "interactions": [
934
+ "Character A is holding Character B's hand."
935
+ ],
936
+ "spatial_mode": "shared_contact",
937
+ "composition": "A single unified full-frame composition using one camera view."
938
+ }
939
+
940
+ Rules:
941
+ - Include exactly 2 to 4 character objects.
942
+ - count_tags must agree with the number and genders requested by the user.
943
+ - common_tags contain only shared scene, framing, camera, lighting, atmosphere, and count tags.
944
+ - relationship_tag is one short Danbooru-style relationship or action tag and appears immediately after the count tags in the final prompt.
945
+ - Do not put character names or character-specific appearance in common_tags.
946
+ - role is optional semantic bookkeeping and is not used to identify a person in the final interaction sentence.
947
+ - 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.
948
+ - identity_anchors must contain only 3 to 6 concise visible identity traits. For locally saved characters, select them only from the saved defining tags.
949
+ - 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.
950
+ - For locally saved characters, appearance must be empty and saved defining tags must never be contradicted.
951
+ - Do not output quality tags, safety tags, artist tags, Markdown, or explanations.
952
+ - Keep character fields and relationships visually concrete.
953
+ - Preserve the user's explicit interaction direction and gaze direction.
954
+ - Put the complete directed relationship in exactly one interactions entry. Character pose fields must not repeat the relationship.
955
+ - 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.
956
+ - 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.
957
+ - Prefer a single coherent moment rather than multiple competing actions.
958
+ - composition must use affirmative language to request one unified full-frame camera view.
959
+
960
+ ${fixedNote}
961
+
962
+ User request:
963
+ ${userPrompt}
964
+ `
965
+ }
966
+
967
+ function cleanMultiText(value, limit) {
968
+ return String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit).trim()
969
+ }
970
+
971
+ function multiStringTuple(value, limit, itemLimit) {
972
+ if (!Array.isArray(value)) return []
973
+ const result = []
974
+ for (const item of value) {
975
+ const text = cleanMultiText(item, itemLimit)
976
+ if (text) result.push(text)
977
+ }
978
+ return result.slice(0, limit)
979
+ }
980
+
981
+ function normalizeMultiSlot(value) {
982
+ const slot = cleanMultiText(value, 40).toLowerCase().replace(/_/g, ' ').replace(/-/g, ' ').replace(/\s+/g, ' ').trim()
983
+ return MULTI_SAFE_SLOTS.has(slot) ? slot : ''
984
+ }
985
+
986
+ function parseMultiPersonPlan(text) {
987
+ let raw = String(text || '').trim()
988
+ raw = raw.replace(/^```(?:json)?\s*/i, '')
989
+ raw = raw.replace(/\s*```$/, '')
990
+ const match = raw.match(/\{[\s\S]*\}/)
991
+ if (match) raw = match[0]
992
+ let data
993
+ try {
994
+ data = JSON.parse(raw)
995
+ } catch (e) {
996
+ return null
997
+ }
998
+ if (!data || typeof data !== 'object') return null
999
+ const rawCharacters = data.characters
1000
+ if (!Array.isArray(rawCharacters) || rawCharacters.length < 2 || rawCharacters.length > 4) return null
1001
+ if (rawCharacters.some(item => !item || typeof item !== 'object')) return null
1002
+
1003
+ const defaultSlots = {
1004
+ 2: ['left', 'right'],
1005
+ 3: ['left', 'center', 'right'],
1006
+ 4: ['far left', 'left', 'right', 'far right'],
1007
+ }[rawCharacters.length]
1008
+ const proposedSlots = rawCharacters.map(item => normalizeMultiSlot(item.slot))
1009
+ if (
1010
+ proposedSlots.some(slot => !slot) ||
1011
+ new Set(proposedSlots).size !== proposedSlots.length ||
1012
+ (rawCharacters.length === 2 && !(new Set(proposedSlots).size === 2 && ['left', 'right'].every(s => proposedSlots.includes(s)) || ['foreground', 'background'].every(s => proposedSlots.includes(s))))
1013
+ ) {
1014
+ proposedSlots.splice(0, proposedSlots.length, ...defaultSlots)
1015
+ }
1016
+
1017
+ const characters = proposedSlots.map((slot, index) => {
1018
+ const item = rawCharacters[index]
1019
+ return {
1020
+ slot,
1021
+ name: cleanMultiText(item.name, 120),
1022
+ danbooru_candidate: cleanMultiText(item.danbooru_candidate, 160),
1023
+ appearance: cleanMultiText(item.appearance, 500),
1024
+ clothing: cleanMultiText(item.clothing, 400),
1025
+ expression: cleanMultiText(item.expression, 240),
1026
+ pose: cleanMultiText(item.pose, 400),
1027
+ props: multiStringTuple(item.props, 12, 120),
1028
+ role: cleanMultiText(item.role, 80),
1029
+ visual_label: cleanMultiText(item.visual_label, 100),
1030
+ identity_anchors: multiStringTuple(item.identity_anchors, 6, 100),
1031
+ emphasized_anchors: multiStringTuple(item.emphasized_anchors, 3, 100),
1032
+ }
1033
+ })
1034
+
1035
+ const countTags = multiStringTuple(data.count_tags, 8, 80)
1036
+ const commonTags = multiStringTuple(data.common_tags, 50, 100)
1037
+ const interactions = multiStringTuple(data.interactions, 1, 500)
1038
+ const composition = cleanMultiText(data.composition, 700)
1039
+ let spatialMode = cleanMultiText(data.spatial_mode, 40).toLowerCase()
1040
+ const relationshipTag = cleanMultiText(data.relationship_tag, 120)
1041
+ if (!MULTI_SAFE_SPATIAL_MODES.has(spatialMode)) spatialMode = interactions.length ? 'shared_contact' : 'shared_scene'
1042
+ let compositionSafe = composition
1043
+ if (MULTI_UNSAFE_COMPOSITION_MARKERS.some(marker => compositionSafe.toLowerCase().includes(marker))) compositionSafe = ''
1044
+ return {
1045
+ count_tags: countTags.length ? countTags : [`${characters.length}people`],
1046
+ common_tags: commonTags,
1047
+ characters,
1048
+ interactions,
1049
+ composition: compositionSafe,
1050
+ spatial_mode: spatialMode,
1051
+ relationship_tag: relationshipTag,
1052
+ }
1053
+ }
1054
+
1055
+ function renderMultiPersonCharacter(character, opts = {}) {
1056
+ const {
1057
+ alias = '', resolvedIdentity = '', fixedTags = '', groupedContact = false,
1058
+ explicitPositions = false, identityAnchors = [], includePose = true,
1059
+ } = opts
1060
+ let label = String(alias || character.visual_label || character.role || '').trim()
1061
+ if (explicitPositions && character.slot) label = `${character.slot} ${label}`
1062
+ const identity = String(resolvedIdentity || character.danbooru_candidate || '').trim()
1063
+ const details = []
1064
+ if (identity && !fixedTags) details.push(identity)
1065
+ if (identityAnchors.length) {
1066
+ details.push(...identityAnchors)
1067
+ } else if (fixedTags) {
1068
+ for (const part of fixedTags.split(',')) {
1069
+ const t = part.trim().replace(/^ +| +$/g, '').replace(/^\(|\)$/g, '')
1070
+ if (t) details.push(t)
1071
+ }
1072
+ }
1073
+ if (!identityAnchors.length && !fixedTags && character.appearance) details.push(character.appearance)
1074
+ if (character.clothing) details.push(character.clothing)
1075
+ if (character.expression) details.push(character.expression)
1076
+ if (includePose && character.pose) details.push(character.pose)
1077
+ if (character.props && character.props.length) details.push(...character.props)
1078
+ const joined = details.filter(Boolean).join(', ')
1079
+ return `${label}: ${joined}.`
1080
+ }
1081
+
1082
+ // ------------------------------------------------------------------
1083
+ // 多人尺寸自动选择(移植自 anima command_actions multi_person 分支)
1084
+ // ------------------------------------------------------------------
1085
+ function multiPersonAutoSize(prompt, allowedSizes) {
1086
+ if (!Array.isArray(allowedSizes) || !allowedSizes.length) return null
1087
+ const promptLower = String(prompt || '').toLowerCase()
1088
+ const threeOrMore = /\b(?:三|四|3|4)\s*(?:人|个|名|girls?|boys?|people)\b|\b(?:3|4)(?:girls?|boys?|people)\b/.test(promptLower)
1089
+ const verticallyStacked = [
1090
+ '骑在肩', '骑肩', '肩膀上', '背着', '抱起', '扑倒', '压在', '上下叠',
1091
+ 'on the shoulders', 'piggyback', 'carrying', 'on top of', 'stacked',
1092
+ ].some(marker => promptLower.includes(marker))
1093
+ const physicalContact = [
1094
+ '牵手', '拥抱', '接吻', '搂着', '抱着', '挽着',
1095
+ 'holding hands', 'hugging', 'embracing', 'kissing', 'arm around',
1096
+ ].some(marker => promptLower.includes(marker))
1097
+ const target = threeOrMore
1098
+ ? [1216, 832]
1099
+ : verticallyStacked
1100
+ ? [1024, 1536]
1101
+ : physicalContact
1102
+ ? [1024, 1024]
1103
+ : [1152, 896]
1104
+ let best = allowedSizes[0]
1105
+ let bestScore = Infinity
1106
+ for (const size of allowedSizes) {
1107
+ const ratioDiff = Math.abs(size[0] / size[1] - target[0] / target[1])
1108
+ const areaDiff = Math.abs(size[0] * size[1] - target[0] * target[1])
1109
+ const score = ratioDiff * 10000 + areaDiff
1110
+ if (score < bestScore) {
1111
+ bestScore = score
1112
+ best = size
1113
+ }
1114
+ }
1115
+ return best
1116
+ }
1117
+
1118
+ // ------------------------------------------------------------------
1119
+ // 插件主体
1120
+ // ------------------------------------------------------------------
1121
+ exports.apply = async function apply(ctx, cfg) {
1122
+ // 注意:不在此处 extend p_system 表 —— 该表由 p-qiandao 等 p 系插件创建。
1123
+ // 重复声明同一张表可能导致 Koishi 的 schema 迁移冲突,拖垮签到插件。
1124
+
1125
+ const logger = ctx.logger('p-draw')
1126
+ ctx.i18n.define('zh-CN', zhCN)
1127
+
1128
+ // 运行时数据(画师组/固定角色)持久化到 p_draw_config 表,而不是调用 scope.update
1129
+ // 写 koishi.yml:scope.update 会触发插件重载,导致正在生成的图被 dispose(Context has
1130
+ // been disposed),且连续多次写入时配置文件会被冲掉(曾出现配置整体恢复成默认)。
1131
+ try {
1132
+ ctx.model.extend('p_draw_config', {
1133
+ id: 'unsigned',
1134
+ fixed_characters: 'json',
1135
+ artist_presets: 'json',
1136
+ active_artist_preset: 'text',
1137
+ default_artist_tags: 'text',
1138
+ user_models: 'json',
1139
+ }, { autoInc: true })
1140
+ } catch (e) {
1141
+ logger.warn(`p_draw_config 表初始化失败:${e.message}`)
1142
+ }
1143
+
1144
+ // 用户自选模型偏好(userid -> unet 文件名),持久化在 p_draw_config.user_models
1145
+ if (!cfg.userModels || typeof cfg.userModels !== 'object') cfg.userModels = {}
1146
+
1147
+ const tempDir = path.join(__dirname, 'temp')
1148
+ if (!fs.existsSync(tempDir)) {
1149
+ try { fs.mkdirSync(tempDir, { recursive: true }) } catch (e) { logger.warn('无法创建临时目录:' + e.message) }
1150
+ }
1151
+
1152
+ const baseUrl = () => normalizeBaseUrl(cfg.comfyuiBaseUrl)
1153
+
1154
+ function parseAllowedSizes() {
1155
+ const sizes = []
1156
+ for (const item of cfg.allowedSizes || []) {
1157
+ const m = String(item).match(/^\s*(\d{2,5})\s*[xX×**✕✖хХ]\s*(\d{2,5})\s*$/)
1158
+ if (m) sizes.push([parseInt(m[1]), parseInt(m[2])])
1159
+ }
1160
+ return sizes
1161
+ }
1162
+
1163
+ async function comfyGet(apiPath, timeout = 20000) {
1164
+ const controller = new AbortController()
1165
+ const timer = setTimeout(() => controller.abort(), timeout)
1166
+ try {
1167
+ const res = await fetch(baseUrl() + apiPath, { signal: controller.signal })
1168
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
1169
+ return await res.json()
1170
+ } finally {
1171
+ clearTimeout(timer)
1172
+ }
1173
+ }
1174
+ async function comfyPost(apiPath, body, timeout = 20000) {
1175
+ const controller = new AbortController()
1176
+ const timer = setTimeout(() => controller.abort(), timeout)
1177
+ try {
1178
+ const res = await fetch(baseUrl() + apiPath, {
1179
+ method: 'POST',
1180
+ headers: { 'Content-Type': 'application/json' },
1181
+ body: JSON.stringify(body),
1182
+ signal: controller.signal,
1183
+ })
1184
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
1185
+ return await res.json()
1186
+ } finally {
1187
+ clearTimeout(timer)
1188
+ }
1189
+ }
1190
+ async function comfyGetBytes(apiPath, timeout = 120000) {
1191
+ const controller = new AbortController()
1192
+ const timer = setTimeout(() => controller.abort(), timeout)
1193
+ try {
1194
+ const res = await fetch(baseUrl() + apiPath, { signal: controller.signal })
1195
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
1196
+ return Buffer.from(await res.arrayBuffer())
1197
+ } finally {
1198
+ clearTimeout(timer)
1199
+ }
1200
+ }
1201
+
1202
+ // ---------------- 状态 ----------------
1203
+ let objectInfoCache = null
1204
+ let objectInfoCacheAt = 0
1205
+
1206
+ // /object_info 可能返回体巨大或接口本身很慢(自定义节点多),
1207
+ // 用短超时 + 10 分钟缓存,避免每次状态检查都干等。
1208
+ async function getObjectInfoCached() {
1209
+ if (objectInfoCache && Date.now() - objectInfoCacheAt < 10 * 60 * 1000) {
1210
+ return objectInfoCache
1211
+ }
1212
+ const data = await comfyGet('/object_info', 5000)
1213
+ objectInfoCache = data
1214
+ objectInfoCacheAt = Date.now()
1215
+ return data
1216
+ }
1217
+
1218
+ async function statusPayload() {
1219
+ const sizes = parseAllowedSizes()
1220
+ const payload = {
1221
+ ok: true,
1222
+ base_url: baseUrl(),
1223
+ workflow: cfg.workflow,
1224
+ allowed_sizes: sizes.map(s => `${s[0]}x${s[1]}`),
1225
+ comfyui_api_reachable: false,
1226
+ }
1227
+ let stats
1228
+ try {
1229
+ stats = await comfyGet('/system_stats', 8000)
1230
+ } catch (e) {
1231
+ payload.ok = false
1232
+ payload.error = String(e && e.message || e)
1233
+ payload.connection_issue = classifyComfyError(e)
1234
+ payload.comfyui_api_reachable = false
1235
+ return payload
1236
+ }
1237
+ payload.comfyui_api_reachable = true
1238
+ // /object_info 可能很慢,单独容错:失败不判离线
1239
+ let objectInfo = null
1240
+ try {
1241
+ objectInfo = await getObjectInfoCached()
1242
+ } catch (e) {
1243
+ payload.object_info_warning = String(e && e.message || e)
1244
+ }
1245
+ const devices = stats && stats.devices ? stats.devices : []
1246
+ const device = devices[0] || {}
1247
+ payload.comfyui_version = stats && stats.system ? stats.system.comfyui_version : undefined
1248
+ payload.gpu = device.name
1249
+ payload.vram_total_mb = Math.round((device.vram_total || 0) / 1024 / 1024)
1250
+ payload.vram_free_mb = Math.round((device.vram_free || 0) / 1024 / 1024)
1251
+ if (objectInfo) {
1252
+ const unetList = availableModels(objectInfo, 'UNETLoader', 'unet_name')
1253
+ const clipList = availableModels(objectInfo, 'CLIPLoader', 'clip_name')
1254
+ const vaeList = availableModels(objectInfo, 'VAELoader', 'vae_name')
1255
+ payload.unet_available = unetList.includes(cfg.unetName)
1256
+ payload.unet_models = unetList
1257
+ payload.clip_available = clipList.includes(cfg.clipName)
1258
+ payload.vae_available = vaeList.includes(cfg.vaeName)
1259
+ } else {
1260
+ payload.unet_available = undefined
1261
+ payload.clip_available = undefined
1262
+ payload.vae_available = undefined
1263
+ }
1264
+ return payload
1265
+ }
1266
+
1267
+ function classifyComfyError(e) {
1268
+ const text = String((e && e.message) || e || '').toLowerCase()
1269
+ if (text.includes('timeout') || text.includes('timed out')) return 'timeout'
1270
+ if (text.includes('refused') || text.includes('econnrefused')) return 'refused'
1271
+ if (text.includes('dns') || text.includes('enotfound') || text.includes('getaddrinfo')) return 'dns'
1272
+ return 'unknown'
1273
+ }
1274
+
1275
+ function availableModels(objectInfo, node, inputName) {
1276
+ try {
1277
+ const value = objectInfo[node].input.required[inputName]
1278
+ if (Array.isArray(value) && value.length && Array.isArray(value[0])) {
1279
+ return value[0].map(String)
1280
+ }
1281
+ } catch (e) { /* ignore */ }
1282
+ return []
1283
+ }
1284
+
1285
+ function statusText(payload) {
1286
+ if (!payload.comfyui_api_reachable) {
1287
+ const issue = payload.connection_issue
1288
+ const hint = issue === 'timeout'
1289
+ ? '连接超时。请确认 ComfyUI 已启动,且监听了可从本机访问的地址;跨机访问需确认防火墙放行 8188 端口。'
1290
+ : issue === 'refused'
1291
+ ? '连接被拒绝。请确认 ComfyUI 已启动,且端口与地址正确;服务端需使用 --listen 0.0.0.0 启动才能被局域网访问。'
1292
+ : issue === 'dns'
1293
+ ? '域名无法解析。请确认地址正确。'
1294
+ : '请先启动 ComfyUI,并确认地址正确。'
1295
+ return `ComfyUI 状态:离线\n地址:${payload.base_url}\n提示:${hint}\n错误:${payload.error || 'unknown'}`
1296
+ }
1297
+ const modelStatus = (configured, available) => {
1298
+ if (available === undefined) return '(未获取)'
1299
+ return available ? '✓' : '✗ 不可用'
1300
+ }
1301
+ const lines = [
1302
+ `ComfyUI 状态:在线`,
1303
+ `版本:${payload.comfyui_version || '未知'}`,
1304
+ `GPU:${payload.gpu || '未知'}(显存 ${payload.vram_total_mb}MB / 空闲 ${payload.vram_free_mb}MB)`,
1305
+ `主模型:${cfg.unetName} ${modelStatus(cfg.unetName, payload.unet_available)}`,
1306
+ `文本编码器:${cfg.clipName} ${modelStatus(cfg.clipName, payload.clip_available)}`,
1307
+ `VAE:${cfg.vaeName} ${modelStatus(cfg.vaeName, payload.vae_available)}`,
1308
+ `可用尺寸:${payload.allowed_sizes.join('、')}`,
1309
+ ]
1310
+ if (payload.object_info_warning) {
1311
+ lines.push(`模型列表查询失败:${payload.object_info_warning}(不影响生图)`)
1312
+ }
1313
+ return lines.join('\n')
1314
+ }
1315
+
1316
+ async function diagnoseText(session) {
1317
+ const payload = await statusPayload()
1318
+ const lines = ['【p-draw 诊断】']
1319
+ lines.push(`插件版本:${require('./package.json').version || '未知'}`)
1320
+ lines.push(`ComfyUI 地址:${payload.base_url}`)
1321
+ lines.push(`工作流:${cfg.customWorkflowEnabled ? '自定义 ' + (cfg.customWorkflowPath || '(未填写路径)') : (cfg.workflow || 'anima_t2i')}`)
1322
+ lines.push(`模型:${cfg.unetName} / ${cfg.clipName} / ${cfg.vaeName}`)
1323
+ lines.push(`默认尺寸:${cfg.width}x${cfg.height},步数 ${cfg.steps},CFG ${cfg.cfg},采样器 ${cfg.samplerName}/${cfg.scheduler}`)
1324
+ lines.push(`队列:${cfg.queueEnabled ? '启用(上限 ' + (cfg.queueMaxRequests || '∞') + ')' : '关闭'}`)
1325
+ lines.push(`P 点价格:${cfg.price}`)
1326
+ lines.push(`权限:${cfg.adminOnly ? '仅管理员' : '开放'}`)
1327
+ lines.push(`你的 userId:${session.userId}`)
1328
+ lines.push(`管理员列表:${(cfg.adminUsers || []).length ? cfg.adminUsers.join(', ') : '(空)'}`)
1329
+ lines.push(`管理员匹配:${isAdminUser(session) ? '是(免 P 点)' : '否(会扣 P 点)'}`)
1330
+ if (payload.comfyui_api_reachable) {
1331
+ lines.push(`ComfyUI API:可达`)
1332
+ lines.push(`版本:${payload.comfyui_version || '未知'}`)
1333
+ lines.push(`GPU:${payload.gpu || '未知'}(显存 ${payload.vram_total_mb}MB / 空闲 ${payload.vram_free_mb}MB)`)
1334
+ lines.push(`模型可用性:主模型${payload.unet_available === undefined ? '(未获取)' : payload.unet_available ? '✓' : '✗'} / 编码器${payload.clip_available === undefined ? '(未获取)' : payload.clip_available ? '✓' : '✗'} / VAE${payload.vae_available === undefined ? '(未获取)' : payload.vae_available ? '✓' : '✗'}`)
1335
+ if (payload.unet_models && payload.unet_models.length) {
1336
+ lines.push(`可选模型:\n${payload.unet_models.join('\n')}`)
1337
+ }
1338
+ } else {
1339
+ lines.push(`ComfyUI API:不可达`)
1340
+ lines.push(`错误:${payload.error || 'unknown'}`)
1341
+ }
1342
+ if (payload.object_info_warning) {
1343
+ lines.push(`模型列表接口:${payload.object_info_warning}(不影响生图)`)
1344
+ }
1345
+ return lines.join('\n')
1346
+ }
1347
+
1348
+ // ---------------- 生成 ----------------
1349
+ let generationQueue = Promise.resolve()
1350
+ let queueSize = 0
1351
+ let queueInFlight = 0
1352
+
1353
+ function queueMax() {
1354
+ return Math.max(0, parseInt(cfg.queueMaxRequests) || 0)
1355
+ }
1356
+
1357
+ function enqueue(work) {
1358
+ const maxQueue = queueMax()
1359
+ if (maxQueue && queueInFlight + queueSize >= maxQueue) {
1360
+ return { ok: false, error: 'queue_full', message: `生成队列已满(最多 ${maxQueue} 个),本次请求已丢弃,请稍后再试。` }
1361
+ }
1362
+ queueSize += 1
1363
+ const position = queueInFlight + queueSize
1364
+ const task = generationQueue.then(async () => {
1365
+ queueSize -= 1
1366
+ queueInFlight += 1
1367
+ try {
1368
+ return await work()
1369
+ } finally {
1370
+ queueInFlight -= 1
1371
+ }
1372
+ })
1373
+ generationQueue = task.catch(() => {})
1374
+ return { ok: true, task, position }
1375
+ }
1376
+
1377
+ async function ensureComfyuiReady() {
1378
+ // 只做轻量的 /system_stats 探测,不请求慢接口 /object_info
1379
+ try {
1380
+ await comfyGet('/system_stats', 8000)
1381
+ } catch (e) {
1382
+ const issue = classifyComfyError(e)
1383
+ const hint = issue === 'timeout'
1384
+ ? '连接超时。请确认 ComfyUI 已启动,且监听了可从本机访问的地址;跨机访问需确认防火墙放行 8188 端口。'
1385
+ : issue === 'refused'
1386
+ ? '连接被拒绝。请确认 ComfyUI 已启动,且端口与地址正确;服务端需使用 --listen 0.0.0.0 启动才能被局域网访问。'
1387
+ : issue === 'dns'
1388
+ ? '域名无法解析。请确认地址正确。'
1389
+ : '请先启动 ComfyUI,并确认地址正确。'
1390
+ return { ok: false, message: `ComfyUI 未启动或无法连接(${baseUrl()})。${hint}` }
1391
+ }
1392
+ return { ok: true }
1393
+ }
1394
+
1395
+ // ---------------- 用户自选模型 ----------------
1396
+ // 从 ComfyUI /object_info(10 分钟缓存)读取真实的 UNET 模型列表
1397
+ async function listUnetModels() {
1398
+ try {
1399
+ const objectInfo = await getObjectInfoCached()
1400
+ const list = availableModels(objectInfo, 'UNETLoader', 'unet_name')
1401
+ if (list.length) return list
1402
+ } catch (e) { /* ignore */ }
1403
+ return []
1404
+ }
1405
+
1406
+ // 解析该用户当前生效的 UNET 模型:有偏好且仍存在于 ComfyUI 时用偏好,否则回落默认
1407
+ async function resolveUnet(USERID) {
1408
+ const chosen = cfg.userModels && cfg.userModels[USERID]
1409
+ if (!chosen || !String(chosen).trim()) return cfg.unetName
1410
+ if (String(chosen).trim() === cfg.unetName) return cfg.unetName
1411
+ const list = await listUnetModels()
1412
+ if (list.length && !list.includes(String(chosen).trim())) return cfg.unetName
1413
+ return String(chosen).trim()
1414
+ }
1415
+
1416
+ // 名称匹配:精确 > 前缀唯一 > 包含唯一;多个匹配返回 { multiple: [...] }
1417
+ function matchUnetModel(input, list) {
1418
+ // 规范化:连字符与下划线视为等价(用户常写 anima-aesthetic,模型名是 anima_aesthetic)
1419
+ const norm = (s) => String(s || '').trim().toLowerCase().replace(/[-_]/g, '~')
1420
+ const key = norm(input)
1421
+ if (!key) return null
1422
+ const normList = list.map(m => ({ raw: m, n: norm(m) }))
1423
+ const exact = normList.find(m => m.n === key)
1424
+ if (exact) return exact.raw
1425
+ const starts = normList.filter(m => m.n.startsWith(key))
1426
+ if (starts.length === 1) return starts[0].raw
1427
+ const includes = normList.filter(m => m.n.includes(key))
1428
+ if (includes.length === 1) return includes[0].raw
1429
+ if (includes.length > 1) return { multiple: includes.map(m => m.raw) }
1430
+ return null
1431
+ }
1432
+
1433
+ async function runComfyGenerate(prompt, size, overrides) {
1434
+ const sizes = parseAllowedSizes()
1435
+ const requestedWidth = (size && size[0]) || overrides.width || cfg.width
1436
+ const requestedHeight = (size && size[1]) || overrides.height || cfg.height
1437
+ const width = Math.round(Number(requestedWidth) || cfg.width)
1438
+ const height = Math.round(Number(requestedHeight) || cfg.height)
1439
+ const steps = Math.round(Number(overrides.steps) || cfg.steps)
1440
+ const cfgVal = Number(overrides.cfg) || cfg.cfg
1441
+ const seed = Number(overrides.seed) || crypto.randomInt(1, 2 ** 32 - 1)
1442
+ const negativePrompt = overrides.negativePrompt || cfg.negativePrompt || ''
1443
+ const unetName = overrides.unet || cfg.unetName
1444
+
1445
+ const workCfg = unetName && unetName !== cfg.unetName ? Object.assign({}, cfg, { unetName }) : cfg
1446
+ const i2iImage = overrides.i2iImage
1447
+ if (i2iImage && cfg.customWorkflowEnabled && cfg.customWorkflowPath) {
1448
+ return { ok: false, message: 'i2i(以图生图)暂不支持自定义工作流(customWorkflowEnabled),请关闭后再试。' }
1449
+ }
1450
+ const promptBody = i2iImage
1451
+ ? animaI2IWorkflow(workCfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, i2iImage, Number(cfg.img2imgDenoise) || 0.55)
1452
+ : buildWorkflow(workCfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, Boolean(size))
1453
+
1454
+ const clientId = crypto.randomUUID()
1455
+ const submit = await comfyPost('/prompt', { prompt: promptBody, client_id: clientId }, 20000)
1456
+ const promptId = submit && submit.prompt_id
1457
+ if (!promptId) {
1458
+ return { ok: false, message: `ComfyUI 提交失败:${JSON.stringify(submit || {}).slice(0, 300)}` }
1459
+ }
1460
+
1461
+ const timeoutMs = Math.max(1, parseInt(cfg.timeout) || 300) * 1000
1462
+ const pollMs = Math.max(1, parseInt(cfg.pollInterval) || 2) * 1000
1463
+ const history = await waitComfyResult(ctx, comfyGet, baseUrl(), promptId, clientId, timeoutMs, pollMs)
1464
+ if (!history) {
1465
+ return { ok: false, message: `ComfyUI 排队或生成过久(超过 ${Math.floor(timeoutMs / 1000)} 秒)。` }
1466
+ }
1467
+
1468
+ const status = history.status || {}
1469
+ if (status.status_str && status.status_str !== 'success') {
1470
+ return { ok: false, message: `ComfyUI 工作流执行失败:${JSON.stringify(status).slice(0, 400)}` }
1471
+ }
1472
+
1473
+ const images = outputImages(history)
1474
+ if (!images.length) {
1475
+ return { ok: false, message: 'ComfyUI 完成了任务但没有产出图片。' }
1476
+ }
1477
+
1478
+ const outputs = []
1479
+ for (let i = 0; i < images.length; i++) {
1480
+ const image = images[i]
1481
+ const query = new URLSearchParams({
1482
+ filename: image.filename || '',
1483
+ subfolder: image.subfolder || '',
1484
+ type: image.type || 'output',
1485
+ })
1486
+ try {
1487
+ const buffer = await comfyGetBytes(`/view?${query.toString()}`, 120000)
1488
+ const ext = path.extname(image.filename || '.png') || '.png'
1489
+ const filePath = path.join(tempDir, `${Date.now()}_${crypto.randomBytes(4).toString('hex')}_pdraw${ext}`)
1490
+ await fsp.writeFile(filePath, buffer)
1491
+ outputs.push(pathToFileURL(filePath).href)
1492
+ } catch (e) {
1493
+ logger.warn(`下载生成图片失败:${e.message}`)
1494
+ }
1495
+ }
1496
+ if (!outputs.length) {
1497
+ return { ok: false, message: 'ComfyUI 已完成任务,但图片下载失败。' }
1498
+ }
1499
+ return {
1500
+ ok: true,
1501
+ outputs,
1502
+ seed,
1503
+ width,
1504
+ height,
1505
+ steps,
1506
+ cfg: cfgVal,
1507
+ prompt_id: promptId,
1508
+ }
1509
+ }
1510
+
1511
+ // ---------------- LLM 提示词优化 ----------------
1512
+ // 兼容 OpenAI / DeepSeek 的返回结构:
1513
+ // content 可能是字符串、null、数组([{type:'text',text:'...'}]),
1514
+ // DeepSeek V4 思考模式下答案可能只在 reasoning_content 里。
1515
+ function extractLlmText(res) {
1516
+ if (!res) return ''
1517
+ const choice = res.choices && res.choices[0]
1518
+ if (!choice) return ''
1519
+ const message = choice.message || {}
1520
+ let content = message.content
1521
+ // content 为空('' / null / undefined)时回退到 reasoning_content
1522
+ if (!content || (Array.isArray(content) && !content.length)) {
1523
+ content = message.reasoning_content
1524
+ }
1525
+ if (Array.isArray(content)) {
1526
+ return content
1527
+ .filter(part => part && (part.type === 'text' || typeof part.text === 'string'))
1528
+ .map(part => part.text || '')
1529
+ .join('')
1530
+ .trim()
1531
+ }
1532
+ if (typeof content === 'string') return content.trim()
1533
+ if (content && typeof content.text === 'string') return content.text.trim()
1534
+ return ''
1535
+ }
1536
+
1537
+ // ---------------- 联网搜索 ----------------
1538
+ const WEB_SEARCH_KEYWORDS = [
1539
+ '联网', '搜索', '搜一下', '查一下', '参考资料', '官方图', '设定图', '资料',
1540
+ 'web search', 'online',
1541
+ ]
1542
+
1543
+ function wantsWebSearch(prompt) {
1544
+ if (!cfg.webSearchEnabled || !cfg.tavilyApiKey) return false
1545
+ const lower = String(prompt || '').toLowerCase()
1546
+ return WEB_SEARCH_KEYWORDS.some(keyword => lower.includes(keyword.toLowerCase()))
1547
+ }
1548
+
1549
+ // 把命中的固定角色 tags 渲染进优化模板的 {character_rule} 占位符。
1550
+ function buildCharacterRule(prompt) {
1551
+ const text = String(prompt || '')
1552
+ for (const [name, tags] of Object.entries(parsePresetList(cfg.fixedCharacters))) {
1553
+ if (name && text.includes(name)) {
1554
+ return `用户提到了固定角色「${name}」,其外观 tags 为:${tags} 请优先保留这些特征。`
1555
+ }
1556
+ }
1557
+ return ''
1558
+ }
1559
+
1560
+ async function webSearch(prompt) {
1561
+ const controller = new AbortController()
1562
+ const timer = setTimeout(() => controller.abort(), 30000)
1563
+ try {
1564
+ const maxResults = Math.max(1, Math.min(parseInt(cfg.webSearchMaxResults) || 5, 8))
1565
+ const depth = ['basic', 'advanced'].includes(cfg.webSearchDepth) ? cfg.webSearchDepth : 'basic'
1566
+ const queryTemplate = String(cfg.webSearchQueryTemplate || '').trim() || '{prompt} anime game character official art visual design'
1567
+ const query = queryTemplate.includes('{prompt}')
1568
+ ? queryTemplate.replace(/\{prompt\}/g, prompt).trim()
1569
+ : `${prompt} ${queryTemplate}`.trim()
1570
+ const rawRes = await fetch('https://api.tavily.com/search', {
1571
+ method: 'POST',
1572
+ headers: { 'Content-Type': 'application/json' },
1573
+ body: JSON.stringify({
1574
+ api_key: cfg.tavilyApiKey,
1575
+ query,
1576
+ max_results: maxResults,
1577
+ search_depth: depth,
1578
+ topic: 'general',
1579
+ }),
1580
+ signal: controller.signal,
1581
+ })
1582
+ if (!rawRes.ok) {
1583
+ const bodyText = await rawRes.text().catch(() => '')
1584
+ logger.warn(`联网搜索接口返回错误:HTTP ${rawRes.status} ${bodyText.slice(0, 200)}`)
1585
+ return ''
1586
+ }
1587
+ const data = await rawRes.json()
1588
+ const results = Array.isArray(data.results) ? data.results : []
1589
+ const lines = [`用户主题:${prompt}`, '搜索结果:']
1590
+ for (const result of results) {
1591
+ const title = String(result.title || '').trim()
1592
+ const content = String(result.content || '').trim().replace(/\s+/g, ' ').slice(0, 500)
1593
+ const url = String(result.url || '').trim()
1594
+ if (!title && !content) continue
1595
+ lines.push(`- ${title}${content ? '\n摘要:' + content : ''}${url ? '\nURL:' + url : ''}`)
1596
+ }
1597
+ if (lines.length <= 2) {
1598
+ logger.warn('联网搜索无结果')
1599
+ return ''
1600
+ }
1601
+ const context = lines.join('\n')
1602
+ logger.info(`联网搜索完成,共 ${results.length} 条结果`)
1603
+ return context
1604
+ } catch (e) {
1605
+ logger.warn(`联网搜索失败:${e.message}`)
1606
+ return ''
1607
+ } finally {
1608
+ clearTimeout(timer)
1609
+ }
1610
+ }
1611
+
1612
+ async function optimizePrompt(session, userPrompt, force = false, img2imgRule = '') {
1613
+ if (!cfg.promptOptimizeEnabled && !force) {
1614
+ return { ok: true, prompt: userPrompt, reason: 'optimize_disabled' }
1615
+ }
1616
+ if (!cfg.llmModel || !cfg.llmBaseUrl) {
1617
+ logger.warn('提示词优化已开启但未配置 llmModel / llmBaseUrl,跳过优化')
1618
+ return { ok: false, prompt: userPrompt, reason: 'llm_not_configured' }
1619
+ }
1620
+ let searchBlock = ''
1621
+ if (wantsWebSearch(userPrompt)) {
1622
+ searchBlock = await webSearch(userPrompt)
1623
+ }
1624
+ const characterRule = buildCharacterRule(userPrompt)
1625
+ const defaultTemplate = `你是为图像生成模型编写正面提示词的 AI 画师。\n\n请根据用户的原始要求设计一幅完整、协调、具有视觉吸引力的画面,并将结果输出为英文 Danbooru-style tags。\n\n输出要求:\n- 只输出一行英文 tags,使用英文逗号分隔。\n- 不要输出解释、分析、标题、编号、Markdown、代码块或中文。\n- 不要输出 masterpiece、best quality、score 等质量前缀。\n- 不要输出画师 tags;质量词和画师组会由程序另行拼接。\n- 尽量使用模型容易理解的可见画面描述。\n- 保持用户明确指定的角色、主体、人数、关键服装、动作、表情和道具。\n- 以最终图像协调、精致、有表现力和好看为优先。\n\n角色和动态上下文:\n{character_rule}\n{img2img_rule}\n{search_block}\n\n用户原始要求:\n{theme}`
1626
+ const template = (cfg.promptOptimizeTemplate || '').trim() || defaultTemplate
1627
+ const searchBlockText = searchBlock
1628
+ ? `联网搜索参考信息(请尽量依据这些内容补全角色外观与设定):\n${searchBlock}`
1629
+ : ''
1630
+ const rendered = template
1631
+ .replace(/\{theme\}/g, userPrompt)
1632
+ .replace(/\{search_block\}/g, searchBlockText)
1633
+ .replace(/\{character_rule\}/g, characterRule)
1634
+ .replace(/\{outfit_transfer_rule\}/g, '')
1635
+ .replace(/\{reference_rule\}/g, '')
1636
+ .replace(/\{img2img_rule\}/g, img2imgRule)
1637
+ .replace(/\{sensual_rule\}/g, '')
1638
+ .replace(/[ \t]+\n/g, '\n')
1639
+ .replace(/\n{3,}/g, '\n\n')
1640
+ .trim()
1641
+ const controller = new AbortController()
1642
+ const timer = setTimeout(() => controller.abort(), 120000)
1643
+ try {
1644
+ const headers = { 'Content-Type': 'application/json' }
1645
+ if (cfg.llmApiKey) headers.Authorization = `Bearer ${cfg.llmApiKey}`
1646
+ const rawRes = await fetch(
1647
+ String(cfg.llmBaseUrl).replace(/\/+$/, '') + '/chat/completions',
1648
+ {
1649
+ method: 'POST',
1650
+ headers,
1651
+ body: JSON.stringify({
1652
+ model: cfg.llmModel,
1653
+ messages: [
1654
+ { role: 'system', content: rendered },
1655
+ { role: 'user', content: userPrompt },
1656
+ ],
1657
+ max_tokens: Math.max(64, parseInt(cfg.llmMaxTokens) || 1000),
1658
+ // DeepSeek V4 默认开启思考模式,改为关闭以保证 content 直接返回 tags
1659
+ thinking: { type: 'disabled' },
1660
+ }),
1661
+ signal: controller.signal,
1662
+ },
1663
+ )
1664
+ if (!rawRes.ok) {
1665
+ const bodyText = await rawRes.text().catch(() => '')
1666
+ const reason = `HTTP ${rawRes.status}${bodyText ? ' ' + bodyText.slice(0, 200) : ''}`
1667
+ logger.warn(`提示词优化接口返回错误:${reason}`)
1668
+ return { ok: false, prompt: userPrompt, reason }
1669
+ }
1670
+ const res = await rawRes.json()
1671
+ const text = extractLlmText(res)
1672
+ if (!text) {
1673
+ const reason = '接口未返回可用的文本内容'
1674
+ logger.warn(`提示词优化:${reason} 原始响应=${JSON.stringify(res).slice(0, 500)}`)
1675
+ return { ok: false, prompt: userPrompt, reason }
1676
+ }
1677
+ return { ok: true, prompt: text }
1678
+ } catch (e) {
1679
+ const reason = String(e && e.message || e)
1680
+ logger.warn(`提示词优化失败:${reason}`)
1681
+ return { ok: false, prompt: userPrompt, reason }
1682
+ } finally {
1683
+ clearTimeout(timer)
1684
+ }
1685
+ }
1686
+
1687
+ function extractSeriesOptimizeJson(text) {
1688
+ let raw = String(text || '').trim()
1689
+ if (raw.startsWith('```')) raw = (raw.match(/```(?:json)?([\s\S]*?)```/) || [null, raw])[1].trim()
1690
+ const start = raw.indexOf('{')
1691
+ const end = raw.lastIndexOf('}')
1692
+ if (start === -1 || end === -1 || end <= start) return null
1693
+ try { return JSON.parse(raw.slice(start, end + 1)) } catch (e) { return null }
1694
+ }
1695
+
1696
+ function filterFixedTags(tags, drops) {
1697
+ if (!tags) return ''
1698
+ const dropKeys = (drops || []).map(d => canonicalTagText(String(d))).filter(Boolean)
1699
+ if (!dropKeys.length) return tags
1700
+ return splitTags(tags)
1701
+ .filter(t => !dropKeys.some(k => k && canonicalTagText(t).includes(k)))
1702
+ .join(', ')
1703
+ }
1704
+
1705
+ // 连续图专用的阶段优化:LLM 把「角色 + 阶段描述」转成 Danbooru tags,并返回
1706
+ // 要从固定角色 tags 中移除的冲突项(如固定 silver hair、阶段变成 black hair)。
1707
+ async function optimizeSeriesStage(userPrompt, identity) {
1708
+ if (!cfg.llmModel || !cfg.llmBaseUrl) {
1709
+ return { ok: false, prompt: userPrompt, drops: [], reason: 'llm_not_configured' }
1710
+ }
1711
+ const fixedTags = identity && parsePresetList(cfg.fixedCharacters)[identity]
1712
+ ? parsePresetList(cfg.fixedCharacters)[identity]
1713
+ : ''
1714
+ const template = `你是为图像生成模型编写正面提示词的 AI 画师。这是「同一个角色」的连续变化过程中的某一个阶段。\n\n用户给出一行描述:<角色身份>,<本阶段的外貌/状态描述>。\n\n固定角色 tags(身份锚点,包含角色名标签、种族、体型、标志特征,也可能包含发色、瞳色等默认外观):\n${fixedTags || '(无)'}\n\n输出要求:\n- 只输出一个 JSON 对象,不要 Markdown、不要解释、不要其他任何文字:\n{\n "stage_tags": "一行英文 Danbooru-style tags,用于本阶段画面,英文逗号分隔;不含 masterpiece/best quality 等质量前缀,不含画师 tags",\n "drop_fixed": ["要从固定 tags 中移除的标签列表;仅当本阶段描述明确改变了该外观时才列出"]\n}\n- 身份一致性:若用户描述的就是固定角色,stage_tags 必须包含该角色的角色名标签(如 kokkoro_(princess_connect!)),并保留种族、体型、标志特征等「不变的底层身份」。\n- 覆盖规则:本阶段描述明确提到的变化(发色、瞳色、表情、眼神、气质、种族变化等)必须体现在 stage_tags 中,并在 drop_fixed 中列出被替换掉的固定标签(措辞与固定 tags 一致或接近)。\n- 本阶段描述与固定 tags 无冲突时,drop_fixed 为 []。\n\n本阶段描述:\n{theme}`
1715
+ const rendered = template.replace(/\{theme\}/g, userPrompt)
1716
+ try {
1717
+ const text = await llmChat({ system: rendered, user: userPrompt, maxTokens: Math.min(parseInt(cfg.llmMaxTokens) || 700, 900) })
1718
+ const data = extractSeriesOptimizeJson(text)
1719
+ if (data && String(data.stage_tags || '').trim()) {
1720
+ const drops = Array.isArray(data.drop_fixed) ? data.drop_fixed.map(String).filter(Boolean) : []
1721
+ return { ok: true, prompt: String(data.stage_tags).trim(), drops, reason: '' }
1722
+ }
1723
+ // JSON 解析失败:把整段文本当作阶段 tags,不剔除固定标签
1724
+ return { ok: true, prompt: text, drops: [], reason: '' }
1725
+ } catch (e) {
1726
+ const reason = String(e && e.message || e)
1727
+ logger.warn(`连续图阶段优化失败:${reason}`)
1728
+ return { ok: false, prompt: userPrompt, drops: [], reason }
1729
+ }
1730
+ }
1731
+
1732
+ // ---------------- 提示词组装 ----------------
1733
+ // fixedOverride:undefined=按名称自动匹配固定角色;'skip'=不注入固定角色;字符串=直接使用该字符串作为固定角色 tags
1734
+ function composePrompt(userPrompt, raw, fixedOverride) {
1735
+ if (raw) return { prompt: userPrompt, degraded: false }
1736
+ const parts = []
1737
+ if (cfg.qualityPrefix) parts.push(String(cfg.qualityPrefix).trim())
1738
+ if (fixedOverride === 'skip') {
1739
+ // 不注入固定角色 tags
1740
+ } else if (typeof fixedOverride === 'string') {
1741
+ if (String(fixedOverride).trim()) parts.push(String(fixedOverride).trim())
1742
+ } else {
1743
+ for (const [name, tags] of Object.entries(parsePresetList(cfg.fixedCharacters))) {
1744
+ if (name && userPrompt.includes(name)) {
1745
+ parts.push(tags)
1746
+ break
1747
+ }
1748
+ }
1749
+ }
1750
+ const presets = parsePresetList(cfg.artistPresets)
1751
+ let artistTags = ''
1752
+ if (cfg.activeArtistPreset && presets[cfg.activeArtistPreset]) {
1753
+ artistTags = presets[cfg.activeArtistPreset]
1754
+ } else if (cfg.defaultArtistTags) {
1755
+ artistTags = String(cfg.defaultArtistTags).trim()
1756
+ }
1757
+ if (artistTags && !/(不用我的风格|不要我的风格|不使用我的风格|不要画师词|不用画师词|不加画师词|no artist)/i.test(userPrompt)) {
1758
+ parts.push(artistTags)
1759
+ }
1760
+ if (cfg.styleTags && !/(不用我的风格|不要我的风格|不使用我的风格)/i.test(userPrompt)) {
1761
+ parts.push(String(cfg.styleTags).trim())
1762
+ }
1763
+ parts.push(userPrompt)
1764
+ return { prompt: parts.filter(Boolean).join(', ') + (parts.filter(Boolean).length ? ',' : ''), degraded: false }
1765
+ }
1766
+
1767
+ // ---------------- 多人(移植自 anima /anm 多人) ----------------
1768
+
1769
+ // 通用 LLM 调用:system + user,返回文本;失败抛错由调用方捕获。
1770
+ async function llmChat({ system, user, maxTokens = 700, baseUrl, apiKey, model, timeout = 120000 }) {
1771
+ const resolvedBase = baseUrl || cfg.llmBaseUrl
1772
+ const resolvedKey = apiKey || cfg.llmApiKey
1773
+ const resolvedModel = model || cfg.llmModel
1774
+ const controller = new AbortController()
1775
+ const timer = setTimeout(() => controller.abort(), timeout)
1776
+ try {
1777
+ const headers = { 'Content-Type': 'application/json' }
1778
+ if (resolvedKey) headers.Authorization = `Bearer ${resolvedKey}`
1779
+ const rawRes = await fetch(
1780
+ String(resolvedBase).replace(/\/+$/, '') + '/chat/completions',
1781
+ {
1782
+ method: 'POST',
1783
+ headers,
1784
+ body: JSON.stringify({
1785
+ model: resolvedModel,
1786
+ messages: [
1787
+ { role: 'system', content: system },
1788
+ { role: 'user', content: user },
1789
+ ],
1790
+ max_tokens: Math.max(64, parseInt(maxTokens) || 700),
1791
+ thinking: { type: 'disabled' },
1792
+ }),
1793
+ signal: controller.signal,
1794
+ },
1795
+ )
1796
+ if (!rawRes.ok) {
1797
+ const bodyText = await rawRes.text().catch(() => '')
1798
+ throw new Error(`HTTP ${rawRes.status}${bodyText ? ' ' + bodyText.slice(0, 200) : ''}`)
1799
+ }
1800
+ const res = await rawRes.json()
1801
+ const text = extractLlmText(res)
1802
+ if (!text) throw new Error('接口未返回可用的文本内容')
1803
+ return text
1804
+ } finally {
1805
+ clearTimeout(timer)
1806
+ }
1807
+ }
1808
+
1809
+ // 多人规划:让 LLM 输出结构化场景 JSON(2-4 人)。
1810
+ async function generateMultiPersonPlan(prompt) {
1811
+ const mentioned = {}
1812
+ for (const [name, tags] of Object.entries(parsePresetList(cfg.fixedCharacters))) {
1813
+ if (name && prompt.includes(name)) mentioned[name] = tags
1814
+ }
1815
+ const planPrompt = buildMultiPersonPlanPrompt(prompt, mentioned)
1816
+ let retryPlanPrompt = planPrompt
1817
+ let planError = 'invalid_plan'
1818
+ let rawPlan = ''
1819
+ for (let attempt = 0; attempt < 2; attempt++) {
1820
+ try {
1821
+ rawPlan = await llmChat({
1822
+ system: 'You plan multi-character Anima illustrations. Return only valid JSON matching the requested schema. Keep every character\'s identity and attributes in its own block.',
1823
+ user: retryPlanPrompt,
1824
+ maxTokens: Math.min(parseInt(cfg.llmMaxTokens) || 700, 900),
1825
+ })
1826
+ } catch (e) {
1827
+ planError = String(e && e.message || e).slice(0, 300)
1828
+ logger.warn(`多人规划尝试 ${attempt + 1} 失败:${planError}`)
1829
+ }
1830
+ if (rawPlan) {
1831
+ const candidate = parseMultiPersonPlan(rawPlan)
1832
+ if (candidate) {
1833
+ const aliases = ['CHARACTER A', 'CHARACTER B', 'CHARACTER C', 'CHARACTER D'].slice(0, candidate.characters.length)
1834
+ const allowedAliases = new Set(aliases)
1835
+ const interactionAliases = new Set()
1836
+ for (const interaction of candidate.interactions) {
1837
+ const found = String(interaction).match(/\bCharacter\s+[A-D]\b/gi) || []
1838
+ found.forEach(m => interactionAliases.add(m.toUpperCase()))
1839
+ }
1840
+ if (candidate.interactions.length && interactionAliases.size && ![...interactionAliases].every(a => allowedAliases.has(a))) {
1841
+ planError = 'invalid_interaction_aliases'
1842
+ } else {
1843
+ return { ok: true, plan: candidate, rawPlan }
1844
+ }
1845
+ } else {
1846
+ planError = 'invalid_plan'
1847
+ }
1848
+ }
1849
+ if (attempt === 0) {
1850
+ planError = 'invalid_plan'
1851
+ retryPlanPrompt += '\nThe previous response was invalid. Return corrected JSON only. Keep 2 to 4 characters, reference only defined Character aliases inside interactions, and preserve one coherent shared scene.'
1852
+ }
1853
+ }
1854
+ return { ok: false, plan: null, error: planError, rawPlan }
1855
+ }
1856
+
1857
+ // 多人最终提示词组装:count/common tags + 角色块 + 互动 + 构图。
1858
+ function buildMultiPersonFinalPrompt(plan, prompt) {
1859
+ const aliases = ['Character A', 'Character B', 'Character C', 'Character D']
1860
+ const characterCount = plan.characters.length
1861
+ const characterRoles = []
1862
+ const characterBlocks = []
1863
+ const characterEntityNames = []
1864
+ let groupedContact = plan.spatial_mode === 'shared_contact'
1865
+ const explicitPositionRequested = /左边|右边|左侧|右侧|前景|后方|前后站位|\bon\s+the\s+(?:left|right)\b|\bforeground\b|\bbackground\b/i.test(prompt)
1866
+ let spatialMode = plan.spatial_mode
1867
+ if (explicitPositionRequested) spatialMode = 'explicit_positions'
1868
+ else if (plan.interactions.length) spatialMode = 'shared_contact'
1869
+ else if (spatialMode === 'explicit_positions') spatialMode = 'shared_scene'
1870
+ groupedContact = spatialMode === 'shared_contact'
1871
+
1872
+ const usedFixedNames = new Set()
1873
+ const fixedGenders = []
1874
+ const configuredChars = parsePresetList(cfg.fixedCharacters)
1875
+ for (let index = 0; index < plan.characters.length; index++) {
1876
+ const character = plan.characters[index]
1877
+ let fixedName = ''
1878
+ for (const name of Object.keys(configuredChars)) {
1879
+ if (!usedFixedNames.has(name) && (name === character.name || name.includes(character.name) || character.name.includes(name))) {
1880
+ fixedName = name
1881
+ break
1882
+ }
1883
+ }
1884
+ let fixedTags = ''
1885
+ let resolvedIdentity = ''
1886
+ if (fixedName) {
1887
+ usedFixedNames.add(fixedName)
1888
+ const configuredTags = splitTags(configuredChars[fixedName])
1889
+ const normalizedSet = new Set(configuredTags.map(t => t.toLowerCase().replace(/\s+/g, '')))
1890
+ if (normalizedSet.has('1girl')) fixedGenders.push('girl')
1891
+ else if (normalizedSet.has('1boy')) fixedGenders.push('boy')
1892
+ fixedTags = configuredTags
1893
+ .filter(tag => !['1girl', '1 girl', '1boy', '1 boy', 'solo'].includes(tag.toLowerCase()))
1894
+ .join(', ')
1895
+ } else if (character.danbooru_candidate) {
1896
+ resolvedIdentity = character.danbooru_candidate
1897
+ }
1898
+
1899
+ const availableIdentityTags = splitTags(fixedTags || character.appearance)
1900
+ .map(t => t.trim().replace(/^\(|\)$/g, '').trim())
1901
+ .filter(t => t && !['1girl', '1 girl', '1boy', '1 boy', 'solo'].includes(t.toLowerCase()))
1902
+ const proposedIdentityTags = (character.identity_anchors || [])
1903
+ .map(t => String(t).trim().replace(/^\(|\)$/g, '').trim())
1904
+ .filter(Boolean)
1905
+ let identityTags = fixedName || resolvedIdentity
1906
+ ? proposedIdentityTags.filter(tag => fixedTags.includes(tag.toLowerCase().replace(/\s+/g, ' '))).slice(0, 6)
1907
+ : proposedIdentityTags.slice(0, 6)
1908
+ if (!identityTags.length) identityTags = availableIdentityTags.slice(0, 6)
1909
+
1910
+ let visualLabel = String(character.visual_label || '').trim().toLowerCase()
1911
+ if (!visualLabel || /\b(?:character\s+[a-d]|first|second|third|fourth|rider|support(?:ing|er)?|left|right|top|bottom)\b/i.test(visualLabel)) {
1912
+ const descriptors = identityTags.slice(0, 2).map(tag => {
1913
+ let d = tag.toLowerCase().replace(/[()_:]+/g, ' ').replace(/\s+/g, ' ').trim()
1914
+ d = d.replace(/\s+hair$/, '-haired').replace(/\s+eyes$/, '-eyed').replace(/\s+ears$/, '-eared')
1915
+ return d
1916
+ }).filter(Boolean).map(d => d.replace(/\s+/g, '-'))
1917
+ const genderLabel = plan.count_tags.some(t => t.includes('girl')) ? 'girl' : 'person'
1918
+ visualLabel = [...descriptors, genderLabel].join(' ')
1919
+ }
1920
+ if (!visualLabel) visualLabel = String(character.role || aliases[index]).trim().toLowerCase()
1921
+ if (characterRoles.includes(visualLabel)) visualLabel = `${visualLabel} ${index + 1}`
1922
+ characterRoles.push(visualLabel)
1923
+
1924
+ const emphasized = new Set((character.emphasized_anchors || []).map(t => String(t).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()).filter(Boolean))
1925
+ const renderedIdentityTags = identityTags.map(tag =>
1926
+ emphasized.has(String(tag).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()) ? `(${tag}:1.3)` : tag,
1927
+ )
1928
+
1929
+ characterEntityNames.push(new Set([character.name, character.danbooru_candidate, fixedName, resolvedIdentity].filter(Boolean)))
1930
+ characterBlocks.push(renderMultiPersonCharacter(character, {
1931
+ alias: visualLabel,
1932
+ resolvedIdentity,
1933
+ fixedTags: fixedName ? fixedTags : '',
1934
+ groupedContact,
1935
+ explicitPositions: spatialMode === 'explicit_positions',
1936
+ identityAnchors: renderedIdentityTags,
1937
+ includePose: !groupedContact,
1938
+ }))
1939
+ }
1940
+
1941
+ const blockedMarkers = ['split screen', 'panel', 'multiple view', 'alternate view', 'character sheet', 'duplicate character', 'cloned character']
1942
+ let deterministicCountTags = []
1943
+ if (fixedGenders.length === characterCount) {
1944
+ const girlCount = fixedGenders.filter(g => g === 'girl').length
1945
+ const boyCount = fixedGenders.filter(g => g === 'boy').length
1946
+ deterministicCountTags = [
1947
+ girlCount ? `${girlCount}girls` : '',
1948
+ boyCount ? `${boyCount}boys` : '',
1949
+ ].filter(Boolean)
1950
+ }
1951
+ if (!deterministicCountTags.length) {
1952
+ const matched = (plan.count_tags || []).filter(tag => {
1953
+ const m = String(tag).match(/^\s*(\d+)\s*(girls?|boys?|people|persons?)\s*$/i)
1954
+ return m && parseInt(m[1]) === characterCount
1955
+ })
1956
+ deterministicCountTags = matched.slice(0, 1)
1957
+ if (!deterministicCountTags.length) deterministicCountTags = [`${characterCount}people`]
1958
+ }
1959
+ const filteredCommonTags = (plan.common_tags || []).filter(tag =>
1960
+ !blockedMarkers.some(marker => tag.toLowerCase().includes(marker)) &&
1961
+ tag.trim().toLowerCase() !== String(plan.relationship_tag || '').trim().toLowerCase() &&
1962
+ !/^\s*\d+\s*(girls?|boys?|people|persons?)\s*$/i.test(tag),
1963
+ )
1964
+ const relationshipTag = String(plan.relationship_tag || '').trim()
1965
+ const commonContent = [
1966
+ ...deterministicCountTags,
1967
+ ...(characterCount === 2 ? ['duo'] : []),
1968
+ ...(relationshipTag ? [relationshipTag] : []),
1969
+ ...filteredCommonTags,
1970
+ ].join(', ')
1971
+
1972
+ const normalizedInteractions = []
1973
+ for (const interaction of plan.interactions) {
1974
+ let normalized = interaction
1975
+ const replacements = []
1976
+ characterEntityNames.forEach((names, index) => {
1977
+ for (const name of names) {
1978
+ if (name.toLowerCase() !== aliases[index].toLowerCase()) replacements.push([name, aliases[index]])
1979
+ }
1980
+ })
1981
+ replacements.sort((a, b) => b[0].length - a[0].length)
1982
+ for (const [name, alias] of replacements) {
1983
+ if (/[\u0080-\uffff]/.test(name)) {
1984
+ normalized = normalized.split(name).join(` ${alias} `)
1985
+ } else {
1986
+ normalized = normalized.replace(new RegExp(`(?<![\\w])${escapeRe(name)}(?![\\w])`, 'gi'), alias)
1987
+ }
1988
+ }
1989
+ normalized = normalized.replace(/\s+/g, ' ').trim().replace(/\s+([,.;:!?])/g, '$1')
1990
+ normalizedInteractions.push(normalized)
1991
+ }
1992
+
1993
+ const normalizedAliases = new Set()
1994
+ for (const interaction of normalizedInteractions) {
1995
+ const found = interaction.match(/\bCharacter\s+[A-D]\b/gi) || []
1996
+ found.forEach(m => normalizedAliases.add(m.toUpperCase()))
1997
+ }
1998
+ const allowedAliasSet = new Set(aliases.slice(0, characterCount).map(a => a.toUpperCase()))
1999
+ if (normalizedInteractions.length && (![...normalizedAliases].every(a => allowedAliasSet.has(a)) || normalizedAliases.size < 2)) {
2000
+ return { ok: false, error: 'invalid_interaction_aliases' }
2001
+ }
2002
+
2003
+ const displayInteractions = normalizedInteractions.map(interaction => {
2004
+ let displayed = interaction
2005
+ aliases.slice(0, characterCount).forEach((alias, index) => {
2006
+ displayed = displayed.replace(new RegExp(`\\b${escapeRe(alias)}\\b`, 'gi'), `the ${characterRoles[index]}`)
2007
+ })
2008
+ return displayed
2009
+ })
2010
+
2011
+ const sceneGuard = 'The composition shows one shared continuous moment.'
2012
+ let relativePosition = ''
2013
+ if (spatialMode === 'explicit_positions' && characterCount === 2) {
2014
+ const slotAliases = {}
2015
+ plan.characters.forEach((character, index) => { slotAliases[character.slot] = `the ${characterRoles[index]}` })
2016
+ if (slotAliases.left && slotAliases.right) {
2017
+ relativePosition = `${slotAliases.left} stands immediately beside ${slotAliases.right}, to ${slotAliases.right}'s left, while both remain in the same central group.`
2018
+ } else if (slotAliases.foreground && slotAliases.background) {
2019
+ relativePosition = `${slotAliases.foreground} stands slightly in front of ${slotAliases.background} while both remain together in the same continuous scene.`
2020
+ }
2021
+ }
2022
+
2023
+ const narrativeBlocks = [...characterBlocks, ...displayInteractions, relativePosition, sceneGuard].filter(Boolean)
2024
+
2025
+ // 组装最终提示词:质量词 + 画师组 + content + narrative 块
2026
+ const contentClean = cleanContentTags(commonContent, 65, false, [], true)
2027
+ const parts = []
2028
+ if (cfg.qualityPrefix) parts.push(String(cfg.qualityPrefix).trim())
2029
+ const presets = parsePresetList(cfg.artistPresets)
2030
+ let artistTags = ''
2031
+ if (cfg.activeArtistPreset && presets[cfg.activeArtistPreset]) {
2032
+ artistTags = presets[cfg.activeArtistPreset]
2033
+ } else if (cfg.defaultArtistTags) {
2034
+ artistTags = String(cfg.defaultArtistTags).trim()
2035
+ }
2036
+ if (artistTags) parts.push(artistTags)
2037
+ if (cfg.styleTags) parts.push(String(cfg.styleTags).trim())
2038
+ parts.push(contentClean || commonContent)
2039
+ let finalPrompt = joinPromptParts(parts)
2040
+ if (narrativeBlocks.length) finalPrompt += '\n\n' + narrativeBlocks.join('\n\n')
2041
+ return { ok: true, prompt: finalPrompt }
2042
+ }
2043
+
2044
+ // 视觉校验(anima_verify + generation_verifier 移植):对生成的图片跑视觉 LLM,
2045
+ // 不合格则用相同提示词重试(最多 multiCandidateCount 张),按多候选规则挑选并返回结果。
2046
+ async function verifyGeneratedImages(session, images, userRequest, prompt, size, planCount, unet) {
2047
+ const verifyBaseUrl = String(cfg.verifyLlmBaseUrl || '').trim()
2048
+ const verifyModel = String(cfg.verifyLlmModel || '').trim()
2049
+ if (!verifyBaseUrl || !verifyModel) {
2050
+ return { ok: true, degraded: true, message: '', verdict: null, outputs: images }
2051
+ }
2052
+ const passScore = Math.max(0, Math.min(10, parseInt(cfg.multiVerifyPassScore) || 6))
2053
+ const candidateCount = Math.max(1, Math.min(3, parseInt(cfg.multiCandidateCount) || 2))
2054
+ const maxRetry = candidateCount - 1
2055
+ const systemPrompt = buildVerifySystemPrompt(true, planCount)
2056
+ const candidates = []
2057
+ let lastVerdict = null
2058
+ let retries = 0
2059
+ let currentImages = images
2060
+ let currentPrompt = prompt
2061
+
2062
+ async function verifyOnce(imgs, userReq) {
2063
+ const controller = new AbortController()
2064
+ const timer = setTimeout(() => controller.abort(), 120000)
2065
+ try {
2066
+ const headers = { 'Content-Type': 'application/json' }
2067
+ if (cfg.verifyLlmApiKey) headers.Authorization = `Bearer ${cfg.verifyLlmApiKey}`
2068
+ const content = imgs.map(src => ({ type: 'image_url', image_url: { url: src } }))
2069
+ const rawRes = await fetch(
2070
+ verifyBaseUrl.replace(/\/+$/, '') + '/chat/completions',
2071
+ {
2072
+ method: 'POST',
2073
+ headers,
2074
+ body: JSON.stringify({
2075
+ model: verifyModel,
2076
+ messages: [
2077
+ { role: 'system', content: systemPrompt },
2078
+ { role: 'user', content: [{ type: 'text', text: `用户的原始画图请求(中文):\n${userReq}\n\n请审查这张图片。` }, ...content] },
2079
+ ],
2080
+ max_tokens: Math.max(256, parseInt(cfg.llmMaxTokens) || 700),
2081
+ }),
2082
+ signal: controller.signal,
2083
+ },
2084
+ )
2085
+ if (!rawRes.ok) {
2086
+ const bodyText = await rawRes.text().catch(() => '')
2087
+ throw new Error(`HTTP ${rawRes.status}${bodyText ? ' ' + bodyText.slice(0, 200) : ''}`)
2088
+ }
2089
+ const res = await rawRes.json()
2090
+ return extractLlmText(res)
2091
+ } finally {
2092
+ clearTimeout(timer)
2093
+ }
2094
+ }
2095
+
2096
+ function extractVerifyJson(text) {
2097
+ let raw = String(text || '').trim()
2098
+ if (raw.startsWith('```')) {
2099
+ raw = (raw.match(/```(?:json)?([\s\S]*?)```/) || [null, raw])[1].trim()
2100
+ }
2101
+ const start = raw.indexOf('{')
2102
+ const end = raw.lastIndexOf('}')
2103
+ if (start === -1 || end === -1 || end <= start) return null
2104
+ try { return JSON.parse(raw.slice(start, end + 1)) } catch (e) { return null }
2105
+ }
2106
+
2107
+ function verdictFromData(data) {
2108
+ let score = 10
2109
+ try { score = parseInt(data.score) } catch (e) { score = 10 }
2110
+ const issues = Array.isArray(data.issues) ? data.issues.map(String).filter(Boolean).slice(0, 5) : []
2111
+ const fixHint = String(data.fix_hint || '').trim()
2112
+ const facts = data.multi_facts && typeof data.multi_facts === 'object' ? data.multi_facts : {}
2113
+ let visibleCount = null
2114
+ try { visibleCount = parseInt(facts.visible_person_count) } catch (e) { visibleCount = null }
2115
+ const layout = String(facts.layout || '').trim().toLowerCase()
2116
+ const identityMatch = String(facts.identity_match || '').trim().toLowerCase()
2117
+ const interactionDirection = String(facts.interaction_direction || '').trim().toLowerCase()
2118
+ let identityConfidence = 0
2119
+ try { identityConfidence = Math.min(1, Math.max(0, parseFloat(facts.identity_confidence) || 0)) } catch (e) { identityConfidence = 0 }
2120
+ let directionConfidence = 0
2121
+ try { directionConfidence = Math.min(1, Math.max(0, parseFloat(facts.direction_confidence) || 0)) } catch (e) { directionConfidence = 0 }
2122
+ const majorAnatomy = facts.major_anatomy_issue === true
2123
+ const explicitPass = typeof data.pass === 'boolean' ? data.pass : null
2124
+ const passed = explicitPass !== null ? (explicitPass && score >= passScore) : score >= passScore
2125
+ return { passed, score, issues, fixHint, visibleCount, layout, identityMatch, interactionDirection, identityConfidence, directionConfidence, majorAnatomy }
2126
+ }
2127
+
2128
+ function rankCandidate(verdict) {
2129
+ const hardFailure = (
2130
+ (verdict.visibleCount !== null && verdict.visibleCount !== planCount) ||
2131
+ ['split_screen', 'collage', 'multiple_views'].includes(verdict.layout) ||
2132
+ verdict.majorAnatomy ||
2133
+ (['swapped', 'wrong'].includes(verdict.identityMatch) && verdict.identityConfidence >= 0.7) ||
2134
+ (['reversed', 'wrong'].includes(verdict.interactionDirection) && verdict.directionConfidence >= 0.7)
2135
+ )
2136
+ const eligible = verdict.skipped || (!hardFailure && verdict.score >= 5)
2137
+ let rank = verdict.score
2138
+ if (verdict.visibleCount === planCount) rank += 100
2139
+ if (verdict.layout === 'single_scene') rank += 50
2140
+ if (verdict.majorAnatomy) rank -= 40
2141
+ if (verdict.interactionDirection === 'correct') rank += 8
2142
+ else if (verdict.interactionDirection === 'unclear') rank += 2
2143
+ if (verdict.identityMatch === 'correct') rank += 5
2144
+ else if (verdict.identityMatch === 'partial') rank += 1
2145
+ return { eligible, rank }
2146
+ }
2147
+
2148
+ let selectedOutputs = images
2149
+ let selectedVerdict = null
2150
+ while (true) {
2151
+ let reply = ''
2152
+ let data = null
2153
+ try {
2154
+ reply = await verifyOnce(currentImages, userRequest)
2155
+ data = extractVerifyJson(reply)
2156
+ } catch (e) {
2157
+ logger.warn(`多人视觉校验失败:${e.message}`)
2158
+ return { ok: true, degraded: true, message: session.text('.multi-verify-error', [String(e && e.message || e)]), verdict: null, outputs: images }
2159
+ }
2160
+ if (!data) {
2161
+ logger.warn(`多人视觉校验返回无法解析:${reply.slice(0, 200)}`)
2162
+ return { ok: true, degraded: true, message: '', verdict: null, outputs: images }
2163
+ }
2164
+ const verdict = verdictFromData(data)
2165
+ verdict.skipped = false
2166
+ candidates.push({ outputs: currentImages, verdict, prompt: currentPrompt })
2167
+ selectedOutputs = currentImages
2168
+ selectedVerdict = verdict
2169
+
2170
+ const rank = rankCandidate(verdict)
2171
+ if (verdict.skipped || verdict.passed || (rank.eligible && rank.rank >= 5)) {
2172
+ break
2173
+ }
2174
+ if (retries >= maxRetry) break
2175
+ retries += 1
2176
+ const hint = verdict.fixHint || verdict.issues.join(';')
2177
+ if (hint) {
2178
+ currentPrompt = `${userRequest}\n【上次问题,请修正】${hint}`
2179
+ }
2180
+ const regen = await runComfyGenerate(currentPrompt, size, { unet })
2181
+ if (!regen.ok) {
2182
+ logger.warn(`多人校验重试生成失败:${regen.message}`)
2183
+ break
2184
+ }
2185
+ currentImages = regen.outputs
2186
+ currentPrompt = regen.finalPrompt || currentPrompt
2187
+ }
2188
+
2189
+ // 多候选挑选
2190
+ const ranked = candidates.map((c, index) => ({ rank: rankCandidate(c.verdict), index, ...c }))
2191
+ const eligible = ranked.filter(c => c.rank.eligible)
2192
+ const best = (eligible.length ? eligible : ranked).sort((a, b) => b.rank.rank - a.rank.rank)[0]
2193
+ const multiAccepted = Boolean(eligible.length)
2194
+ selectedOutputs = best.outputs
2195
+ selectedVerdict = best.verdict
2196
+ if (!multiAccepted && !cfg.multiSendDegradedCandidate) {
2197
+ return { ok: false, discarded: true, message: session.text('.multi-verify-discarded'), verdict: selectedVerdict, outputs: [] }
2198
+ }
2199
+ const noteParts = []
2200
+ if (multiAccepted) {
2201
+ noteParts.push(session.text('.multi-verify-passed', [selectedVerdict.score]))
2202
+ } else {
2203
+ noteParts.push(session.text('.multi-verify-degraded', selectedVerdict.issues.length ? '(' + selectedVerdict.issues.join(';').slice(0, 80) + ')' : ''))
2204
+ }
2205
+ if (retries) noteParts.push(session.text('.multi-verify-failed', [selectedVerdict.issues.length ? ':' + selectedVerdict.issues.join(';').slice(0, 80) : '', retries]))
2206
+ return { ok: true, degraded: false, message: noteParts.join('\n'), verdict: selectedVerdict, outputs: selectedOutputs }
2207
+ }
2208
+
2209
+ function buildVerifySystemPrompt(multiPerson, planCount) {
2210
+ let system = '你是一个严格但公正的二次元插画审查助手。你会看到用户的原始画图请求(中文)和一张已生成的图片。判断图片是否满足请求:主体是否正确、动作/姿态、服饰、场景、整体画风,以及基本质量(无明显肢体畸形、无脸崩、无乱码文字、构图协调)。\n\n只输出一个 JSON 对象,不要 Markdown、不要解释:\n{\n "score": 0-10 的整数(10=完全符合),\n "pass": true/false,\n "issues": ["用简短中文短语列出具体问题,没问题则空数组"],\n "fix_hint": "一句中文,告诉提示词作者下次该怎么改;通过则留空"\n}\n\nissues 要具体,例如「少了草帽」「背景是教室不是废土」「多出第三只手」。图片明显没问题时,pass=true、给高分、issues 和 fix_hint 都留空。'
2211
+ if (multiPerson) {
2212
+ system += `\n这是 /anm 多人任务。还必须严格检查:实际人物数量是否符合请求;每个角色是否只出现一次;是否出现分屏、漫画格、多视图、克隆或额外人物;固定角色的发色、瞳色、种族和标志性配饰是否串到其他角色;互动的主动方、承受方和空间位置是否正确。`
2213
+ system += `\nJSON 中还必须增加 multi_facts 对象,只报告直接观察到的事实:"visible_person_count" 为可见人物整数;"layout" 只能是 single_scene、split_screen、collage、multiple_views 或 unknown;"identity_match" 只能是 correct、partial、swapped、wrong 或 unknown;"interaction_direction" 只能是 correct、reversed、unclear、wrong 或 unknown。另给出 0.0 到 1.0 的 "identity_confidence" 和 "direction_confidence",以及布尔值 "major_anatomy_issue";只有能清楚看见证据时才给高置信度。不要让总分替代这些客观字段。`
2214
+ }
2215
+ return system
2216
+ }
2217
+
2218
+ // 共享批量执行器:一次性扣除总价,逐张生成,单张失败只退该张单价。
2219
+ // runOne(i) 需返回 { ok, outputs, seed, message? };返回数组为多张输出(如视觉校验候选)。
2220
+ async function executeBatch(USERID, isAdmin, count, unitPrice, runOne) {
2221
+ const results = []
2222
+ let successCount = 0
2223
+ for (let i = 0; i < count; i++) {
2224
+ let item
2225
+ try {
2226
+ item = await runOne(i)
2227
+ } catch (e) {
2228
+ item = { ok: false, message: String(e && e.message || e) }
2229
+ }
2230
+ const outputs = Array.isArray(item.outputs) ? item.outputs : (item.outputs ? [item.outputs] : [])
2231
+ if (item.ok && outputs.length) {
2232
+ successCount += 1
2233
+ results.push({ i, ok: true, outputs, seed: item.seed, note: item.note || '' })
2234
+ } else {
2235
+ if (!isAdmin) await refundP(USERID, unitPrice)
2236
+ if (cfg.outputLogs) logger.warn(`批量第 ${i + 1} 张生成失败(${USERID}):${item.message || '无输出'}`)
2237
+ results.push({ i, ok: false, message: item.message || '无输出' })
2238
+ }
2239
+ }
2240
+ return { results, successCount }
2241
+ }
2242
+
2243
+ // 多人主流程:尺寸自动选择 + 规划 + 组装 + 生成 + 校验 + 发图(引用原消息)
2244
+ async function handleGenerateMulti(session, rawText) {
2245
+ const USERID = session.userId
2246
+ const isAdmin = isAdminUser(session)
2247
+ const unet = await resolveUnet(USERID)
2248
+ const price = Math.max(0, parseInt(cfg.multiPrice) || cfg.price)
2249
+ if (cfg.outputLogs) {
2250
+ logger.info(`[p-draw] 多人请求 userId=${USERID} isAdmin=${isAdmin}`)
2251
+ }
2252
+
2253
+ const allowed = parseAllowedSizes()
2254
+ const parsedSize = parseGenerationSize(rawText, allowed)
2255
+ if (parsedSize.error) return parsedSize.error
2256
+
2257
+ // 批量张数解析(x3 / 3张 / --数量 3 等)
2258
+ const parsedBatch = parseBatchCount(parsedSize.prompt, cfg.batchMax)
2259
+ const text = parsedBatch.prompt
2260
+ if (!text) return session.text('.multi-usage')
2261
+ const count = parsedBatch.count
2262
+
2263
+ // P 点校验(按总价 = 张数 × 单价)
2264
+ if (!isAdmin) {
2265
+ const notExists = await isAccountExists(USERID)
2266
+ if (!notExists) return session.text('.account-notExists')
2267
+ const usersdata = await getPUser(USERID)
2268
+ const saving = usersdata?.p || 0
2269
+ if (saving < count * price) return session.text('.no-enough-p', [count * price])
2270
+ }
2271
+
2272
+ // 未指定尺寸时按人数/接触关系自动选横图
2273
+ let size = parsedSize.size
2274
+ if (!size && allowed.length) {
2275
+ size = multiPersonAutoSize(text, allowed)
2276
+ }
2277
+
2278
+ // ComfyUI 就绪
2279
+ const ready = await ensureComfyuiReady()
2280
+ if (!ready.ok) return ready.message
2281
+
2282
+ // 多人规划(强制依赖 LLM)
2283
+ if (!cfg.llmModel || !cfg.llmBaseUrl) {
2284
+ return session.text('.multi-no-llm')
2285
+ }
2286
+
2287
+ // 多人规划
2288
+ const planResult = await generateMultiPersonPlan(text)
2289
+ if (!planResult.ok) {
2290
+ return session.text('.multi-usage') + '\n(多人规划失败:' + planResult.error + ')'
2291
+ }
2292
+ const plan = planResult.plan
2293
+
2294
+ // 组装最终提示词
2295
+ const built = buildMultiPersonFinalPrompt(plan, text)
2296
+ if (!built.ok) {
2297
+ return session.text('.multi-usage') + '\n(多人规划失败:' + built.error + ')'
2298
+ }
2299
+ const finalPrompt = built.prompt
2300
+
2301
+ // 多人负面词:在配置负面词后追加多人专属禁止词
2302
+ const multiNegative = [...(cfg.negativePrompt ? splitTags(cfg.negativePrompt) : []), ...MULTI_PERSON_NEGATIVE_TAGS].join(', ')
2303
+
2304
+ // 扣 P 点(一次性扣除总价)
2305
+ if (!isAdmin) {
2306
+ const saving = await deductP(USERID, count * price)
2307
+ if (cfg.outputLogs) logger.info(`[p-draw] ${USERID} 多人已扣除 ${count * price} P 点(${count} 张 × ${price}),余额 ${saving - count * price}`)
2308
+ }
2309
+
2310
+ // 队列:预排队全部任务,占满即退回总价
2311
+ const queuedTasks = []
2312
+ let firstPosition = null
2313
+ if (cfg.queueEnabled) {
2314
+ for (let i = 0; i < count; i++) {
2315
+ const q = enqueue(() => runComfyGenerate(finalPrompt, size, { negativePrompt: multiNegative, unet }))
2316
+ if (!q.ok) {
2317
+ if (!isAdmin) await refundP(USERID, count * price)
2318
+ return q.message
2319
+ }
2320
+ queuedTasks.push(q.task)
2321
+ if (firstPosition == null) firstPosition = q.position
2322
+ }
2323
+ }
2324
+
2325
+ // 即时反馈
2326
+ const notice = []
2327
+ if (parsedBatch.clamped) notice.push(session.text('.batch-limit', [count]))
2328
+ if (cfg.queueEnabled) {
2329
+ notice.push(session.text('.queued', [firstPosition, cfg.queueMaxRequests || '∞']))
2330
+ if (count > 1) notice.push(session.text('.batch-count', [count]))
2331
+ if (!isAdmin) notice.push(session.text('.charged', [count * price]))
2332
+ } else {
2333
+ notice.push(session.text('.generating'))
2334
+ if (count > 1) notice.push(session.text('.batch-count', [count]))
2335
+ if (!isAdmin) notice.push(session.text('.charged', [count * price]))
2336
+ }
2337
+ if (notice.length) {
2338
+ try {
2339
+ await session.send(notice.filter(Boolean).join('\n'))
2340
+ } catch (e) {
2341
+ logger.warn(`发送反馈消息失败:${e.message}`)
2342
+ }
2343
+ }
2344
+
2345
+ // 单张生成 +(可选)视觉校验
2346
+ const runOne = async (i) => {
2347
+ let result
2348
+ if (cfg.queueEnabled) {
2349
+ try { result = await queuedTasks[i] } catch (e) { result = { ok: false, message: `生成失败:${e.message}` } }
2350
+ } else {
2351
+ try { result = await runComfyGenerate(finalPrompt, size, { negativePrompt: multiNegative, unet }) } catch (e) { result = { ok: false, message: `生成失败:${e.message}` } }
2352
+ }
2353
+ if (!result.ok || !result.outputs || !result.outputs.length) return result
2354
+ if (!cfg.multiVerifyEnabled) {
2355
+ return { ok: true, outputs: result.outputs, seed: result.seed, note: session.text('.multi-degraded', ['(未启用校验或未配置视觉模型)']) }
2356
+ }
2357
+ const verified = await verifyGeneratedImages(session, result.outputs, text, finalPrompt, size, plan.characters.length, unet)
2358
+ if (!verified.ok) return { ok: false, message: verified.message }
2359
+ return { ok: true, outputs: verified.outputs, seed: result.seed, note: verified.message || '' }
2360
+ }
2361
+
2362
+ const { results, successCount } = await executeBatch(USERID, isAdmin, count, price, runOne)
2363
+
2364
+ // 汇总
2365
+ const allOutputs = []
2366
+ const notes = []
2367
+ const seeds = []
2368
+ const failures = []
2369
+ for (const item of results) {
2370
+ if (item.ok) {
2371
+ allOutputs.push(...item.outputs)
2372
+ if (item.seed != null) seeds.push(item.seed)
2373
+ if (item.note) notes.push(item.note)
2374
+ } else {
2375
+ failures.push(`第 ${item.i + 1} 张:${item.message}`)
2376
+ }
2377
+ }
2378
+
2379
+ if (!allOutputs.length) {
2380
+ if (cfg.outputLogs) logger.warn(`多人生成全部失败(${USERID}),已按张退款`)
2381
+ return session.text('.generate-failed', ['全部失败(已按张退款)'])
2382
+ }
2383
+
2384
+ if (cfg.outputLogs) logger.success(`${USERID} 多人生成成功 ${successCount}/${count} 张`)
2385
+
2386
+ // 发图:引用用户触发指令的原消息
2387
+ const imageElements = allOutputs.map(src => h.image(src))
2388
+ try {
2389
+ await session.send(h.quote(session.messageId) + imageElements.join(''))
2390
+ } catch (e) {
2391
+ logger.warn(`发送多人图片失败:${e.message}`)
2392
+ await session.send(imageElements)
2393
+ }
2394
+
2395
+ const reply = []
2396
+ if (count > 1) {
2397
+ reply.push(session.text('.generate-ok-batch', [count * price, successCount, seeds.join(', ') || '-']))
2398
+ } else {
2399
+ reply.push(session.text('.generate-ok', [price, seeds[0] || '-']))
2400
+ }
2401
+ if (notes.length) reply.push(notes.join('\n'))
2402
+ if (failures.length) reply.push(session.text('.batch-partial', [successCount, count, failures.length, failures.join(';')]))
2403
+ return reply.filter(Boolean).join('\n')
2404
+ }
2405
+
2406
+ // 连续图/过程图主流程:同一角色多阶段(固定身份 + 阶段描述 + 全阶段共用同一 seed 保证一致性)
2407
+ // 语法:连续 <角色>:<阶段1> → <阶段2> → ... 或 连续 <角色>:<阶段1>|<阶段2>|...
2408
+ async function handleGenerateSeries(session, rawText) {
2409
+ const USERID = session.userId
2410
+ const isAdmin = isAdminUser(session)
2411
+ const unet = await resolveUnet(USERID)
2412
+ const price = Math.max(0, parseInt(cfg.price) || 500)
2413
+
2414
+ // 阶段分隔符:箭头 / 管道
2415
+ const STAGE_SEP = /→|➔|➜|←|↔|=>|->|⇒|\|/
2416
+
2417
+ // 尺寸解析(连续图默认横图);固定 seed:全阶段共用,支持 --seed 覆盖(含 --seed: 冒号形式)
2418
+ const seedInfo = parseSeed(String(rawText || ''))
2419
+ const seed = seedInfo.seed != null ? seedInfo.seed : crypto.randomInt(1, 2 ** 32 - 1)
2420
+ const allowed = parseAllowedSizes()
2421
+ const parsedSize = parseGenerationSize(seedInfo.prompt, allowed)
2422
+ if (parsedSize.error) return parsedSize.error
2423
+ let size = parsedSize.size
2424
+ if (!size && allowed.length) {
2425
+ size = allowed.reduce((best, s) => {
2426
+ const a = Math.abs(s[0] / s[1] - 16 / 9)
2427
+ const b = Math.abs(best[0] / best[1] - 16 / 9)
2428
+ return a < b ? s : best
2429
+ })
2430
+ }
2431
+ const sizeCleanedPrompt = parsedSize.prompt
2432
+
2433
+ // 提取身份与阶段文本(角色:阶段1 → 阶段2)
2434
+ let identity = ''
2435
+ let stageText = String(sizeCleanedPrompt || '').trim()
2436
+ const colonMatch = stageText.match(/^(.+?)[::]\s*(.+)$/)
2437
+ if (colonMatch) {
2438
+ identity = colonMatch[1].trim()
2439
+ stageText = colonMatch[2].trim()
2440
+ }
2441
+ const stages = stageText
2442
+ .split(STAGE_SEP)
2443
+ .map(s => s.trim().replace(/^[\s,,、;;::]+|[\s,,、;;::]+$/g, '').replace(/\s+/g, ' '))
2444
+ .filter(Boolean)
2445
+ if (!stages.length) return session.text('.series-usage')
2446
+
2447
+ const maxStages = Math.max(1, parseInt(cfg.batchMax) || 4)
2448
+ const count = Math.min(stages.length, maxStages)
2449
+ const clamped = stages.length > count
2450
+ const stageList = stages.slice(0, count)
2451
+
2452
+ // P 点校验(按总价)
2453
+ if (!isAdmin) {
2454
+ const notExists = await isAccountExists(USERID)
2455
+ if (!notExists) return session.text('.account-notExists')
2456
+ const usersdata = await getPUser(USERID)
2457
+ const saving = usersdata?.p || 0
2458
+ if (saving < count * price) return session.text('.no-enough-p', [count * price])
2459
+ }
2460
+
2461
+ // ComfyUI 就绪
2462
+ const ready = await ensureComfyuiReady()
2463
+ if (!ready.ok) return ready.message
2464
+
2465
+ // 组装各阶段提示词:身份(含固定角色 tags)+ 阶段描述。
2466
+ // 连续图**只要配置了 LLM 就强制逐阶段优化**(不受 promptOptimizeEnabled 限制),
2467
+ // 因为 anima 是 Danbooru-tag 模型,中文阶段描述必须转成 tags 才能体现在画面里;
2468
+ // 未配置 LLM 时退回原始中文描述。
2469
+ const fixedChars = parsePresetList(cfg.fixedCharacters)
2470
+ const stagePrompts = []
2471
+ let degradedStages = 0
2472
+ for (const st of stageList) {
2473
+ const base = identity ? `${identity},${st}` : st
2474
+ const result = await optimizeSeriesStage(base, identity)
2475
+ if (!result.ok) degradedStages += 1
2476
+ const stageTags = result.prompt || base
2477
+ const drops = result.drops || []
2478
+ // 始终注入固定角色 tags 作为身份锚点(保证角色名/种族/尖耳朵出现),
2479
+ // 阶段描述里被明确改变的外观由 drop 列表剔除,避免被固定默认值拉回。
2480
+ const identityTags = identity ? filterFixedTags(fixedChars[identity] || '', drops) : ''
2481
+ const anchor = stageTags
2482
+ const composed = composePrompt(anchor, false, identityTags)
2483
+ stagePrompts.push(composed.prompt)
2484
+ }
2485
+
2486
+ // 扣 P 点(一次性扣除总价)
2487
+ if (!isAdmin) {
2488
+ const saving = await deductP(USERID, count * price)
2489
+ if (cfg.outputLogs) logger.info(`[p-draw] ${USERID} 连续图已扣除 ${count * price} P 点(${count} 阶段 × ${price},seed=${seed}),余额 ${saving - count * price}`)
2490
+ }
2491
+
2492
+ // 队列:预排队全部阶段
2493
+ const queuedTasks = []
2494
+ let firstPosition = null
2495
+ if (cfg.queueEnabled) {
2496
+ for (let i = 0; i < count; i++) {
2497
+ const q = enqueue(() => runComfyGenerate(stagePrompts[i], size, { seed, unet }))
2498
+ if (!q.ok) {
2499
+ if (!isAdmin) await refundP(USERID, count * price)
2500
+ return q.message
2501
+ }
2502
+ queuedTasks.push(q.task)
2503
+ if (firstPosition == null) firstPosition = q.position
2504
+ }
2505
+ }
2506
+
2507
+ // 即时反馈
2508
+ const notice = []
2509
+ if (clamped) notice.push(session.text('.batch-limit', [count]))
2510
+ if (identity && fixedChars[identity]) notice.push(`已固定角色「${identity}」的身份 tags,各阶段外观将保持一致。`)
2511
+ if (degradedStages) notice.push(session.text('.prompt-degraded', ['(连续图阶段优化失败,已使用原始描述)']))
2512
+ if (cfg.queueEnabled) {
2513
+ notice.push(session.text('.queued', [firstPosition, cfg.queueMaxRequests || '∞']))
2514
+ if (count > 1) notice.push(session.text('.batch-count', [count]))
2515
+ if (!isAdmin) notice.push(session.text('.charged', [count * price]))
2516
+ } else {
2517
+ notice.push(session.text('.generating'))
2518
+ if (count > 1) notice.push(session.text('.batch-count', [count]))
2519
+ if (!isAdmin) notice.push(session.text('.charged', [count * price]))
2520
+ }
2521
+ if (notice.length) {
2522
+ try {
2523
+ await session.send(notice.filter(Boolean).join('\n'))
2524
+ } catch (e) {
2525
+ logger.warn(`发送反馈消息失败:${e.message}`)
2526
+ }
2527
+ }
2528
+
2529
+ // 单阶段生成(共用 seed)
2530
+ const runOne = async (i) => {
2531
+ let result
2532
+ if (cfg.queueEnabled) {
2533
+ try { result = await queuedTasks[i] } catch (e) { result = { ok: false, message: `生成失败:${e.message}` } }
2534
+ } else {
2535
+ try { result = await runComfyGenerate(stagePrompts[i], size, { seed, unet }) } catch (e) { result = { ok: false, message: `生成失败:${e.message}` } }
2536
+ }
2537
+ return result
2538
+ }
2539
+
2540
+ const { results, successCount } = await executeBatch(USERID, isAdmin, count, price, runOne)
2541
+
2542
+ // 汇总
2543
+ const allOutputs = []
2544
+ const seeds = []
2545
+ const failures = []
2546
+ for (const item of results) {
2547
+ if (item.ok) {
2548
+ allOutputs.push(...item.outputs)
2549
+ if (item.seed != null) seeds.push(item.seed)
2550
+ } else {
2551
+ failures.push(`第 ${item.i + 1} 阶段:${item.message}`)
2552
+ }
2553
+ }
2554
+
2555
+ if (!allOutputs.length) {
2556
+ if (cfg.outputLogs) logger.warn(`连续图全部失败(${USERID}),已按阶段退款`)
2557
+ return session.text('.generate-failed', ['全部失败(已按阶段退款)'])
2558
+ }
2559
+
2560
+ if (cfg.outputLogs) logger.success(`${USERID} 连续图生成成功 ${successCount}/${count} 阶段(seed=${seed})`)
2561
+
2562
+ // 发图:引用用户触发指令的原消息
2563
+ const imageElements = allOutputs.map(src => h.image(src))
2564
+ try {
2565
+ await session.send(h.quote(session.messageId) + imageElements.join(''))
2566
+ } catch (e) {
2567
+ logger.warn(`发送连续图失败:${e.message}`)
2568
+ await session.send(imageElements)
2569
+ }
2570
+
2571
+ const reply = []
2572
+ reply.push(session.text('.series-ok', [count * price, successCount, seed]))
2573
+ if (failures.length) reply.push(session.text('.batch-partial', [successCount, count, failures.length, failures.join(';')]))
2574
+ return reply.filter(Boolean).join('\n')
2575
+ }
2576
+
2577
+ // ---------------- 权限 ----------------
2578
+ function readableOptimizeReason(reason) {
2579
+ if (!reason) return ''
2580
+ if (reason === 'llm_not_configured') return '(未配置 LLM 模型名或接口地址)'
2581
+ if (reason === 'optimize_disabled') return ''
2582
+ return `(${reason})`
2583
+ }
2584
+ // Koishi 的 session.userId 可能带平台前缀(如 onebot:12345),
2585
+ // 这里统一归一化为纯数字串后再与配置中的 ID 比较。
2586
+ function normalizeId(value) {
2587
+ const text = String(value == null ? '' : value).trim()
2588
+ const match = text.match(/\d{5,}/)
2589
+ return match ? match[0] : text.toLowerCase()
2590
+ }
2591
+
2592
+ function idInList(value, list) {
2593
+ const target = normalizeId(value)
2594
+ return (list || []).some(item => normalizeId(item) === target)
2595
+ }
2596
+
2597
+ function isAdminUser(session) {
2598
+ return idInList(session.userId, cfg.adminUsers)
2599
+ }
2600
+
2601
+ function isAllowed(session) {
2602
+ if (cfg.adminOnly && !isAdminUser(session)) return false
2603
+
2604
+ const senderId = session.userId
2605
+ if (idInList(senderId, cfg.blockedUserIds)) return false
2606
+
2607
+ const channelId = session.channelId || ''
2608
+ if (idInList(channelId, cfg.blockedGroupIds)) return false
2609
+
2610
+ const allowedGroups = cfg.allowedGroupIds || []
2611
+ const allowedUsers = cfg.allowedUserIds || []
2612
+
2613
+ if (channelId && idInList(channelId, allowedGroups)) return true
2614
+
2615
+ if (channelId && allowedGroups.length && !allowedUsers.length) return false
2616
+
2617
+ if (allowedUsers.length && !idInList(senderId, allowedUsers)) return false
2618
+ return true
2619
+ }
2620
+
2621
+ // ---------------- P 点 ----------------
2622
+ async function getPUser(USERID) {
2623
+ try {
2624
+ const rows = await ctx.database.get('p_system', { userid: USERID })
2625
+ return rows && rows[0] ? rows[0] : null
2626
+ } catch (e) {
2627
+ logger.warn(`读取 p_system 失败(请确认已安装并启用 p-qiandao):${e.message}`)
2628
+ return null
2629
+ }
2630
+ }
2631
+
2632
+ async function isAccountExists(USERID) {
2633
+ const user = await getPUser(USERID)
2634
+ return !!user
2635
+ }
2636
+
2637
+ async function deductP(USERID, amount) {
2638
+ const user = await getPUser(USERID)
2639
+ const current = user?.p || 0
2640
+ await ctx.database.set('p_system', { userid: USERID }, { p: Math.max(0, current - amount) })
2641
+ return current
2642
+ }
2643
+
2644
+ async function refundP(USERID, amount) {
2645
+ const user = await getPUser(USERID)
2646
+ const current = user?.p || 0
2647
+ await ctx.database.set('p_system', { userid: USERID }, { p: current + amount })
2648
+ }
2649
+
2650
+ // ---------------- 画师组/角色管理(持久化到数据库,避免 scope.update 触发重载) ----------------
2651
+ // 注意:更新数据里不能带主键 id,否则数据库驱动会报 cannot modify primary key
2652
+ function runtimeState() {
2653
+ return {
2654
+ fixed_characters: cfg.fixedCharacters || [],
2655
+ artist_presets: cfg.artistPresets || [],
2656
+ active_artist_preset: cfg.activeArtistPreset || '',
2657
+ default_artist_tags: cfg.defaultArtistTags || '',
2658
+ user_models: cfg.userModels || {},
2659
+ }
2660
+ }
2661
+
2662
+ // 启动时把数据库里保存的画师组/固定角色合并进 cfg(数据库覆盖配置,保证运行时新增不被重启丢失)
2663
+ async function loadRuntimeState() {
2664
+ try {
2665
+ const rows = await ctx.database.get('p_draw_config', { id: 1 })
2666
+ const row = rows && rows[0]
2667
+ if (!row) return
2668
+ if (Array.isArray(row.fixed_characters)) cfg.fixedCharacters = row.fixed_characters
2669
+ if (Array.isArray(row.artist_presets)) cfg.artistPresets = row.artist_presets
2670
+ if (row.active_artist_preset) cfg.activeArtistPreset = row.active_artist_preset
2671
+ if (row.default_artist_tags != null) cfg.defaultArtistTags = row.default_artist_tags
2672
+ if (row.user_models && typeof row.user_models === 'object') cfg.userModels = row.user_models
2673
+ if (cfg.outputLogs) logger.info(`[p-draw] 已加载运行时配置(画师组 ${(cfg.artistPresets || []).length} 个,固定角色 ${(cfg.fixedCharacters || []).length} 个,模型偏好 ${Object.keys(cfg.userModels || {}).length} 个)`)
2674
+ } catch (e) {
2675
+ logger.warn(`读取运行时配置失败(画师组/固定角色可能未持久化):${e.message}`)
2676
+ }
2677
+ }
2678
+
2679
+ async function persistConfig(key, value) {
2680
+ cfg[key] = value
2681
+ try {
2682
+ const state = runtimeState()
2683
+ const existing = await ctx.database.get('p_draw_config', { id: 1 })
2684
+ if (existing && existing[0]) {
2685
+ await ctx.database.set('p_draw_config', { id: 1 }, state)
2686
+ } else {
2687
+ await ctx.database.create('p_draw_config', { id: 1, ...state })
2688
+ }
2689
+ } catch (e) {
2690
+ logger.warn(`运行时配置保存失败(重启后可能丢失):${e.message}`)
2691
+ }
2692
+ }
2693
+
2694
+ function normalizeTagText(text) {
2695
+ const tags = []
2696
+ for (const tag of String(text || '').split(',')) {
2697
+ const t = tag.trim()
2698
+ if (t) tags.push(t)
2699
+ }
2700
+ return tags.join(', ') + (tags.length ? ',' : '')
2701
+ }
2702
+
2703
+ // ---------------- 指令 ----------------
2704
+ ctx.command('p/p-draw [prompt:rawtext]')
2705
+ .alias('画图', '生图', '绘图', '画画')
2706
+ .action(async ({ session }, prompt) => {
2707
+ const USERID = session.userId
2708
+ const text = String(prompt || '').trim()
2709
+
2710
+ if (!isAllowed(session)) return session.text('.not-permitted')
2711
+
2712
+ if (!text) return session.text('.usage')
2713
+
2714
+ const lower = text.toLowerCase()
2715
+ if (lower === 'help' || lower === '帮助' || lower === '使用帮助' || lower === 'help 帮助') {
2716
+ return session.text('.usage')
2717
+ }
2718
+ if (lower === '状态' || lower === 'status') {
2719
+ const payload = await statusPayload()
2720
+ return statusText(payload)
2721
+ }
2722
+ if (lower === '诊断' || lower === 'diagnose' || lower === '部署诊断' || lower === 'debug' || lower === '调试' || lower === '调试状态') {
2723
+ return await diagnoseText(session)
2724
+ }
2725
+
2726
+ // 连续图指令:p-draw 连续 <角色>:<阶段1> → <阶段2>
2727
+ const seriesMatch = text.match(/^连续\s*(.*)$/)
2728
+ if (seriesMatch) {
2729
+ return await handleGenerateSeries(session, seriesMatch[1].trim())
2730
+ }
2731
+
2732
+ // 以图生图指令:p-draw i2i <描述>(需同一条消息附带原图)
2733
+ const i2iMatch = text.match(/^i2i\s*(.*)$/i)
2734
+ if (i2iMatch) {
2735
+ return await handleGenerateI2I(session, i2iMatch[1].trim())
2736
+ }
2737
+
2738
+ // 多人指令:p-draw 多人 <描述>
2739
+ const multiMatch = text.match(/^(?:多人|多人生图|双人|三人|群像)\s*(.*)$/)
2740
+ if (multiMatch) {
2741
+ return await handleGenerateMulti(session, multiMatch[1].trim())
2742
+ }
2743
+
2744
+ // 画师组管理
2745
+ const createArtist = text.match(/^(?:创建|新建|新建新的|创建新的|保存|保存新的)\s*画师组\s*(.*)$/)
2746
+ if (createArtist) {
2747
+ const parsed = parseNameTags(createArtist[1])
2748
+ if (!parsed) return session.text('.artist-format')
2749
+ const presets = parsePresetList(cfg.artistPresets)
2750
+ presets[parsed.name] = normalizeTagText(parsed.tags)
2751
+ await persistConfig('artistPresets', Object.entries(presets).map(([n, t]) => `${n}=${t}`))
2752
+ await persistConfig('activeArtistPreset', parsed.name)
2753
+ if (cfg.outputLogs) logger.success(`${USERID} 创建画师组 ${parsed.name}`)
2754
+ return session.text('.artist-created', [parsed.name, parsed.tags])
2755
+ }
2756
+ const appendArtist = text.match(/^(?:追加|添加|加入|加入新的|添加新的)\s*画师组\s*(.*)$/)
2757
+ if (appendArtist) {
2758
+ const parsed = parseNameTags(appendArtist[1])
2759
+ if (parsed) {
2760
+ const presets = parsePresetList(cfg.artistPresets)
2761
+ presets[parsed.name] = mergeTagText(presets[parsed.name], normalizeTagText(parsed.tags))
2762
+ await persistConfig('artistPresets', Object.entries(presets).map(([n, t]) => `${n}=${t}`))
2763
+ await persistConfig('activeArtistPreset', parsed.name)
2764
+ return session.text('.artist-appended', [parsed.name, presets[parsed.name]])
2765
+ }
2766
+ const presets = parsePresetList(cfg.artistPresets)
2767
+ const active = cfg.activeArtistPreset && presets[cfg.activeArtistPreset]
2768
+ ? cfg.activeArtistPreset
2769
+ : ''
2770
+ if (active) {
2771
+ presets[active] = mergeTagText(presets[active], normalizeTagText(appendArtist[1]))
2772
+ await persistConfig('artistPresets', Object.entries(presets).map(([n, t]) => `${n}=${t}`))
2773
+ return session.text('.artist-appended', [active, presets[active]])
2774
+ }
2775
+ const merged = mergeTagText(cfg.defaultArtistTags, normalizeTagText(appendArtist[1]))
2776
+ await persistConfig('defaultArtistTags', merged)
2777
+ return session.text('.artist-default-appended', [merged])
2778
+ }
2779
+ const useArtist = text.match(/^(?:切换|启用|使用|选择)\s*画师组\s*(.*)$/)
2780
+ if (useArtist) {
2781
+ const name = String(useArtist[1]).trim()
2782
+ if (!name) return session.text('.artist-use-format')
2783
+ if (['默认', '默认画师', '默认画师组', 'default'].includes(name)) {
2784
+ await persistConfig('activeArtistPreset', '')
2785
+ return session.text('.artist-default')
2786
+ }
2787
+ const presets = parsePresetList(cfg.artistPresets)
2788
+ if (!presets[name]) return session.text('.artist-not-found', [name])
2789
+ await persistConfig('activeArtistPreset', name)
2790
+ return session.text('.artist-used', [name, presets[name]])
2791
+ }
2792
+ if (text.match(/^(?:查看|列出|显示)\s*画师组|画师组列表|画师组$/)) {
2793
+ const presets = parsePresetList(cfg.artistPresets)
2794
+ const active = cfg.activeArtistPreset && presets[cfg.activeArtistPreset] ? cfg.activeArtistPreset : ''
2795
+ const lines = ['画师组:']
2796
+ lines.push(`- 备用画师 tags:${cfg.defaultArtistTags ? '已配置' : '未配置'}${!active ? '(当前)' : ''}`)
2797
+ if (!Object.keys(presets).length) {
2798
+ lines.push('- 已保存的画师组:无')
2799
+ } else {
2800
+ for (const [name, tags] of Object.entries(presets)) {
2801
+ lines.push(`- ${name}${name === active ? '(当前)' : ''}:${tags.slice(0, 120)}`)
2802
+ }
2803
+ }
2804
+ return lines.join('\n')
2805
+ }
2806
+ const deleteArtist = text.match(/^(?:删除|移除)\s*画师组\s*(.*)$/)
2807
+ if (deleteArtist) {
2808
+ const name = String(deleteArtist[1]).trim()
2809
+ if (!name) return session.text('.artist-delete-format')
2810
+ const presets = parsePresetList(cfg.artistPresets)
2811
+ if (!presets[name]) return session.text('.artist-not-found', [name])
2812
+ delete presets[name]
2813
+ await persistConfig('artistPresets', Object.entries(presets).map(([n, t]) => `${n}=${t}`))
2814
+ if (cfg.activeArtistPreset === name) await persistConfig('activeArtistPreset', '')
2815
+ return session.text('.artist-deleted', [name])
2816
+ }
2817
+
2818
+ // 固定角色管理
2819
+ const addCharacter = text.match(/^(?:添加|加入|新增|新建|创建|保存)\s*(?:固定)?\s*角色\s*(.*)$/)
2820
+ if (addCharacter) {
2821
+ const parsed = parseNameTags(addCharacter[1])
2822
+ if (!parsed) return session.text('.character-format')
2823
+ const chars = parsePresetList(cfg.fixedCharacters)
2824
+ chars[parsed.name] = normalizeTagText(parsed.tags)
2825
+ await persistConfig('fixedCharacters', Object.entries(chars).map(([n, t]) => `${n}=${t}`))
2826
+ if (cfg.outputLogs) logger.success(`${USERID} 添加固定角色 ${parsed.name}`)
2827
+ return session.text('.character-created', [parsed.name, parsed.tags])
2828
+ }
2829
+
2830
+ // 模型切换:p-draw 模型 <名称>(查看)/ p-draw 模型 默认(重置)
2831
+ const modelMatch = text.match(/^(?:切换)?\s*模型\s*(.*)$/)
2832
+ if (modelMatch) {
2833
+ const arg = String(modelMatch[1]).trim()
2834
+ const list = await listUnetModels()
2835
+ const all = list.length ? list : [cfg.unetName]
2836
+ const current = await resolveUnet(USERID)
2837
+ if (!arg || ['当前', '查看', '列表', 'help', '帮助'].includes(arg)) {
2838
+ return session.text('.model-usage', [current, all.join('\n')])
2839
+ }
2840
+ if (['默认', '重置', '恢复默认'].includes(arg)) {
2841
+ delete cfg.userModels[USERID]
2842
+ await persistConfig('userModels', cfg.userModels)
2843
+ if (cfg.outputLogs) logger.success(`${USERID} 恢复默认模型`)
2844
+ return session.text('.model-reset', [cfg.unetName])
2845
+ }
2846
+ const match = matchUnetModel(arg, all)
2847
+ if (!match) {
2848
+ // 明显是一段生图描述(带逗号/冒号/很长)时,明确告知模型指令不能生图
2849
+ if (/[,,::]/.test(arg) || arg.length > 50) return session.text('.model-no-draw')
2850
+ return session.text('.model-not-found', [arg, all.join('\n')])
2851
+ }
2852
+ if (match.multiple) return session.text('.model-ambiguous', [arg, match.multiple.join('\n')])
2853
+ cfg.userModels[USERID] = String(match)
2854
+ await persistConfig('userModels', cfg.userModels)
2855
+ if (cfg.outputLogs) logger.success(`${USERID} 切换模型 → ${match}`)
2856
+ return session.text('.model-switched', [String(match)])
2857
+ }
2858
+
2859
+ // 主生成流程
2860
+ return await handleGenerate(session, text)
2861
+ })
2862
+
2863
+ // ---------------- 以图生图(p-draw i2i) ----------------
2864
+ async function handleGenerateI2I(session, rawText) {
2865
+ if (cfg.customWorkflowEnabled && cfg.customWorkflowPath) {
2866
+ return session.text('.i2i-no-custom-workflow')
2867
+ }
2868
+ const image = await extractImageFromSession(session)
2869
+ if (!image) return session.text('.i2i-no-image')
2870
+ let uploadName
2871
+ try {
2872
+ uploadName = await uploadImageToComfyui(image)
2873
+ } catch (e) {
2874
+ logger.warn(`上传原图失败:${e.message}`)
2875
+ return session.text('.i2i-upload-fail', [e.message])
2876
+ }
2877
+ if (cfg.outputLogs) logger.info(`[p-draw] i2i ${session.userId} 原图已上传:${uploadName}`)
2878
+ return await handleGenerate(session, rawText, uploadName)
2879
+ }
2880
+
2881
+ // 从会话消息里取第一张图片并下载为内存 Buffer
2882
+ async function extractImageFromSession(session) {
2883
+ const elements = session.elements || []
2884
+ const img = elements.find((e) => e.type === 'img' || e.type === 'image')
2885
+ if (!img) return null
2886
+ const attrs = img.attrs || img.data || {}
2887
+ const src = String(attrs.src || '')
2888
+ if (!src) return null
2889
+ try {
2890
+ if (/^file:\/\//i.test(src)) {
2891
+ const filePath = src.replace(/^file:\/\//i, '')
2892
+ const buffer = await fsp.readFile(filePath)
2893
+ return { buffer, ext: path.extname(filePath) || '.png' }
2894
+ }
2895
+ if (/^data:/i.test(src)) {
2896
+ const m = src.match(/^data:image\/([a-zA-Z0-9+]+);base64,(.+)$/)
2897
+ if (!m) return null
2898
+ const kind = m[1].toLowerCase()
2899
+ const ext = kind === 'jpeg' ? '.jpg' : kind === 'webp' ? '.webp' : kind === 'png' ? '.png' : '.' + (kind || 'png')
2900
+ return { buffer: Buffer.from(m[2], 'base64'), ext }
2901
+ }
2902
+ const res = await fetch(src)
2903
+ if (!res.ok) return null
2904
+ const buffer = Buffer.from(await res.arrayBuffer())
2905
+ const mime = String(res.headers.get('content-type') || '')
2906
+ const ext = mime.includes('jpeg') ? '.jpg' : mime.includes('webp') ? '.webp' : '.png'
2907
+ return { buffer, ext }
2908
+ } catch (e) {
2909
+ logger.warn(`读取原图失败:${e.message}`)
2910
+ return null
2911
+ }
2912
+ }
2913
+
2914
+ // 上传图片到 ComfyUI input 目录,返回 ComfyUI 使用的文件名
2915
+ async function uploadImageToComfyui(image) {
2916
+ const filename = `pdraw_i2i_${Date.now()}_${crypto.randomBytes(4).toString('hex')}${image.ext || '.png'}`
2917
+ const form = new FormData()
2918
+ form.append('image', new Blob([image.buffer]), filename)
2919
+ form.append('overwrite', 'true')
2920
+ form.append('type', 'input')
2921
+ const res = await fetch(baseUrl() + '/upload/image', { method: 'POST', body: form })
2922
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
2923
+ const data = await res.json().catch(() => ({}))
2924
+ if (!data || !data.name) throw new Error('ComfyUI 未返回上传文件名')
2925
+ return data.name
2926
+ }
2927
+
2928
+ async function handleGenerate(session, rawText, i2iImage = null) {
2929
+ const USERID = session.userId
2930
+ const isAdmin = isAdminUser(session)
2931
+ const unet = await resolveUnet(USERID)
2932
+ const i2iRule = i2iImage
2933
+ ? '这是以图生图(img2img):原图的构图、主体与色调会被保留。请主要描述你希望发生的变化(风格、服装、表情、场景改造、细节调整等),不要重复描述原图已有的细节。'
2934
+ : ''
2935
+ if (cfg.outputLogs) {
2936
+ logger.info(`[p-draw] 请求 userId=${USERID} isAdmin=${isAdmin} adminUsers=${JSON.stringify(cfg.adminUsers || [])} normalizeId=${normalizeId(USERID)}`)
2937
+ }
2938
+
2939
+ // 尺寸解析
2940
+ const allowed = parseAllowedSizes()
2941
+ const parsedSize = parseGenerationSize(rawText, allowed)
2942
+ if (parsedSize.error) return parsedSize.error
2943
+
2944
+ // 批量张数解析(x3 / 3张 / --数量 3 等)
2945
+ const parsedBatch = parseBatchCount(parsedSize.prompt, cfg.batchMax)
2946
+ // 固定种子解析(--seed:xxx / --seed xxx / --seed=xxx),并从提示词中剥离
2947
+ const parsedSeed = parseSeed(parsedBatch.prompt)
2948
+ const text = parsedSeed.prompt
2949
+ const seed = parsedSeed.seed
2950
+ const count = parsedBatch.count
2951
+
2952
+ // P 点校验(按总价 = 张数 × 单价)
2953
+ if (!isAdmin) {
2954
+ const notExists = await isAccountExists(USERID)
2955
+ if (!notExists) return session.text('.account-notExists')
2956
+ const usersdata = await getPUser(USERID)
2957
+ const saving = usersdata?.p || 0
2958
+ if (saving < count * cfg.price) return session.text('.no-enough-p', [count * cfg.price])
2959
+ }
2960
+
2961
+ // 原样模式
2962
+ const stripped = stripRawPrefix(text)
2963
+ const raw = stripped.raw
2964
+ const userPrompt = stripped.prompt
2965
+ if (!userPrompt) return session.text('.no-prompt')
2966
+
2967
+ // ComfyUI 就绪
2968
+ const ready = await ensureComfyuiReady()
2969
+ if (!ready.ok) return ready.message
2970
+
2971
+ // 提示词
2972
+ let finalPrompt = userPrompt
2973
+ let degraded = false
2974
+ let optimizedReason = ''
2975
+ let tokenUsedCount = 0
2976
+ let tokenShortfall = 0
2977
+ let perImageOptimize = false
2978
+ let noOptimizeReason = ''
2979
+ let interactiveCoupon = false
2980
+ if (!raw) {
2981
+ const globalOpt = cfg.promptOptimizeEnabled
2982
+ const adminOpt = !globalOpt && isAdmin && cfg.llmModel && cfg.llmBaseUrl
2983
+ const tokenOpt = !globalOpt && !isAdmin && cfg.llmModel && cfg.llmBaseUrl
2984
+ if (globalOpt) {
2985
+ // 全局优化开启:一次优化,整批复用同一提示词
2986
+ const optimized = await optimizePrompt(session, userPrompt, false, i2iRule)
2987
+ finalPrompt = optimized.prompt
2988
+ degraded = !optimized.ok
2989
+ optimizedReason = optimized.reason || ''
2990
+ } else if (adminOpt) {
2991
+ // 管理员在全局关闭时也免费优化(不耗券)
2992
+ const optimized = await optimizePrompt(session, userPrompt, true, i2iRule)
2993
+ finalPrompt = optimized.prompt
2994
+ degraded = !optimized.ok
2995
+ optimizedReason = optimized.reason || ''
2996
+ } else if (tokenOpt) {
2997
+ // 全局优化关闭:使用 p-shop 的「提示词优化券」(p_system.llmToken)。
2998
+ // 券一次性,按张数扣:x3 扣 3 张,每张图独立做一次 LLM 优化。
2999
+ // 交互式确认:有券问是否使用(拒绝则取消本次生图);
3000
+ // 没券/券不足问是否购买(显示价格,拒绝一次再警告并问第二次,再拒绝直接生图)。
3001
+ const puser = await getPUser(USERID)
3002
+ const tokens = puser ? parseInt(puser.llmToken || 0) : 0
3003
+ if (typeof session.prompt === 'function') {
3004
+ const outcome = await couponConfirmFlow(session, USERID, tokens, count)
3005
+ if (outcome.cancelled) return ''
3006
+ tokenUsedCount = outcome.tokenUsedCount
3007
+ perImageOptimize = outcome.perImageOptimize
3008
+ noOptimizeReason = outcome.noOptimizeReason
3009
+ interactiveCoupon = true
3010
+ } else {
3011
+ // 平台不支持交互式确认:退回自动消耗逻辑
3012
+ if (tokens >= count) {
3013
+ try {
3014
+ await ctx.database.set('p_system', { userid: USERID }, { llmToken: Math.max(0, tokens - count) })
3015
+ tokenUsedCount = count
3016
+ perImageOptimize = true
3017
+ } catch (e) {
3018
+ logger.warn(`消耗提示词优化券失败:${e.message}`)
3019
+ }
3020
+ } else {
3021
+ tokenShortfall = count - tokens
3022
+ }
3023
+ }
3024
+ } else {
3025
+ // 没走任何 LLM 优化:LLM 未配置
3026
+ noOptimizeReason = 'no-llm-config'
3027
+ }
3028
+ } else {
3029
+ noOptimizeReason = 'raw'
3030
+ }
3031
+ // 非按张优化模式:直接拼好整批复用的提示词
3032
+ if (!perImageOptimize) {
3033
+ const composed = composePrompt(finalPrompt, raw)
3034
+ finalPrompt = composed.prompt
3035
+ degraded = degraded || composed.degraded
3036
+ }
3037
+
3038
+ // 扣 P 点(一次性扣除总价)
3039
+ if (!isAdmin) {
3040
+ const saving = await deductP(USERID, count * cfg.price)
3041
+ if (cfg.outputLogs) logger.info(`[p-draw] ${USERID} 已扣除 ${count * cfg.price} P 点(${count} 张 × ${cfg.price}),余额 ${saving - count * cfg.price}`)
3042
+ }
3043
+
3044
+ // 队列:预排队全部任务,占满即退回总价
3045
+ const queuedTasks = []
3046
+ let firstPosition = null
3047
+ if (cfg.queueEnabled) {
3048
+ for (let i = 0; i < count; i++) {
3049
+ const q = enqueue(async () => {
3050
+ let p = finalPrompt
3051
+ if (perImageOptimize) {
3052
+ const optimized = await optimizePrompt(session, userPrompt, true, i2iRule)
3053
+ p = composePrompt(optimized.prompt || userPrompt, raw).prompt
3054
+ }
3055
+ return runComfyGenerate(p, parsedSize.size, { unet, seed, i2iImage })
3056
+ })
3057
+ if (!q.ok) {
3058
+ if (!isAdmin) await refundP(USERID, count * cfg.price)
3059
+ return q.message
3060
+ }
3061
+ queuedTasks.push(q.task)
3062
+ if (firstPosition == null) firstPosition = q.position
3063
+ }
3064
+ }
3065
+
3066
+ // 先发一条即时反馈(扣费结果 / 队列位置 / 降级提示),
3067
+ // 确保用户不会以为指令没反应。
3068
+ const notice = []
3069
+ if (degraded) {
3070
+ const reason = readableOptimizeReason(optimizedReason)
3071
+ notice.push(session.text('.prompt-degraded', [reason]))
3072
+ }
3073
+ if (tokenUsedCount && !interactiveCoupon) notice.push(session.text('.token-used', [tokenUsedCount]))
3074
+ if (tokenShortfall) notice.push(session.text('.token-short', [count, count - tokenShortfall]))
3075
+ if (!tokenShortfall && noOptimizeReason && !interactiveCoupon) {
3076
+ const reasons = {
3077
+ 'no-llm-config': '未配置 LLM(llmBaseUrl/llmModel 为空)',
3078
+ 'raw': '无优化模式(原样生图)',
3079
+ }
3080
+ notice.push(session.text('.no-optimize', [reasons[noOptimizeReason] || noOptimizeReason]))
3081
+ }
3082
+ if (parsedBatch.clamped) notice.push(session.text('.batch-limit', [count]))
3083
+ if (cfg.queueEnabled) {
3084
+ notice.push(session.text('.queued', [firstPosition, cfg.queueMaxRequests || '∞']))
3085
+ if (count > 1) notice.push(session.text('.batch-count', [count]))
3086
+ if (!isAdmin) notice.push(session.text('.charged', [count * cfg.price]))
3087
+ } else {
3088
+ notice.push(session.text('.generating'))
3089
+ if (count > 1) notice.push(session.text('.batch-count', [count]))
3090
+ if (!isAdmin) notice.push(session.text('.charged', [count * cfg.price]))
3091
+ }
3092
+ if (notice.length) {
3093
+ try {
3094
+ await session.send(notice.filter(Boolean).join('\n'))
3095
+ } catch (e) {
3096
+ logger.warn(`发送反馈消息失败:${e.message}`)
3097
+ }
3098
+ }
3099
+
3100
+ // 单张生成
3101
+ const runOne = async (i) => {
3102
+ let p = finalPrompt
3103
+ if (perImageOptimize) {
3104
+ const optimized = await optimizePrompt(session, userPrompt, true, i2iRule)
3105
+ p = composePrompt(optimized.prompt || userPrompt, raw).prompt
3106
+ }
3107
+ let result
3108
+ if (cfg.queueEnabled) {
3109
+ try { result = await queuedTasks[i] } catch (e) { result = { ok: false, message: `生成失败:${e.message}` } }
3110
+ } else {
3111
+ try { result = await runComfyGenerate(p, parsedSize.size, { unet, seed, i2iImage }) } catch (e) { result = { ok: false, message: `生成失败:${e.message}` } }
3112
+ }
3113
+ return result
3114
+ }
3115
+
3116
+ const { results, successCount } = await executeBatch(USERID, isAdmin, count, cfg.price, runOne)
3117
+
3118
+ // 汇总
3119
+ const allOutputs = []
3120
+ const seeds = []
3121
+ const failures = []
3122
+ for (const item of results) {
3123
+ if (item.ok) {
3124
+ allOutputs.push(...item.outputs)
3125
+ if (item.seed != null) seeds.push(item.seed)
3126
+ } else {
3127
+ failures.push(`第 ${item.i + 1} 张:${item.message}`)
3128
+ }
3129
+ }
3130
+
3131
+ if (!allOutputs.length) {
3132
+ if (cfg.outputLogs) logger.warn(`生成全部失败(${USERID}),已按张退款`)
3133
+ return session.text('.generate-failed', ['全部失败(已按张退款)'])
3134
+ }
3135
+
3136
+ if (cfg.outputLogs) logger.success(`${USERID} 生成成功 ${successCount}/${count} 张`)
3137
+
3138
+ const imageElements = allOutputs.map(src => h.image(src))
3139
+ // 引用用户触发指令的原消息
3140
+ try {
3141
+ await session.send(h.quote(session.messageId) + imageElements.join(''))
3142
+ } catch (e) {
3143
+ logger.warn(`发送图片失败(引用):${e.message}`)
3144
+ await session.send(imageElements)
3145
+ }
3146
+
3147
+ const reply = []
3148
+ if (count > 1) {
3149
+ reply.push(session.text('.generate-ok-batch', [count * cfg.price, successCount, seeds.join(', ') || '-']))
3150
+ } else {
3151
+ reply.push(session.text('.generate-ok', [cfg.price, seeds[0] || '-']))
3152
+ }
3153
+ if (failures.length) reply.push(session.text('.batch-partial', [successCount, count, failures.length, failures.join(';')]))
3154
+ return reply.filter(Boolean).join('\n')
3155
+ }
3156
+ // ---------------- 提示词优化券交互式确认 ----------------
3157
+ // 仅全局优化关闭 + 非管理员 + 已配置 LLM(tokenOpt 分支)时进入。
3158
+ // 返回:{ cancelled, tokenUsedCount, perImageOptimize, noOptimizeReason }
3159
+ async function couponConfirmFlow(session, USERID, tokens, count) {
3160
+ const couponPrice = await resolveCouponPrice()
3161
+ if (tokens >= count) {
3162
+ // 有券:问是否使用;拒绝/超时都取消本次生图(尚未扣 P)
3163
+ await session.send(session.text('.coupon-ask-use', [tokens, count]))
3164
+ const reply = await session.prompt(cfg.couponAskTimeout * 1000)
3165
+ const ans = normalizeConfirm(reply)
3166
+ if (ans === true) {
3167
+ try {
3168
+ await ctx.database.set('p_system', { userid: USERID }, { llmToken: Math.max(0, tokens - count) })
3169
+ await session.send(session.text('.coupon-use-confirmed', [count]))
3170
+ return { cancelled: false, tokenUsedCount: count, perImageOptimize: true, noOptimizeReason: '' }
3171
+ } catch (e) {
3172
+ logger.warn(`消耗提示词优化券失败:${e.message}`)
3173
+ return { cancelled: false, tokenUsedCount: 0, perImageOptimize: false, noOptimizeReason: 'coupon-consume-fail' }
3174
+ }
3175
+ } else if (ans === false) {
3176
+ await session.send(session.text('.coupon-use-cancelled'))
3177
+ return { cancelled: true, tokenUsedCount: 0, perImageOptimize: false, noOptimizeReason: '' }
3178
+ } else {
3179
+ await session.send(session.text('.coupon-cancelled'))
3180
+ return { cancelled: true, tokenUsedCount: 0, perImageOptimize: false, noOptimizeReason: '' }
3181
+ }
3182
+ } else {
3183
+ // 没券/券不足:问是否购买(显示价格);拒绝一次再警告并问第二次,再拒绝直接生图
3184
+ const askBuy = async () => {
3185
+ await session.send(session.text('.coupon-ask-buy', [count, tokens, couponPrice, count * couponPrice]))
3186
+ const reply = await session.prompt(cfg.couponAskTimeout * 1000)
3187
+ return normalizeConfirm(reply)
3188
+ }
3189
+ let ans = await askBuy()
3190
+ if (ans === false) {
3191
+ await session.send(session.text('.coupon-buy-warn'))
3192
+ const reply = await session.prompt(cfg.couponAskTimeout * 1000)
3193
+ ans = normalizeConfirm(reply)
3194
+ if (ans !== true) {
3195
+ await session.send(session.text('.coupon-buy-cancelled'))
3196
+ return { cancelled: false, tokenUsedCount: 0, perImageOptimize: false, noOptimizeReason: 'coupon-declined' }
3197
+ }
3198
+ return await buyCouponsAndConsume(session, USERID, tokens, count, couponPrice)
3199
+ } else if (ans === true) {
3200
+ return await buyCouponsAndConsume(session, USERID, tokens, count, couponPrice)
3201
+ } else {
3202
+ await session.send(session.text('.coupon-cancelled'))
3203
+ return { cancelled: true, tokenUsedCount: 0, perImageOptimize: false, noOptimizeReason: '' }
3204
+ }
3205
+ }
3206
+ }
3207
+
3208
+ // 购买 count 张券并用于本批:先校验余额(需覆盖券价 + 本批生成价),扣券价 P 后消耗。
3209
+ async function buyCouponsAndConsume(session, USERID, tokens, count, couponPrice) {
3210
+ const total = count * couponPrice
3211
+ const usersdata = await getPUser(USERID)
3212
+ const saving = usersdata?.p || 0
3213
+ if (saving < total + count * cfg.price) {
3214
+ // P 点不足买不起券:询问是否仍然生图(不使用 LLM 优化)
3215
+ await session.send(session.text('.coupon-buy-pshort', [total, saving]))
3216
+ const reply = await session.prompt(cfg.couponAskTimeout * 1000)
3217
+ const ans = normalizeConfirm(reply)
3218
+ if (ans === true) {
3219
+ await session.send(session.text('.coupon-buy-cancelled'))
3220
+ return { cancelled: false, tokenUsedCount: 0, perImageOptimize: false, noOptimizeReason: 'coupon-declined' }
3221
+ }
3222
+ await session.send(session.text('.coupon-use-cancelled'))
3223
+ return { cancelled: true, tokenUsedCount: 0, perImageOptimize: false, noOptimizeReason: '' }
3224
+ }
3225
+ try {
3226
+ await deductP(USERID, total)
3227
+ // 净效果:买 count 张 + 本批消耗 count 张 = llmToken 保持原值
3228
+ await ctx.database.set('p_system', { userid: USERID }, { llmToken: Math.max(0, tokens) })
3229
+ await session.send(session.text('.coupon-bought-used', [count, total]))
3230
+ return { cancelled: false, tokenUsedCount: count, perImageOptimize: true, noOptimizeReason: '' }
3231
+ } catch (e) {
3232
+ logger.warn(`购买/消耗提示词优化券失败:${e.message}`)
3233
+ await refundP(USERID, total)
3234
+ await session.send(session.text('.coupon-consume-fail'))
3235
+ return { cancelled: false, tokenUsedCount: 0, perImageOptimize: false, noOptimizeReason: 'coupon-consume-fail' }
3236
+ }
3237
+ }
3238
+
3239
+ // 解析用户对确认问题的回复:true=确认 / false=拒绝 / null=未确认(超时或乱答)
3240
+ function normalizeConfirm(reply) {
3241
+ const s = String(reply || '').trim().replace(/[,。!?、,.!?\s]/g, '').toLowerCase()
3242
+ if (!s) return null
3243
+ const yes = ['是', '对', '要', '用', '买', '购买', '好', '行', '可以', '确认', '确定', '嗯', '使用', '要用', '用券', '同意', 'yes', 'y', 'ok', '1', 'true']
3244
+ const no = ['不', '否', '不要', '不用', '不买', '不购买', '算了', '取消', '不用了', '不需要', '不行', '拒绝', '不是', 'no', 'n', '0', 'false']
3245
+ if (yes.includes(s)) return true
3246
+ if (no.includes(s)) return false
3247
+ return null
3248
+ }
3249
+
3250
+ // 提示词优化券单价:优先读 data/p-shop.json 里覆盖的价格,否则用配置 couponPrice
3251
+ async function resolveCouponPrice() {
3252
+ const candidates = [
3253
+ path.join(ctx.baseDir, 'data', 'p-shop.json'),
3254
+ path.join(process.cwd(), 'data', 'p-shop.json'),
3255
+ ]
3256
+ for (const f of candidates) {
3257
+ try {
3258
+ if (fs.existsSync(f)) {
3259
+ const data = JSON.parse(fs.readFileSync(f, 'utf-8'))
3260
+ if (data && typeof data === 'object') {
3261
+ const item = data['提示词优化券']
3262
+ if (item && typeof item.price === 'number' && item.price > 0) return item.price
3263
+ }
3264
+ }
3265
+ } catch (e) {
3266
+ logger.warn(`读取 p-shop.json 价格失败:${e.message}`)
3267
+ }
3268
+ }
3269
+ return cfg.couponPrice
3270
+ }
3271
+
3272
+ // 启动时合并数据库里保存的运行时配置(画师组/固定角色)
3273
+ await loadRuntimeState()
3274
+
3275
+ ctx.on('dispose', () => {
3276
+ // 清理临时文件
3277
+ try {
3278
+ const files = fs.readdirSync(tempDir)
3279
+ for (const file of files) {
3280
+ try { fs.unlinkSync(path.join(tempDir, file)) } catch (e) { /* ignore */ }
3281
+ }
3282
+ } catch (e) { /* ignore */ }
3283
+ })
3284
+
3285
+ // 暴露内部接口供自动化测试调用(Koishi 忽略 apply 返回值,不影响生产行为)
3286
+ return { couponConfirmFlow, buyCouponsAndConsume, normalizeConfirm, resolveCouponPrice, handleGenerateI2I, extractImageFromSession, uploadImageToComfyui, animaI2IWorkflow }
3287
+ }