koishi-plugin-p-draw 1.2.13 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/comfy.js ADDED
@@ -0,0 +1,81 @@
1
+ // ComfyUI 结果等待与输出提取。
2
+ // waitComfyResult 依赖一个注入的 comfyGet(由 http 客户端提供),可独立单测。
3
+
4
+ function outputImages(history) {
5
+ const images = []
6
+ const outputs = history.outputs || {}
7
+ if (outputs && typeof outputs === 'object') {
8
+ for (const nodeOutput of Object.values(outputs)) {
9
+ if (!nodeOutput || typeof nodeOutput !== 'object') continue
10
+ for (const image of nodeOutput.images || []) {
11
+ if (image && typeof image === 'object') images.push(image)
12
+ }
13
+ }
14
+ }
15
+ return images
16
+ }
17
+
18
+ // ------------------------------------------------------------------
19
+ // ComfyUI 结果等待:优先 WebSocket 事件,失败/不可用回退轮询
20
+ // ------------------------------------------------------------------
21
+ function waitViaWebSocket(baseUrl, promptId, clientId, timeoutMs) {
22
+ return new Promise((resolve) => {
23
+ let socket
24
+ let timer = null
25
+ let settled = false
26
+ const finish = (ok) => {
27
+ if (settled) return
28
+ settled = true
29
+ if (timer) clearTimeout(timer)
30
+ try { if (socket) socket.close() } catch (e) { /* ignore */ }
31
+ resolve(ok)
32
+ }
33
+ try {
34
+ const wsUrl = baseUrl.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:') + `/ws?clientId=${encodeURIComponent(clientId)}`
35
+ socket = new WebSocket(wsUrl)
36
+ } catch (e) {
37
+ finish(false)
38
+ return
39
+ }
40
+ timer = setTimeout(() => finish(false), timeoutMs)
41
+ socket.onmessage = (ev) => {
42
+ let msg
43
+ try { msg = JSON.parse(String(ev.data)) } catch (e) { return }
44
+ if (!msg || typeof msg !== 'object') return
45
+ if (msg.type === 'execution_success' && msg.data && msg.data.prompt_id === promptId) { finish(true); return }
46
+ if (msg.type === 'execution_error' || msg.type === 'execution_interrupted') { finish(false) }
47
+ }
48
+ socket.onerror = () => finish(false)
49
+ socket.onclose = () => finish(false)
50
+ })
51
+ }
52
+
53
+ async function waitComfyResult(ctx, comfyGet, baseUrl, promptId, clientId, timeoutMs, pollMs) {
54
+ const deadline = Date.now() + timeoutMs
55
+ if (typeof WebSocket !== 'undefined') {
56
+ const remaining = Math.max(0, deadline - Date.now())
57
+ try {
58
+ const viaWs = await waitViaWebSocket(baseUrl, promptId, clientId, remaining)
59
+ if (viaWs) {
60
+ try {
61
+ const data = await comfyGet(`/history/${promptId}`, 20000)
62
+ if (data && data[promptId]) return data[promptId]
63
+ } catch (e) { /* fall through */ }
64
+ }
65
+ } catch (e) { /* fall through to polling */ }
66
+ }
67
+ while (Date.now() < deadline) {
68
+ try {
69
+ const data = await comfyGet(`/history/${promptId}`, 20000)
70
+ if (data && data[promptId]) return data[promptId]
71
+ } catch (e) { /* transient */ }
72
+ await ctx.sleep(pollMs)
73
+ }
74
+ return null
75
+ }
76
+
77
+ module.exports = {
78
+ outputImages,
79
+ waitViaWebSocket,
80
+ waitComfyResult,
81
+ }
package/lib/http.js ADDED
@@ -0,0 +1,74 @@
1
+ // ComfyUI HTTP 客户端工厂:统一 GET/POST/GET-bytes,带超时与错误详情提取。
2
+ // 不再需要在 apply 里重复三套 fetch + AbortController。
3
+
4
+ // 构造 ComfyUI 专用客户端。getBaseUrl 在每次调用时求值,保证配置热更新生效。
5
+ function buildComfyClient({ getBaseUrl }) {
6
+ async function get(apiPath, timeout = 20000) {
7
+ const controller = new AbortController()
8
+ const timer = setTimeout(() => controller.abort(), timeout)
9
+ try {
10
+ const res = await fetch(getBaseUrl() + apiPath, { signal: controller.signal })
11
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
12
+ return await res.json()
13
+ } finally {
14
+ clearTimeout(timer)
15
+ }
16
+ }
17
+
18
+ async function post(apiPath, body, timeout = 20000) {
19
+ const controller = new AbortController()
20
+ const timer = setTimeout(() => controller.abort(), timeout)
21
+ try {
22
+ const res = await fetch(getBaseUrl() + apiPath, {
23
+ method: 'POST',
24
+ headers: { 'Content-Type': 'application/json' },
25
+ body: JSON.stringify(body),
26
+ signal: controller.signal,
27
+ })
28
+ if (!res.ok) {
29
+ // ComfyUI /prompt 校验失败时会返回 node_errors 等详细错误,尽量带出来便于排查
30
+ const text = await res.text().catch(() => '')
31
+ const detail = (() => {
32
+ try {
33
+ const data = JSON.parse(text)
34
+ const nodeErrors = (data && data.node_errors) || (data && data.error && data.error.extra_info && data.error.extra_info.node_errors) || null
35
+ if (nodeErrors && typeof nodeErrors === 'object') {
36
+ const lines = Object.entries(nodeErrors).map(([id, e]) => {
37
+ const cls = (e && e.class_type) || ''
38
+ const errs = (e && Array.isArray(e.errors) && e.errors.length)
39
+ ? e.errors.map(x => `${(x && x.message) || ''}${x && x.details ? ' | ' + x.details : ''}`.trim()).join('; ')
40
+ : JSON.stringify(e)
41
+ return ` #${id} [${cls}]: ${errs}`
42
+ })
43
+ if (lines.length) return `\n${lines.join('\n')}`
44
+ }
45
+ if (data && data.error) {
46
+ return `${data.error.message || ''}${data.error.details ? ' ' + data.error.details : ''}`.trim()
47
+ }
48
+ } catch (e) { /* ignore */ }
49
+ return text.slice(0, 800)
50
+ })()
51
+ throw new Error(`HTTP ${res.status}${detail ? ':' + detail : ''}`)
52
+ }
53
+ return await res.json()
54
+ } finally {
55
+ clearTimeout(timer)
56
+ }
57
+ }
58
+
59
+ async function getBytes(apiPath, timeout = 120000) {
60
+ const controller = new AbortController()
61
+ const timer = setTimeout(() => controller.abort(), timeout)
62
+ try {
63
+ const res = await fetch(getBaseUrl() + apiPath, { signal: controller.signal })
64
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
65
+ return Buffer.from(await res.arrayBuffer())
66
+ } finally {
67
+ clearTimeout(timer)
68
+ }
69
+ }
70
+
71
+ return { get, post, getBytes }
72
+ }
73
+
74
+ module.exports = { buildComfyClient }
package/lib/i18n.js ADDED
@@ -0,0 +1,152 @@
1
+ // 中文文案与配置描述(zhCN),供 Config schema 与 i18n.define 使用。
2
+
3
+ const zhCN = {
4
+ comfyuiBaseUrl: { $description: 'ComfyUI 地址' },
5
+ workflow: { $description: '工作流类型(内置 anima_t2i)' },
6
+ customWorkflowEnabled: { $description: '使用自定义 ComfyUI 工作流 JSON' },
7
+ customWorkflowPath: { $description: '自定义工作流 JSON 路径(相对插件目录)' },
8
+ customWorkflowOverrideParameters: { $description: '用插件参数覆盖自定义工作流参数' },
9
+ timeout: { $description: '单次生成超时(秒)' },
10
+ pollInterval: { $description: '生成状态查询间隔(秒)' },
11
+ unetName: { $description: '主模型文件名' },
12
+ clipName: { $description: '文本编码器文件名' },
13
+ vaeName: { $description: 'VAE 文件名' },
14
+ width: { $description: '默认宽度' },
15
+ height: { $description: '默认高度' },
16
+ allowedSizes: { $description: '可用尺寸列表(宽x高)' },
17
+ steps: { $description: '采样步数' },
18
+ cfg: { $description: 'CFG 强度' },
19
+ samplerName: { $description: '采样器' },
20
+ scheduler: { $description: '调度器' },
21
+ qualityPrefix: { $description: '质量词前缀' },
22
+ negativePrompt: { $description: '负面提示词' },
23
+ promptOptimizeEnabled: { $description: '启用自然语言优化(需要配置下方 LLM 接口)' },
24
+ llmBaseUrl: { $description: 'LLM 接口地址(OpenAI 兼容,例如 https://api.deepseek.com/v1)' },
25
+ llmApiKey: { $description: 'LLM API Key' },
26
+ llmModel: { $description: 'LLM 模型名(留空则不优化,原样生图)' },
27
+ llmMaxTokens: { $description: 'LLM 输出上限' },
28
+ webSearchEnabled: { $description: '启用联网搜索(指令里写“联网/搜索/查一下”等触发)' },
29
+ tavilyApiKey: { $description: 'Tavily API Key(联网搜索用,https://tavily.com 申请)' },
30
+ webSearchMaxResults: { $description: '联网搜索结果数量' },
31
+ webSearchDepth: { $description: '搜索深度(basic / advanced)' },
32
+ webSearchQueryTemplate: { $description: '搜索词模板({prompt} 代表用户需求)' },
33
+ promptOptimizeTemplate: { $description: '自然语言优化模板(支持 {theme} {search_block} 占位符)' },
34
+ fixedCharacters: { $description: '固定角色(格式:角色名=tags)' },
35
+ artistPresets: { $description: '画师组(格式:名称=tags)' },
36
+ activeArtistPreset: { $description: '启用的画师组名称' },
37
+ defaultArtistTags: { $description: '备用画师 tags' },
38
+ styleTags: { $description: '画风 tags' },
39
+ queueEnabled: { $description: '启用生成队列(逐张顺序执行)' },
40
+ queueMaxRequests: { $description: '队列最大任务数(0 表示不限制)' },
41
+ batchMax: { $description: '单次指令最多生成的张数(支持 x3 / 3张 / --数量 3 等写法)' },
42
+ price: { $description: '一张图消耗的 P 点' },
43
+ multiPrice: { $description: '多人指令(p-draw 多人)单张消耗的 P 点' },
44
+ couponPrice: { $description: '提示词优化券单价(P 点/张,购买询问时显示)' },
45
+ couponAskTimeout: { $description: '提示词优化券确认等待时间(秒)' },
46
+ img2imgDenoise: { $description: '普通以图生图(p-draw i2i)的去噪强度,越小越接近原图(建议 0.4-0.7)' },
47
+ i2iMode: { $description: 'i2i 模式选择方式:ask=每次询问 / style=直接换风格(漫画化)/ ootd=直接换装换姿势 / plain=普通 img2img' },
48
+ i2iAskTimeout: { $description: 'i2i 模式询问等待时间(秒)' },
49
+ seriesAskTimeout: { $description: '连续图 LLM 使用确认等待时间(秒)' },
50
+ i2iStyleDenoise: { $description: '换风格模式(漫画化)的去噪强度,越大风格变化越彻底(建议 0.7-0.85)' },
51
+ i2iOotdDenoise: { $description: '换装换姿势模式(保留角色)的去噪强度(建议 0.5-0.6)' },
52
+ i2iControlNetStrength: { $description: '换风格模式的 ControlNet 强度,越大构图锁得越死(建议 0.5-0.8)' },
53
+ i2iIPAdapterPath: { $description: 'Anima IP-Adapter 模型文件路径(换装换姿势模式保脸用;需安装 comfyui-anima-ipadapter 节点并把模型路径填到这里)' },
54
+ i2iIPAdapterWeight: { $description: '换装换姿势模式的 IP-Adapter 权重,越大角色特征保留越强(建议 0.6-1.0)' },
55
+ controlNetModel: { $description: 'ControlNet 模型文件名(留空自动检测 Qwen/Anima 系 ControlNet;需放到 ComfyUI/models/controlnet)' },
56
+ taggerEnabled: { $description: 'i2i 前自动识图(需 ComfyUI 安装 WD14 Tagger 节点与模型;识别出的标签会注入提示词优化)' },
57
+ taggerModel: { $description: '识图模型名(WD14 Tagger 节点里可选模型)' },
58
+ taggerThreshold: { $description: '识图标签置信度阈值' },
59
+ taggerCharacterThreshold: { $description: '识图角色标签置信度阈值' },
60
+ adminUsers: { $description: '免 P 点管理员用户 ID 列表' },
61
+ outputLogs: { $description: '是否在控制台输出详细日志' },
62
+ multiVerifyEnabled: { $description: '多人图生成后启用视觉校验(需配置下方视觉模型)' },
63
+ multiVerifyPassScore: { $description: '多人视觉校验合格分数(0-10)' },
64
+ multiCandidateCount: { $description: '多人候选采样数量(校验失败时最多重试 候选数-1 次)' },
65
+ multiSendDegradedCandidate: { $description: '多人候选全部不达标时仍发送最优候选' },
66
+ verifyLlmBaseUrl: { $description: '视觉校验 LLM 接口地址(OpenAI 兼容;留空则跳过校验)' },
67
+ verifyLlmApiKey: { $description: '视觉校验 LLM API Key' },
68
+ verifyLlmModel: { $description: '视觉校验 LLM 模型名(需支持图片输入,如 qwen-vl)' },
69
+ adminOnly: { $description: '仅管理员可用(adminUsers 中的用户)' },
70
+ allowedUserIds: { $description: '用户白名单(QQ 号,留空表示不限制)' },
71
+ blockedUserIds: { $description: '用户黑名单(QQ 号,黑名单优先于白名单)' },
72
+ allowedGroupIds: { $description: 'QQ 群白名单(群号,留空表示不限制)' },
73
+ blockedGroupIds: { $description: 'QQ 群黑名单(群号,黑名单优先于白名单)' },
74
+ commands: {
75
+ 'p-draw': {
76
+ description: '连接本地 ComfyUI 生图,消耗 P 点',
77
+ messages: {
78
+ 'not-permitted': 'ComfyUI 助手已关闭,或当前用户没有使用权限。',
79
+ usage: 'p-draw 帮助(本指令名可自行更换,如 /anm):\n\n【生成】\n p-draw <描述>\n 例:p-draw 一个女孩,白色裙子,立绘,简单背景\n 可加 --seed 数字 固定种子(单张 / 批量 / 连续图均支持)\n\n【多人生成】\n p-draw 多人 <描述>(2-4 人画面)\n 例:p-draw 多人 左边若叶睦抱着吉他,右边千早爱音牵着她的手\n\n【连续图】\n p-draw 连续 <角色>:<阶段1> → <阶段2> → ...\n 例:p-draw 连续 少女:清纯校服 → 换上晚礼服 → 华丽登场\n 全阶段共用同一 seed,角色外观尽量一致;可加 --seed 数字 固定种子\n 阶段分隔:→ / -> / |\n\n【批量张数】\n 描述后加 x3 / ×3 / 3张 / 三张 / --数量 3,例:p-draw 一个女孩 x3\n 多张按 张数×单价 一次性扣 P 点,余额不足则不生成\n\n【尺寸】\n 竖图 / 横图 / 方图 / 长竖图 / 宽屏,或 1024x1536:描述 / --尺寸 1216x832\n 例:p-draw 竖图:狐莉站在梨花树下\n\n【原样模式】\n p-draw 无优化 masterpiece, best quality, 1girl, solo\n\n【提示词优化】\n 配置 LLM(llmBaseUrl/llmModel)后:\n 全局优化开启 → 每次自动把中文描述转成 Danbooru tags\n 全局优化关闭 → 生图时询问是否使用「提示词优化券」(p-shop 购买,每张图扣 1 张):\n 有券 → 确认后消耗券并优化,拒绝则取消本次生图\n 没券 → 询问是否购买(显示价格),确认后购买并消耗、优化生图;\n 拒绝一次会再次警告,再拒绝则直接生图(不优化);\n P 点不足买不起券时,会询问是否仍然生图\n 无优化 <tags> 原样生图,不耗券\n 连续图逐阶段强制优化;多人指令依赖 LLM 规划(未配置会提示)\n\n【画师组】\n 创建画师组 名称=tags / 追加画师组 名称=tags / 切换画师组 名称 / 查看画师组 / 删除画师组 名称\n\n【固定角色】\n 添加角色 名称=tags\n\n【以图生图】\n p-draw i2i <描述>,并在同一条消息里附一张原图(文件/截图/链接均可)\n 例:p-draw i2i 换成晚礼服,背景换成舞台灯光\n 发送后会询问处理模式(可配置 i2iMode 固定模式跳过询问):\n ① 换风格(漫画化):保留原图构图,转成二次元画风(需 ControlNet)\n ② 换装换姿势:保留角色长相,重新设计服装/姿势/场景(需 Anima IP-Adapter)\n ③ 取消:不生成\n 可加 --denoise 0.6 单独调整强度;未装 ControlNet/IPAdapter 时自动回退普通 img2img\n\n【模型】\n p-draw 模型(查看当前与可用模型) / p-draw 模型 名称(切换,支持模糊匹配)/ p-draw 模型 默认(重置)\n 例:p-draw 模型 anima-aesthetic\n\n【状态】\n p-draw 状态(查看 ComfyUI 连接状态与模型可用性)',
80
+ 'account-notExists': '君现在还没有 p 点,请先签到哦',
81
+ 'no-enough-p': '君的 p 点不够 {0}p 哦,先去签个到吧qwq',
82
+ 'no-prompt': '请提供画面描述,例如:p-draw 一个女孩,白色裙子',
83
+ generating: '正在生成中,请稍候...',
84
+ charged: '已扣除 {0} P 点,出图后余额会再核对。',
85
+ queued: '已加入生成队列,当前第 {0} 位(队列上限 {1})。',
86
+ 'prompt-degraded': '提示词优化服务不可用{0},本次已使用原始提示词继续生成;结果可能不符合 Danbooru Tag 预期。',
87
+ 'token-used': '已消耗 {0} 张提示词优化券,本次每张图都会使用 LLM 提示词优化。',
88
+ 'token-short': '提示词优化券不足(需 {0} 张,现有 {1} 张),本次未使用 LLM 优化。',
89
+ 'coupon-ask-use': '你有提示词优化券 {0} 张,本次生图需要消耗 {1} 张。\n是否使用提示词优化券进行 LLM 优化?\n(回复「是」使用 / 回复「否」取消本次生图)',
90
+ 'coupon-use-confirmed': '已消耗 {0} 张提示词优化券,本次每张图都会使用 LLM 优化。',
91
+ 'coupon-use-cancelled': '已取消本次生图(未使用提示词优化券)。',
92
+ 'coupon-cancelled': '未收到有效回复,本次操作已取消。',
93
+ 'coupon-ask-buy': '提示词优化券不足(需 {0} 张,现有 {1} 张)。\n提示词优化券价格:{2} P/张,本次共需 {3} P。\n是否购买并使用?\n(回复「是」购买 / 回复「否」不购买)',
94
+ 'coupon-buy-warn': '不使用提示词优化券的话,生成的图可能不好看。\n是否仍要购买并使用提示词优化券?\n(回复「是」购买 / 回复「否」直接生图)',
95
+ 'coupon-bought-used': '已购买 {0} 张提示词优化券(扣除 {1} P),并消耗 {0} 张用于本次 LLM 优化。',
96
+ 'coupon-buy-cancelled': '好的,本次不使用提示词优化券,直接生图。',
97
+ 'coupon-buy-pshort': 'P 点不足,无法购买提示词优化券(需 {0} P,现有 {1} P)。\n是否仍然生图(不使用 LLM 优化)?\n(回复「是」生图 / 回复「否」取消本次生图)',
98
+ 'coupon-consume-fail': '提示词优化券操作失败,本次未使用 LLM 优化。',
99
+ 'generate-failed': '生成失败:{0}',
100
+ 'generate-ok': '已扣除 {0} P 点,seed={1}',
101
+ 'generate-ok-batch': '已扣除 {0} P 点,共 {1} 张(seed:{2})',
102
+ 'batch-partial': '本次共生成 {0}/{1} 张,失败 {2} 张:{3}',
103
+ 'batch-limit': '每次最多生成 {0} 张,本次已按 {0} 张处理。',
104
+ 'batch-count': '本次共生成 {0} 张。',
105
+ 'artist-format': '请使用「名称=tags」的格式。例:p-draw 创建画师组 千代风格=@artist_a, @artist_b,',
106
+ 'artist-created': '已保存并启用画师组「{0}」:\n{1}',
107
+ 'artist-appended': '已追加画师组「{0}」:\n{1}',
108
+ 'artist-default-appended': '已追加默认画师 tags:\n{0}',
109
+ 'artist-use-format': '请写要启用的画师组名称。例:p-draw 切换画师组 千代风格',
110
+ 'artist-default': '已切回默认画师 tags。',
111
+ 'artist-not-found': '没有找到画师组「{0}」。',
112
+ 'artist-used': '已启用画师组「{0}」:\n{1}',
113
+ 'artist-deleted': '已删除画师组「{0}」。',
114
+ 'artist-delete-format': '请写要删除的画师组名称。例:p-draw 删除画师组 千代风格',
115
+ 'character-format': '请使用「名称=tags」的格式。例:p-draw 添加角色 狐莉=1girl, solo, fox girl',
116
+ 'character-created': '已保存角色「{0}」:\n{1}',
117
+ 'multi-usage': '多人生图:p-draw 多人 <描述>(2-4 人画面)\n例:p-draw 多人 左边若叶睦抱着吉他,右边千早爱音牵着她的手',
118
+ 'multi-verify-passed': '多人图已通过视觉校验({0} 分)。',
119
+ 'multi-verify-failed': '多人图未通过视觉校验{0},已重试 {1} 次。',
120
+ 'multi-verify-degraded': '多人图校验失败,已发送最优候选{0}。',
121
+ 'multi-verify-discarded': '多人图校验失败且未启用降级发送,本次图片不发送。',
122
+ 'multi-degraded': '多人视觉校验不可用{0},本次已直接发送生成结果。',
123
+ 'multi-verify-error': '多人视觉校验调用失败:{0}',
124
+ 'series-usage': '连续图:p-draw 连续 <角色>:<阶段1> → <阶段2> → ...\n例:p-draw 连续 少女:清纯校服 → 换上晚礼服 → 华丽登场\n或用 | 分隔,可加 --seed 固定种子保证角色一致。',
125
+ 'series-llm-ask': '是否使用 LLM 优化连续图各阶段提示词?\n1. 是(使用 LLM,自动整理成 Danbooru tags)\n2. 否(不使用 LLM,直接用你输入的内容)\n3. 取消(不生成)\n请回复 1 / 2 / 3',
126
+ 'series-llm-invalid': '未识别的回答,请回复 1(使用 LLM)/ 2(不使用 LLM)/ 3(取消)。',
127
+ 'series-no-llm': '本次未使用 LLM 优化,直接使用你输入的描述/tags。',
128
+ 'series-ok': '已扣除 {0} P 点,共 {1} 张连续图(seed={2})',
129
+ 'model-usage': '当前模型:{0}\n可用模型:\n{1}\n用法:p-draw 模型 <名称>(支持模糊匹配,如 anima-aesthetic);p-draw 模型 默认 恢复默认。',
130
+ 'model-switched': '已切换为模型「{0}」,对之后的生图生效。',
131
+ 'model-reset': '已恢复默认模型「{0}」。',
132
+ 'model-not-found': '未找到模型「{0}」。可用模型:\n{1}',
133
+ 'model-no-draw': '「模型」只能用来切换/查看模型,不能生图。请先用「p-draw 模型 <名称>」切换,再单独发送要画的内容。',
134
+ 'model-ambiguous': '「{0}」匹配到多个模型,请写得更具体些:\n{1}',
135
+ 'no-optimize': '提示:本次未使用 LLM 优化({0})。',
136
+ 'i2i-no-image': 'i2i(以图生图)需要附一张原图。用法:p-draw i2i <描述>,并在同一条消息里带上图片。',
137
+ 'i2i-upload-fail': '原图上传 ComfyUI 失败:{0}',
138
+ 'i2i-no-custom-workflow': 'i2i(以图生图)暂不支持自定义工作流(customWorkflowEnabled),请关闭后再试。',
139
+ 'i2i-mode-ask': '请选择 i2i 处理模式:\n① 换风格(漫画化)——保留原图构图,转成二次元画风\n② 换装换姿势——保留角色长相,重新设计服装/姿势/场景\n③ 取消——不生成\n回复 1 / 2 / 3 或对应名称即可。',
140
+ 'i2i-mode-invalid': '没有理解你的选择。请回复 ①换风格(漫画化) / ②换装换姿势 / ③取消。',
141
+ 'i2i-mode-cancelled': '已取消本次 i2i 生图。',
142
+ 'i2i-mode-style': '已选择【换风格(漫画化)】:保留原图构图,转成二次元画风。',
143
+ 'i2i-mode-ootd': '已选择【换装换姿势】:保留角色长相,重新设计服装/姿势/场景。',
144
+ 'i2i-no-controlnet': '未检测到可用的 Anima ControlNet-LLLite,本次「换风格」将使用普通 img2img,构图保留效果会弱一些。',
145
+ 'i2i-no-ipadapter': '未检测到可用的 Anima IP-Adapter,本次「换装换姿势」将使用普通 img2img,角色保留效果会弱一些。',
146
+ 'multi-no-llm': '多人指令需要 LLM 规划,但当前未配置 llmBaseUrl / llmModel。请管理员在配置中填写后使用。',
147
+ },
148
+ },
149
+ },
150
+ }
151
+
152
+ module.exports = { zhCN }
package/lib/media.js ADDED
@@ -0,0 +1,22 @@
1
+ // 消息媒体来源处理。
2
+ // OneBot 不一定能读取机器人进程本地的 file:// 路径,因此发送前转换为 Buffer。
3
+
4
+ const fsp = require('fs/promises')
5
+ const { fileURLToPath } = require('url')
6
+
7
+ async function materializeImageSource(source, readFile = fsp.readFile) {
8
+ if (typeof source !== 'string' || !/^file:\/\//i.test(source)) return source
9
+ let filePath
10
+ try {
11
+ filePath = fileURLToPath(source)
12
+ } catch (e) {
13
+ return source
14
+ }
15
+ try {
16
+ return await readFile(filePath)
17
+ } catch (e) {
18
+ return source
19
+ }
20
+ }
21
+
22
+ module.exports = { materializeImageSource }
package/lib/multi.js ADDED
@@ -0,0 +1,262 @@
1
+ // 多人规划纯函数库(移植自 anima multi_person_prompt / command_actions)。
2
+ // 不依赖 koishi,可独立单测。
3
+
4
+ const MULTI_PERSON_NEGATIVE_TAGS = [
5
+ 'split screen', 'comic panels', 'multiple views', 'character sheet',
6
+ 'duplicate characters', 'cloned character', 'extra person', 'extra girl', 'extra boy',
7
+ 'twins', 'merged bodies', 'fused characters',
8
+ ]
9
+
10
+ const MULTI_SAFE_SLOTS = new Set(['left', 'right', 'center', 'foreground', 'background', 'far left', 'far right'])
11
+ const MULTI_UNSAFE_COMPOSITION_MARKERS = [
12
+ 'split screen', 'panel', 'multiple views', 'alternate views', 'character sheet',
13
+ 'top left', 'top right', 'bottom left', 'bottom right',
14
+ ]
15
+ const MULTI_SAFE_SPATIAL_MODES = new Set(['shared_contact', 'shared_scene', 'explicit_positions'])
16
+
17
+ function buildMultiPersonPlanPrompt(userPrompt, fixedCharacters = {}) {
18
+ const fixedNote = Object.keys(fixedCharacters).length
19
+ ? `Locally saved characters explicitly mentioned by the user:\n${JSON.stringify(fixedCharacters, null, 2)}`
20
+ : 'No locally saved character name was detected.'
21
+ return `Plan one coherent Anima image containing 2 to 4 people.
22
+
23
+ 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.
24
+
25
+ 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".
26
+
27
+ 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.
28
+
29
+ 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.
30
+
31
+ Return JSON only with this exact shape:
32
+ {
33
+ "count_tags": ["2girls"],
34
+ "common_tags": ["medium shot", "outdoors"],
35
+ "characters": [
36
+ {
37
+ "slot": "left",
38
+ "name": "character name from the user",
39
+ "danbooru_candidate": "romanized_character_tag",
40
+ "role": "short semantic role such as rider or supporting girl",
41
+ "visual_label": "distinctive visible label such as white-haired fox girl",
42
+ "identity_anchors": ["3 to 6 short appearance tags"],
43
+ "emphasized_anchors": ["0 to 3 explicitly requested unusual traits"],
44
+ "appearance": "Visible identity traits for a non-fixed character only; empty for a locally saved character.",
45
+ "clothing": "One concise English clothing phrase.",
46
+ "expression": "One concise English expression phrase.",
47
+ "pose": "One concise English body pose that does not repeat the interaction.",
48
+ "props": ["visible prop held or worn by this person"]
49
+ },
50
+ {
51
+ "slot": "right",
52
+ "name": "second character name from the user",
53
+ "danbooru_candidate": "romanized_character_tag",
54
+ "appearance": "",
55
+ "clothing": "One concise English clothing phrase.",
56
+ "expression": "One concise English expression phrase.",
57
+ "pose": "One concise English body pose.",
58
+ "props": []
59
+ }
60
+ ],
61
+ "relationship_tag": "holding hands",
62
+ "interactions": [
63
+ "Character A is holding Character B's hand."
64
+ ],
65
+ "spatial_mode": "shared_contact",
66
+ "composition": "A single unified full-frame composition using one camera view."
67
+ }
68
+
69
+ Rules:
70
+ - Include exactly 2 to 4 character objects.
71
+ - count_tags must agree with the number and genders requested by the user.
72
+ - common_tags contain only shared scene, framing, camera, lighting, atmosphere, and count tags.
73
+ - relationship_tag is one short Danbooru-style relationship or action tag and appears immediately after the count tags in the final prompt.
74
+ - Do not put character names or character-specific appearance in common_tags.
75
+ - role is optional semantic bookkeeping and is not used to identify a person in the final interaction sentence.
76
+ - 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.
77
+ - identity_anchors must contain only 3 to 6 concise visible identity traits. For locally saved characters, select them only from the saved defining tags.
78
+ - 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.
79
+ - For locally saved characters, appearance must be empty and saved defining tags must never be contradicted.
80
+ - Do not output quality tags, safety tags, artist tags, Markdown, or explanations.
81
+ - Keep character fields and relationships visually concrete.
82
+ - Preserve the user's explicit interaction direction and gaze direction.
83
+ - Put the complete directed relationship in exactly one interactions entry. Character pose fields must not repeat the relationship.
84
+ - 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.
85
+ - 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.
86
+ - Prefer a single coherent moment rather than multiple competing actions.
87
+ - composition must use affirmative language to request one unified full-frame camera view.
88
+
89
+ ${fixedNote}
90
+
91
+ User request:
92
+ ${userPrompt}
93
+ `
94
+ }
95
+
96
+ function cleanMultiText(value, limit) {
97
+ return String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit).trim()
98
+ }
99
+
100
+ function multiStringTuple(value, limit, itemLimit) {
101
+ if (!Array.isArray(value)) return []
102
+ const result = []
103
+ for (const item of value) {
104
+ const text = cleanMultiText(item, itemLimit)
105
+ if (text) result.push(text)
106
+ }
107
+ return result.slice(0, limit)
108
+ }
109
+
110
+ function normalizeMultiSlot(value) {
111
+ const slot = cleanMultiText(value, 40).toLowerCase().replace(/_/g, ' ').replace(/-/g, ' ').replace(/\s+/g, ' ').trim()
112
+ return MULTI_SAFE_SLOTS.has(slot) ? slot : ''
113
+ }
114
+
115
+ function parseMultiPersonPlan(text) {
116
+ let raw = String(text || '').trim()
117
+ raw = raw.replace(/^```(?:json)?\s*/i, '')
118
+ raw = raw.replace(/\s*```$/, '')
119
+ const match = raw.match(/\{[\s\S]*\}/)
120
+ if (match) raw = match[0]
121
+ let data
122
+ try {
123
+ data = JSON.parse(raw)
124
+ } catch (e) {
125
+ return null
126
+ }
127
+ if (!data || typeof data !== 'object') return null
128
+ const rawCharacters = data.characters
129
+ if (!Array.isArray(rawCharacters) || rawCharacters.length < 2 || rawCharacters.length > 4) return null
130
+ if (rawCharacters.some(item => !item || typeof item !== 'object')) return null
131
+
132
+ const defaultSlots = {
133
+ 2: ['left', 'right'],
134
+ 3: ['left', 'center', 'right'],
135
+ 4: ['far left', 'left', 'right', 'far right'],
136
+ }[rawCharacters.length]
137
+ const proposedSlots = rawCharacters.map(item => normalizeMultiSlot(item.slot))
138
+ if (
139
+ proposedSlots.some(slot => !slot) ||
140
+ new Set(proposedSlots).size !== proposedSlots.length ||
141
+ (rawCharacters.length === 2 && !(new Set(proposedSlots).size === 2 && ['left', 'right'].every(s => proposedSlots.includes(s)) || ['foreground', 'background'].every(s => proposedSlots.includes(s))))
142
+ ) {
143
+ proposedSlots.splice(0, proposedSlots.length, ...defaultSlots)
144
+ }
145
+
146
+ const characters = proposedSlots.map((slot, index) => {
147
+ const item = rawCharacters[index]
148
+ return {
149
+ slot,
150
+ name: cleanMultiText(item.name, 120),
151
+ danbooru_candidate: cleanMultiText(item.danbooru_candidate, 160),
152
+ appearance: cleanMultiText(item.appearance, 500),
153
+ clothing: cleanMultiText(item.clothing, 400),
154
+ expression: cleanMultiText(item.expression, 240),
155
+ pose: cleanMultiText(item.pose, 400),
156
+ props: multiStringTuple(item.props, 12, 120),
157
+ role: cleanMultiText(item.role, 80),
158
+ visual_label: cleanMultiText(item.visual_label, 100),
159
+ identity_anchors: multiStringTuple(item.identity_anchors, 6, 100),
160
+ emphasized_anchors: multiStringTuple(item.emphasized_anchors, 3, 100),
161
+ }
162
+ })
163
+
164
+ const countTags = multiStringTuple(data.count_tags, 8, 80)
165
+ const commonTags = multiStringTuple(data.common_tags, 50, 100)
166
+ const interactions = multiStringTuple(data.interactions, 1, 500)
167
+ const composition = cleanMultiText(data.composition, 700)
168
+ let spatialMode = cleanMultiText(data.spatial_mode, 40).toLowerCase()
169
+ const relationshipTag = cleanMultiText(data.relationship_tag, 120)
170
+ if (!MULTI_SAFE_SPATIAL_MODES.has(spatialMode)) spatialMode = interactions.length ? 'shared_contact' : 'shared_scene'
171
+ let compositionSafe = composition
172
+ if (MULTI_UNSAFE_COMPOSITION_MARKERS.some(marker => compositionSafe.toLowerCase().includes(marker))) compositionSafe = ''
173
+ return {
174
+ count_tags: countTags.length ? countTags : [`${characters.length}people`],
175
+ common_tags: commonTags,
176
+ characters,
177
+ interactions,
178
+ composition: compositionSafe,
179
+ spatial_mode: spatialMode,
180
+ relationship_tag: relationshipTag,
181
+ }
182
+ }
183
+
184
+ function renderMultiPersonCharacter(character, opts = {}) {
185
+ const {
186
+ alias = '', resolvedIdentity = '', fixedTags = '', groupedContact = false,
187
+ explicitPositions = false, identityAnchors = [], includePose = true, asTagStream = false,
188
+ } = opts
189
+ let label = String(alias || character.visual_label || character.role || '').trim()
190
+ if (explicitPositions && character.slot) label = `${character.slot} ${label}`
191
+ const identity = String(resolvedIdentity || character.danbooru_candidate || '').trim()
192
+ const details = []
193
+ if (identity && !fixedTags) details.push(identity)
194
+ if (identityAnchors.length) {
195
+ details.push(...identityAnchors)
196
+ } else if (fixedTags) {
197
+ for (const part of fixedTags.split(',')) {
198
+ const t = part.trim().replace(/^ +| +$/g, '').replace(/^\(|\)$/g, '')
199
+ if (t) details.push(t)
200
+ }
201
+ }
202
+ if (!identityAnchors.length && !fixedTags && character.appearance) details.push(character.appearance)
203
+ if (character.clothing) details.push(character.clothing)
204
+ if (character.expression) details.push(character.expression)
205
+ if (includePose && character.pose) details.push(character.pose)
206
+ if (character.props && character.props.length) details.push(...character.props)
207
+ const joined = details.filter(Boolean).join(', ')
208
+ if (asTagStream) return [label, joined].filter(Boolean).join(', ')
209
+ return `${label}: ${joined}.`
210
+ }
211
+
212
+ // ------------------------------------------------------------------
213
+ // 多人尺寸自动选择(移植自 anima command_actions multi_person 分支)
214
+ // ------------------------------------------------------------------
215
+ function multiPersonAutoSize(prompt, allowedSizes) {
216
+ if (!Array.isArray(allowedSizes) || !allowedSizes.length) return null
217
+ const promptLower = String(prompt || '').toLowerCase()
218
+ // 修复:JS 中 \b 边界不识别 CJK(三/四 与两侧都非 \w,\b 永远不成立),
219
+ // 导致「三个人/四人」等中文人数无法触发宽屏尺寸。改用否定数字 lookbehind 匹配。
220
+ const threeOrMore = /(?<!\d)(?:三|四)\s*(?:人|个|名)|(?<!\d)(?:3|4)(?:\s*(?:人|个|名)|(?:girls?|boys?|people))\b/i.test(promptLower)
221
+ const verticallyStacked = [
222
+ '骑在肩', '骑肩', '肩膀上', '背着', '抱起', '扑倒', '压在', '上下叠',
223
+ 'on the shoulders', 'piggyback', 'carrying', 'on top of', 'stacked',
224
+ ].some(marker => promptLower.includes(marker))
225
+ const physicalContact = [
226
+ '牵手', '拥抱', '接吻', '搂着', '抱着', '挽着',
227
+ 'holding hands', 'hugging', 'embracing', 'kissing', 'arm around',
228
+ ].some(marker => promptLower.includes(marker))
229
+ const target = threeOrMore
230
+ ? [1216, 832]
231
+ : verticallyStacked
232
+ ? [1024, 1536]
233
+ : physicalContact
234
+ ? [1024, 1024]
235
+ : [1152, 896]
236
+ let best = allowedSizes[0]
237
+ let bestScore = Infinity
238
+ for (const size of allowedSizes) {
239
+ const ratioDiff = Math.abs(size[0] / size[1] - target[0] / target[1])
240
+ const areaDiff = Math.abs(size[0] * size[1] - target[0] * target[1])
241
+ const score = ratioDiff * 10000 + areaDiff
242
+ if (score < bestScore) {
243
+ bestScore = score
244
+ best = size
245
+ }
246
+ }
247
+ return best
248
+ }
249
+
250
+ module.exports = {
251
+ MULTI_PERSON_NEGATIVE_TAGS,
252
+ MULTI_SAFE_SLOTS,
253
+ MULTI_UNSAFE_COMPOSITION_MARKERS,
254
+ MULTI_SAFE_SPATIAL_MODES,
255
+ buildMultiPersonPlanPrompt,
256
+ cleanMultiText,
257
+ multiStringTuple,
258
+ normalizeMultiSlot,
259
+ parseMultiPersonPlan,
260
+ renderMultiPersonCharacter,
261
+ multiPersonAutoSize,
262
+ }