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.
- package/index.js +3287 -0
- package/package.json +29 -0
- 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
|
+
}
|