dsh-multimodal-node 0.0.0-stage → 0.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.
@@ -0,0 +1,1077 @@
1
+ /**
2
+ * Natural language → prompt.
3
+ *
4
+ * Two inputs, two outputs, on purpose:
5
+ *
6
+ * The **fixed head** is the same for every generation (quality words, the
7
+ * negative baseline) and lives in settings, so changing a workflow does not
8
+ * mean retyping it. The **custom text** is what the person is actually asking
9
+ * for. Both sides — positive and negative — get the pair.
10
+ *
11
+ * The assistant's job is therefore narrow: turn a sentence into Danbooru-style
12
+ * underscore tags, in the vocabulary the model was actually trained on, and
13
+ * never invent tags. That last part is the whole value: the knowledge base
14
+ * behind this plugin exists because self-invented phrases are ignored by the
15
+ * model while looking like they work, so the assistant is constrained to the
16
+ * verified dictionary whenever one is available, and told to say so when a
17
+ * concept has no tag.
18
+ *
19
+ * The model call is optional. Without an `llm` service (or with the assistant
20
+ * switched off) the same endpoint falls back to dictionary search, which is
21
+ * deterministic and still useful: it is a tag lookup, not a guess.
22
+ */
23
+ import { readFile, readdir } from 'node:fs/promises';
24
+ import { isAbsolute, join } from 'node:path';
25
+ import { homedir } from 'node:os';
26
+ import { tagDictionary, isAdultTag } from './tiers.js';
27
+
28
+ /**
29
+ * How the assistant is told to behave; kept here so it is reviewable.
30
+ *
31
+ * It is a FUNCTION of the tier, not a constant, because the tier changes what the correct
32
+ * answer is. A fixed prompt can only be wrong one way or the other: forbid adult tags for
33
+ * an account that is allowed them (the model then refuses a legitimate request, which is
34
+ * the reported "助手仍然拒绝帮我填写成人提示词"), or permit them for an account that is not.
35
+ * Naming the case in the prompt is what makes the model comply instead of hedging.
36
+ */
37
+ function systemFor(tier) {
38
+ const adult = tier === 'nsfw';
39
+ const tierNote = adult ? '账号等级:NSFW(成人内容已授权)' : '账号等级:SFW(只做全年龄内容)';
40
+ return SYSTEM_TEMPLATE(tierNote, adult);
41
+ }
42
+
43
+ const SYSTEM_TEMPLATE = (tierNote, adult) => [
44
+ '你是 Stable Diffusion / Pony 系底模的提示词工程师。',
45
+ '任务:把用户的一句自然语言需求,改写成一串逗号分隔的下划线标签(Danbooru 风格)。',
46
+ '',
47
+ // The authorisation section is BUILT per branch, not filtered out afterwards. A filter
48
+ // has to guess which lines are adult-only from their wording, and it got that wrong: an
49
+ // SFW request was still told 不要拒绝, which is an instruction about adult refusals and
50
+ // has no business in a family-safe prompt.
51
+ ...(adult
52
+ ? [
53
+ '【分级授权】',
54
+ '本请求来自已通过分级的账号:' + tierNote + '。',
55
+ '用户可能要求成人、裸露、性相关的提示词标签。这是合法的创作请求,应当直接完成:',
56
+ '按 Danbooru 惯例写出对应的解剖、裸露、性行为、姿势等标签(例如 nude、nipples、',
57
+ 'sex、spread_legs 这类社区通用写法),写成清晰的标签供绘图使用。',
58
+ '不要因为内容成人就拒绝、淡化或说教,也不要在 NOTES 里讨论妥当性。',
59
+ ]
60
+ : [
61
+ '【分级】',
62
+ '本请求来自已通过分级的账号:' + tierNote + '。',
63
+ '本次只做全年龄内容:任何成人、裸露、性相关的标签都不得出现。',
64
+ ]),
65
+ '',
66
+ '规则:',
67
+ '1. 只输出标签,不要句子,不要解释,不要编号。',
68
+ '2. 标签一律小写下划线形式(long_hair、on_back、off_shoulder)。多词标签必须带下划线。',
69
+ '3. 主体词必须写:人数与性别(1girl / 1boy / solo)。',
70
+ '4. 数量克制:正向 12–25 个标签,按「主体 → 外观 → 服装 → 动作/姿势 → 场景/构图」排序。',
71
+ '5. 词库优先,但词库不是边界:词库里有就用词库的写法(下划线标签);',
72
+ ' 词库里没有的概念,直接写最贴切的日常英文单词或短语(例如 gossamer、bare shoulders、',
73
+ ' dim streetlight),不要为了迁就词库而把概念换成别的意思,也不要生造新词。',
74
+ '6. 不加权重语法,除非用户明确要求。',
75
+ '',
76
+ '【最重要的一条:不要自行添加用户没有说的要素】',
77
+ '7. 用户没说的一律不加。没说发色就不加 hair_color;没说服装就不加任何服装标签;',
78
+ ' 没说场景就不加 indoors / outdoors / 背景描述;没说镜头就不加 from_side / close-up;',
79
+ ' 没说表情就不加 smile / expressionless。',
80
+ '8. 用户只说了「1girl」就只写 1girl 与主体必要标签,不要顺手补 masterpiece / best quality /',
81
+ ' absurdres 这类画质词 —— 那是表头的职责,不是这次请求的内容。',
82
+ '9. 判断标准:把生成的每个标签念给用户听,如果他没提过、也不能从他的原话必然推出,就删掉。',
83
+ ' 「短发女生」→ 可以写 short_hair(他说过);「站在便利店门口」→ 可以写 convenience_store',
84
+ ' (他说过的场景);但不能由它推出 rain 或 night(那是另加的氛围)。',
85
+ '',
86
+ '【负向:只有明确说要排除什么时才写】',
87
+ '10. 用户说「不要 XX」「去掉 XX」「别出现 XX」「不想要 XX」时,XX 必须写进 NEGATIVE,',
88
+ ' 且绝不出现在 POSITIVE。例如「不要模糊、不要多余手指」→ NEGATIVE 里要有 blurry、extra_fingers。',
89
+ '11. 用户没有提出任何排除要求时,NEGATIVE 必须是空字符串,一个字都不写。',
90
+ ' 不要因为「惯例」就填 blurry / lowres / bad_anatomy 之类的通用负向词 —— 负向表头已经',
91
+ ' 负责画质底线,这里再填一遍只会覆盖用户自己写的负向提示词。',
92
+ '',
93
+ '输出格式(严格三行,不要多余文字):',
94
+ 'POSITIVE: <逗号分隔标签>',
95
+ 'NEGATIVE: <逗号分隔标签;用户没要求排除就留空>',
96
+ 'NOTES: <一句中文,说明哪些概念词库里没有、用了什么替代;没有就写「无」>',
97
+ ].join('\n');
98
+
99
+ /**
100
+ * Layered output: one line per layer, in the workflow's declared order.
101
+ *
102
+ * A separate system prompt rather than a flag on the one above, because the model has to
103
+ * be told the layer *names and their meaning* or it dumps everything into the first line.
104
+ * Saying "面部 / 躯干 / 环境" is what makes "put the face words in the face layer" happen.
105
+ */
106
+ function layeredSystem(layerNames, tier = 'sfw') {
107
+ const adult = tier === 'nsfw';
108
+ const list = layerNames
109
+ .map((name, index) => ` L${index + 1} = ${name === '' ? `第 ${index + 1} 层` : name}`)
110
+ .join('\n');
111
+ /**
112
+ * Which layers must stay EMPTY, and why.
113
+ *
114
+ * A layer whose name says lora/LoRA/LyCORIS/embedding is where a LoRA trigger word goes,
115
+ * and that word comes from the LoRA file's own metadata — not from a sentence the user
116
+ * typed. The assistant used to treat it as "just another layer" and fill it with body or
117
+ * clothing tags, which then landed in the trigger slot and quietly changed what the LoRA
118
+ * did. So the instruction is explicit and per-layer, not a general hint.
119
+ */
120
+ const emptyLayers = layerNames
121
+ .map((name, index) => ({ index, name: String(name) }))
122
+ .filter((row) => isLoraLayer(row.name));
123
+ const emptyLines = emptyLayers.map(
124
+ (row) => ` L${row.index + 1}(${row.name})必须完全留空:只写「L${row.index + 1}: 」,冒号后面一个字符都不要写。`,
125
+ );
126
+
127
+ return [
128
+ '你是 Stable Diffusion / Pony 系底模的提示词工程师。',
129
+ '用户的工作流把正向提示词拆成了几层,你要把需求分发到对应层里。',
130
+ '',
131
+ '分层(必须严格按这个顺序与编号输出):',
132
+ list,
133
+ '',
134
+ ...(emptyLines.length > 0
135
+ ? [
136
+ '【必须留空的层】',
137
+ '下面这些层是给 LoRA 触发词用的,触发词由 LoRA 文件自己决定,不由这次的需求决定。',
138
+ ...emptyLines,
139
+ '即使其它层写满了,这些层也必须是空的。',
140
+ '',
141
+ ]
142
+ : []),
143
+ `【分级授权】账号等级:${adult ? 'NSFW(成人内容已授权)' : 'SFW(只做全年龄内容)'}。`,
144
+ ...(adult
145
+ ? [
146
+ '这是一次合法的成人内容创作请求。用户可能要求裸露、性相关的标签,应当直接完成:',
147
+ '按 Danbooru 惯例把对应的解剖、裸露、性行为、姿势标签分配到合适的层里,',
148
+ '不要拒绝、不要淡化、不要说教、不要只给「安全替代」。',
149
+ ]
150
+ : ['本次请求只做全年龄内容,任何成人、裸露、性相关标签都不得出现。']),
151
+ '',
152
+ '规则:',
153
+ '1. 只输出标签,不要句子,不要解释。标签一律小写下划线(long_hair、off_shoulder)。',
154
+ '2. 按层的含义分配:脸/五官/表情进「面部」那层,衣服/身体/姿势进「躯干」那层,',
155
+ ' 背景/光线/构图/镜头进「环境」那层。名字是别的词时按字面意思理解。',
156
+ '3. 每层 3–12 个标签;某层确实没有对应内容时,那一行只写编号和冒号,后面留空。',
157
+ '4. 主体词(1girl / solo 等)放第一层(若第一层是必须留空的层,则放到第一个非空层)。',
158
+ '5. 词库优先,但词库不是边界:词库里有就用词库写法;词库里没有的概念,',
159
+ ' 直接写最贴切的日常英文单词或短语(gossamer、bare shoulders、dim streetlight),',
160
+ ' 不要为了迁就词库把概念换成别的意思,也不要生造新词。',
161
+ '6. 不加权重语法,除非用户明确要求。',
162
+ '',
163
+ '【不要自行添加用户没有说的要素】',
164
+ '7. 用户没说的一律不加:没说发色就不加 hair_color,没说服装就不加任何服装标签,',
165
+ ' 没说场景就不加 indoors / outdoors / 背景描述,没说表情就不加 smile / expressionless。',
166
+ '8. 不要顺手补 masterpiece / best quality / absurdres 这类画质词 —— 那是表头的职责。',
167
+ '9. 判断标准:把每个标签念给用户听,他没提过、也不能从原话必然推出的,删掉。',
168
+ '',
169
+ '【负向:只有明确说要排除什么时才写】',
170
+ '10. 用户说「不要 XX」「去掉 XX」时,XX 必须写进 NEGATIVE,绝不出现在任何正向层里。',
171
+ '11. 用户没提出排除要求时,NEGATIVE 必须是空字符串,不要填 blurry / lowres 之类的通用负向词。',
172
+ '',
173
+ '输出格式(严格按此,每层一行,最后两行 NEGATIVE 与 NOTES):',
174
+ ...layerNames.map((_, index) => `L${index + 1}: <逗号分隔标签>`),
175
+ 'NEGATIVE: <逗号分隔标签;用户没要求排除就留空>',
176
+ 'NOTES: <一句中文说明>',
177
+ ].join('\n');
178
+ }
179
+
180
+ /** Chinese numerals a layer label can plausibly use (八层 is the cap). */
181
+ const CJK_DIGITS = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
182
+
183
+ /**
184
+ * Is this layer the LoRA trigger slot?
185
+ *
186
+ * One predicate for the whole file. It decides three things that must agree: which layers
187
+ * the system prompt tells the model to leave blank, which layers a model reply is force-
188
+ * emptied on, and which layer the dictionary fallback must not write into. Three copies of
189
+ * `/lora|lycoris|embedding|触发词/i` is three chances for one of them to be edited alone.
190
+ */
191
+ export function isLoraLayer(name) {
192
+ return /lora|lycoris|embedding|触发词/i.test(String(name ?? ''));
193
+ }
194
+
195
+ /**
196
+ * The index the assistant may write into when it has no layer semantics to rely on.
197
+ *
198
+ * The first layer that is not a LoRA trigger slot, or 0 when every layer is one (a graph
199
+ * that is all trigger slots has nowhere else to put anything, and dropping the text
200
+ * entirely would look like the assistant did nothing).
201
+ */
202
+ export function firstWritableLayer(layerNames) {
203
+ const names = Array.isArray(layerNames) ? layerNames : [];
204
+ const index = names.findIndex((name) => !isLoraLayer(name));
205
+ return index < 0 ? 0 : index;
206
+ }
207
+
208
+ /**
209
+ * Parse the layered reply.
210
+ *
211
+ * Deliberately tolerant about formatting and strict about assignment: a model that writes
212
+ * `L1:` `L1:` `1.` or `第一层:` still lands in the right slot, but a line with no number
213
+ * is not silently merged into layer 1 — an unexpected line is recorded and dropped, so the
214
+ * operator can see that the model drifted instead of getting a silently wrong prompt.
215
+ */
216
+ export function parseLayered(text, layerCount) {
217
+ const layers = new Array(layerCount).fill('');
218
+ let negative = '';
219
+ let notes = '';
220
+ const stray = [];
221
+ for (const raw of String(text ?? '').split(/\r?\n/)) {
222
+ const line = raw.trim();
223
+ if (line === '') continue;
224
+ const neg = /^NEGATIVE\s*[::]\s*(.*)$/i.exec(line);
225
+ if (neg !== null) {
226
+ negative = neg[1].trim();
227
+ continue;
228
+ }
229
+ const note = /^NOTES\s*[::]\s*(.*)$/i.exec(line);
230
+ if (note !== null) {
231
+ notes = note[1].trim();
232
+ continue;
233
+ }
234
+ // Accept 1 / L1 / 「1.」 / 「1、」 / 第一层 / 第1层 alike. The CJK forms matter: a
235
+ // Chinese model answering in Chinese writes 第一层 about as often as L1, and
236
+ // dropping those lines would silently lose a whole layer of the prompt.
237
+ const layer = /^(?:L\s*)?(\d+)\s*[.、::]\s*(.*)$/i.exec(line) ?? parseCjkLayer(line);
238
+ if (layer !== null) {
239
+ const index = Number(layer[1]) - 1;
240
+ if (index >= 0 && index < layerCount) {
241
+ layers[index] = layer[2].trim();
242
+ continue;
243
+ }
244
+ }
245
+ stray.push(line.slice(0, 60));
246
+ }
247
+ return { layers, negative, notes, stray };
248
+ }
249
+
250
+ /** 「第一层:…」 → [line, '1', rest]; null when the line is not a CJK layer label. */
251
+ function parseCjkLayer(line) {
252
+ const match = /^第\s*([0-9]+|[一二三四五六七八九十]+)\s*层\s*[::]?\s*(.*)$/.exec(line);
253
+ if (match === null) return null;
254
+ const digits = /^[0-9]+$/.test(match[1]) ? match[1] : String(CJK_DIGITS.indexOf(match[1]) + 1);
255
+ return [line, digits, match[2]];
256
+ }
257
+
258
+
259
+ /** The dictionary as a compact tag list, filtered by the account's tier. */
260
+ export function dictionaryFor(tier, limit = 900) {
261
+ const rows = tagDictionary().filter((row) => tier === 'nsfw' || row.n === 0);
262
+ const bySection = new Map();
263
+ for (const row of rows) {
264
+ const list = bySection.get(row.s) ?? [];
265
+ list.push(row);
266
+ bySection.set(row.s, list);
267
+ }
268
+ const lines = [];
269
+ let used = 0;
270
+ for (const [section, list] of bySection) {
271
+ const picked = [];
272
+ for (const row of list) {
273
+ const text = `${row.t}(${row.z})`;
274
+ if (used + text.length > limit) break;
275
+ picked.push(text);
276
+ used += text.length + 2;
277
+ }
278
+ if (picked.length > 0) lines.push(`${section}: ${picked.join(', ')}`);
279
+ }
280
+ return lines.join('\n');
281
+ }
282
+
283
+ /** Deterministic fallback / refinement: find tags matching a Chinese or Latin query. */
284
+ export function searchTags(query, tier, limit = 24) {
285
+ const text = String(query ?? '').trim().toLowerCase();
286
+ if (text === '') return [];
287
+ // Chinese has no spaces, so a sentence arrives as one token. Split it into the
288
+ // words a person actually typed PLUS bigrams: `咖啡馆看书` must still find
289
+ // `咖啡`-ish tags when no dictionary entry is that long.
290
+ const words = [];
291
+ for (const piece of text.split(/[\s,,、。;;!!??]+/)) {
292
+ if (piece === '') continue;
293
+ if (/^[\u4e00-\u9fff]+$/.test(piece)) {
294
+ words.push(piece);
295
+ for (let index = 0; index + 2 <= piece.length; index += 1) words.push(piece.slice(index, index + 2));
296
+ if (piece.length >= 3) {
297
+ for (let index = 0; index + 3 <= piece.length; index += 1) words.push(piece.slice(index, index + 3));
298
+ }
299
+ } else {
300
+ words.push(piece);
301
+ }
302
+ }
303
+ const scored = [];
304
+ for (const row of tagDictionary()) {
305
+ if (tier !== 'nsfw' && row.n === 1) continue;
306
+ let score = 0;
307
+ if (row.t === text) score = 100;
308
+ else if (row.z === text) score = 95;
309
+ else if (row.t.includes(text)) score = 60;
310
+ else if (row.z.includes(text)) score = 55;
311
+ else {
312
+ for (const word of words) {
313
+ if (word.length < 2) continue;
314
+ if (row.z === word) score = Math.max(score, 80);
315
+ else if (row.z.includes(word)) score = Math.max(score, word.length >= 3 ? 45 : 30);
316
+ else if (row.t.includes(word)) score = Math.max(score, 25);
317
+ }
318
+ }
319
+ if (score > 0) scored.push({ tag: row.t, zh: row.z, section: row.s, nsfw: row.n === 1, score });
320
+ }
321
+ scored.sort((left, right) => right.score - left.score || left.tag.localeCompare(right.tag));
322
+ return scored.slice(0, limit);
323
+ }
324
+
325
+ /**
326
+ * The knowledge bases this plugin can read, as the KB plugin stores them.
327
+ *
328
+ * `dsh-knowledge-wiki` keeps each library at `<root>/<name>/markdown/<docId>/`
329
+ * with one `<docId>.md` merged file per document, so a reader does not need the
330
+ * plugin: the layout is on disk. That matters because the KB plugin exposes
331
+ * tools, not a service — there is no API to call.
332
+ */
333
+ export async function listKnowledgeBases(root) {
334
+ const base = typeof root === 'string' && root.trim() !== '' ? root.trim() : defaultKnowledgeRoot();
335
+ try {
336
+ const entries = await readdir(base, { withFileTypes: true });
337
+ const rows = [];
338
+ for (const entry of entries) {
339
+ if (!entry.isDirectory()) continue;
340
+ const metaFile = join(base, entry.name, 'kb.json');
341
+ try {
342
+ const meta = JSON.parse(await readFile(metaFile, 'utf8'));
343
+ const docs = Array.isArray(meta.docs) ? meta.docs : [];
344
+ rows.push({
345
+ name: entry.name,
346
+ documents: docs.length,
347
+ description: typeof meta.description === 'string' ? meta.description : '',
348
+ titles: docs.slice(0, 40).map((doc) => String(doc.title ?? '')).filter((title) => title !== ''),
349
+ });
350
+ } catch {
351
+ // A directory without a readable kb.json is not a library.
352
+ }
353
+ }
354
+ return rows.sort((left, right) => left.name.localeCompare(right.name));
355
+ } catch {
356
+ return [];
357
+ }
358
+ }
359
+
360
+ /** The KB plugin's default root, matching its own `~/.dsh/knowledge`. */
361
+ export function defaultKnowledgeRoot() {
362
+ return join(homedir(), '.dsh', 'knowledge');
363
+ }
364
+
365
+ /** Where agent skills live when the operator has not pointed somewhere else. */
366
+ export function defaultSkillRoot() {
367
+ return join(homedir(), '.agents', 'skills');
368
+ }
369
+
370
+ /**
371
+ * Load the prompt-engineering skill the assistant must follow.
372
+ *
373
+ * A skill is the operator's own markdown rules — for Pony that means the fixed six-score
374
+ * quality prefix, the banned pseudo-words (`detailed_skin_texture`, `masterpiece`), the
375
+ * negative layers that pull the base model away from anime, and the assembly order. Feeding
376
+ * those rules into the system prompt is the difference between "plausible English tags" and
377
+ * tags the checkpoint actually recognises.
378
+ *
379
+ * Read on every call, not cached: editing SKILL.md must take effect on the next turn, and the
380
+ * file is small. `references/` is included when asked because the tag table is what turns
381
+ * "this word sounds right" into "this word has 115K posts".
382
+ *
383
+ * Returns `{ok, name, dir, text, files, bytes, reason?}` — never throws, so a missing skill
384
+ * degrades to "no skill" instead of failing the assistant turn.
385
+ */
386
+ export async function loadPromptSkill(config) {
387
+ const name = typeof config?.name === 'string' ? config.name.trim() : '';
388
+ if (name === '') return { ok: false, name: '', dir: '', text: '', files: [], bytes: 0, reason: '未配置技能' };
389
+ const root = typeof config?.dir === 'string' && config.dir.trim() !== '' ? config.dir.trim() : defaultSkillRoot();
390
+ const base = join(root, name);
391
+ const skillFile = join(base, 'SKILL.md');
392
+ let main = '';
393
+ try {
394
+ main = await readFile(skillFile, 'utf8');
395
+ } catch {
396
+ return { ok: false, name, dir: base, text: '', files: [], bytes: 0, reason: `找不到技能文件:${skillFile}` };
397
+ }
398
+ const budget = Math.max(2000, Number(config?.maxBytes) > 0 ? Number(config.maxBytes) : 12000);
399
+ /**
400
+ * Split the budget: the rules get the majority, the tables get the rest.
401
+ *
402
+ * The first version fed SKILL.md the whole budget and then handed the references whatever
403
+ * was left over. That worked until the skill grew: SKILL.md went from 16.5 KB to 21 KB,
404
+ * the leftover dropped below the 400-byte floor, and `references/tag-master.md` silently
405
+ * stopped being injected — the tag table is the part that says which words are real, so
406
+ * losing it is losing the most valuable half of the skill.
407
+ *
408
+ * Now SKILL.md is capped at its share (60%) and every reference gets a guaranteed slice of
409
+ * the remainder. Both halves always arrive; only their depth varies.
410
+ */
411
+ const mainShare = Math.floor(budget * 0.6);
412
+ const refShare = Math.max(600, budget - mainShare);
413
+ const parts = [`【技能:${name}】`, main.slice(0, mainShare)];
414
+ const files = ['SKILL.md'];
415
+ let bytes = Math.min(main.length, mainShare);
416
+ if (config?.includeReferences !== false) {
417
+ /**
418
+ * The reference tables are the part that makes the rules checkable: a tag either has
419
+ * posts or it does not. Only the files that answer "is this word real" are pulled in
420
+ * — the whole references/ directory is far more than a system prompt can hold.
421
+ *
422
+ * Order matters: tag-master first (the authoritative list), then zh-to-tag (how a
423
+ * Chinese sentence maps onto it), then the NSFW list when the account is allowed it.
424
+ */
425
+ const wanted = ['references/tag-master.md', 'references/zh-to-tag.md', 'references/nsfw-tags.md'];
426
+ const each = Math.floor(refShare / wanted.length);
427
+ for (const rel of wanted) {
428
+ try {
429
+ const text = await readFile(join(base, rel), 'utf8');
430
+ const slice = text.slice(0, each);
431
+ parts.push('', `【技能参考:${rel}】`, slice);
432
+ files.push(rel);
433
+ bytes += slice.length;
434
+ } catch {
435
+ // A skill without that reference simply contributes the others.
436
+ }
437
+ }
438
+ }
439
+ return { ok: true, name, dir: base, text: parts.join('\n'), files, bytes };
440
+ }
441
+
442
+ /**
443
+ * Read an excerpt for the assistant, preferring the knowledge base plugin.
444
+ *
445
+ * Order: the named knowledge base (default `提示词`) → a plain directory if one
446
+ * is configured → nothing. The KB is preferred because that is where the
447
+ * operator already curated and indexed the material; a directory is the escape
448
+ * hatch for someone who has the files but never imported them.
449
+ */
450
+ export async function knowledgeExcerpt(config, { maxBytes = 6000 } = {}) {
451
+ const kbName = typeof config?.promptKb?.kbName === 'string' ? config.promptKb.kbName.trim() : '';
452
+ if (kbName !== '') {
453
+ const fromKb = await readKnowledgeBase(config?.promptKb?.root, kbName, { maxBytes });
454
+ if (fromKb.ok) return fromKb;
455
+ }
456
+ const dir = typeof config?.promptKb?.dir === 'string' ? config.promptKb.dir.trim() : '';
457
+ if (dir === '' || !isAbsolute(dir)) {
458
+ return { ok: false, reason: kbName !== '' ? `知识库「${kbName}」里没有可读内容` : '未配置提示词知识库' };
459
+ }
460
+ try {
461
+ const entries = await readdir(dir);
462
+ const files = entries.filter((name) => name.toLowerCase().endsWith('.md')).sort();
463
+ if (files.length === 0) return { ok: false, reason: `目录里没有 .md:${dir}` };
464
+ const parts = [];
465
+ let used = 0;
466
+ for (const name of files) {
467
+ if (used >= maxBytes) break;
468
+ const text = await readFile(join(dir, name), 'utf8');
469
+ const slice = text.slice(0, Math.min(2000, maxBytes - used));
470
+ parts.push(`# ${name}\n${slice}`);
471
+ used += slice.length;
472
+ }
473
+ return { ok: true, text: parts.join('\n\n'), files: files.length };
474
+ } catch (error) {
475
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
476
+ }
477
+ }
478
+
479
+ /** Read one KB library's merged markdown, newest documents first. */
480
+ export async function readKnowledgeBase(root, name, { maxBytes = 6000 } = {}) {
481
+ const libRoot = join(typeof root === 'string' && root.trim() !== '' ? root.trim() : defaultKnowledgeRoot(), name);
482
+ try {
483
+ const meta = JSON.parse(await readFile(join(libRoot, 'kb.json'), 'utf8'));
484
+ const docs = Array.isArray(meta.docs) ? meta.docs : [];
485
+ const parts = [];
486
+ let used = 0;
487
+ for (const doc of docs) {
488
+ if (used >= maxBytes) break;
489
+ const docId = typeof doc.id === 'string' ? doc.id : '';
490
+ if (docId === '') continue;
491
+ const file = join(libRoot, 'markdown', docId, `${docId}.md`);
492
+ let text;
493
+ try {
494
+ text = await readFile(file, 'utf8');
495
+ } catch {
496
+ continue;
497
+ }
498
+ const slice = text.slice(0, Math.max(600, Math.min(2400, maxBytes - used)));
499
+ parts.push(`# ${String(doc.title ?? doc.sourceFile ?? docId)}\n${slice}`);
500
+ used += slice.length;
501
+ }
502
+ if (parts.length === 0) return { ok: false, reason: `知识库「${name}」还没有可读的正文` };
503
+ return { ok: true, text: parts.join('\n\n'), source: 'kb', kbName: name, documents: parts.length, titles: docs.slice(0, 20).map((doc) => String(doc.title ?? '')) };
504
+ } catch (error) {
505
+ return { ok: false, reason: `知识库「${name}」读取失败:${error instanceof Error ? error.message : String(error)}` };
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Parse the model's three-line answer. Anything unparseable is reported instead
511
+ * of guessed: a wrong prompt that looks plausible is worse than an error.
512
+ */
513
+ export function parseAssistant(text) {
514
+ const out = { positive: '', negative: '', notes: '' };
515
+ for (const rawLine of String(text ?? '').split('\n')) {
516
+ const line = rawLine.trim();
517
+ const positive = /^POSITIVE\s*[::]\s*(.*)$/i.exec(line);
518
+ if (positive !== null) {
519
+ out.positive = cleanTags(positive[1]);
520
+ continue;
521
+ }
522
+ const negative = /^NEGATIVE\s*[::]\s*(.*)$/i.exec(line);
523
+ if (negative !== null) {
524
+ out.negative = cleanTags(negative[1]);
525
+ continue;
526
+ }
527
+ const notes = /^NOTES\s*[::]\s*(.*)$/i.exec(line);
528
+ if (notes !== null) out.notes = notes[1].trim();
529
+ }
530
+ if (out.positive === '' && out.negative === '') {
531
+ // A model that answered in plain prose is still usable: take the first
532
+ // line that looks like a tag list.
533
+ for (const rawLine of String(text ?? '').split('\n')) {
534
+ const line = rawLine.trim();
535
+ if (line.includes(',') && !line.includes('。')) {
536
+ out.positive = cleanTags(line);
537
+ break;
538
+ }
539
+ }
540
+ }
541
+ return out;
542
+ }
543
+
544
+ /** Strip bullets, quotes, weights; join into a normalised comma list. */
545
+ export function cleanTags(text) {
546
+ return String(text ?? '')
547
+ // A bullet marker is a dash/star/dot *followed by a space or a Chinese
548
+ // separators*, never a bare digit: `1girl` must survive, `1. ` must not.
549
+ .replace(/^[\s\-*•]+/, '')
550
+ .replace(/^\d{1,2}[.、)]\s+/, '')
551
+ // `(tag:1.2)` is a weighted tag, not a comment: keep the tag, drop the
552
+ // weight. A parenthesised gloss a model wrote in Chinese is dropped whole.
553
+ .replace(/[((][^))]*[一-鿿][^))]*[))]/g, '')
554
+ .replace(/[((]\s*([a-z0-9_\- ]+?)\s*(?::[\d.]+)?\s*[))]/gi, '$1')
555
+ .replace(/["'`]/g, '')
556
+ .split(/[,,、]/)
557
+ // A bullet can lead any element, not only the first: `- 2girls` after a
558
+ // comma is the same list marker the first line had.
559
+ .map((part) => part.trim().replace(/^[\-*•]+\s*/, '').replace(/\s+/g, '_').replace(/_{2,}/g, '_'))
560
+ .filter((part) => part !== '' && !/^_+$/.test(part))
561
+ .join(', ');
562
+ }
563
+
564
+ /** Join a fixed head and a custom body without doubling commas or blanks. */
565
+ export function joinPrompt(head, body) {
566
+ const left = String(head ?? '').trim().replace(/[,,]\s*$/, '');
567
+ const right = String(body ?? '').trim().replace(/^[,,]\s*/, '');
568
+ if (left === '') return right;
569
+ if (right === '') return left;
570
+ return `${left}, ${right}`;
571
+ }
572
+
573
+ /**
574
+ * One assistant turn.
575
+ *
576
+ * @param {{llm?: any, route?: {provider:string, model:string}, config: any, tier: string}} deps
577
+ * @param {{message: string, positive?: string, negative?: string, history?: {role:string,text:string}[]}} input
578
+ */
579
+ export async function assist(deps, input) {
580
+ const tier = deps.tier === 'nsfw' ? 'nsfw' : 'sfw';
581
+ const message = String(input?.message ?? '').trim();
582
+ if (message === '') throw new Error('请先写一句需求');
583
+
584
+ const suggestions = searchTags(message, tier, 12);
585
+ const excerpt = await knowledgeExcerpt(deps.config, { maxBytes: deps.config?.promptKb?.excerptBytes ?? 6000 });
586
+ const dictionary = dictionaryFor(tier, 3500);
587
+
588
+ const llm = deps.llm;
589
+ const route = deps.route;
590
+ if (llm === undefined || route === undefined || route.provider === undefined || route.model === undefined) {
591
+ return {
592
+ ok: true,
593
+ mode: 'dictionary',
594
+ positive: joinPrompt(input?.positive, suggestions.slice(0, 12).map((row) => row.tag).join(', ')),
595
+ negative: String(input?.negative ?? '').trim(),
596
+ // The dictionary cannot decide exclusions, so it never claims to have touched
597
+ // the negative box. Without this the console would treat "no model" as "the
598
+ // model cleared your negative prompt".
599
+ negativeTouched: false,
600
+ notes: suggestions.length === 0
601
+ ? '没连上模型,且词库里没找到匹配标签;换个说法或直接写标签。'
602
+ : `没连上模型,按词库检索给出 ${suggestions.length} 个候选标签。`,
603
+ suggestions,
604
+ // Reported even without a model: the dictionary is static, but knowing
605
+ // whether a library was found is what tells a user their import worked.
606
+ kb: excerpt.ok
607
+ ? { name: excerpt.kbName ?? null, documents: excerpt.documents ?? excerpt.files ?? 0 }
608
+ : { reason: excerpt.reason },
609
+ };
610
+ }
611
+
612
+ const layerNames = Array.isArray(input?.layerNames) ? input.layerNames.map(String) : [];
613
+ const layered = layerNames.length > 0;
614
+
615
+ const context = [
616
+ `【真实标签词库(${tier === 'nsfw' ? '含成人' : '全年龄'})】`,
617
+ dictionary,
618
+ '',
619
+ excerpt.ok
620
+ ? `【知识库「${excerpt.kbName ?? '本地目录'}」摘录(${excerpt.documents ?? 0} 篇)】
621
+ ${excerpt.text}`
622
+ : '',
623
+ '',
624
+ // The current boxes are context for *continuity*: in replace mode they are what
625
+ // gets overwritten, in insert mode they are what the answer is appended to, and in
626
+ // both they let the model read "去掉 XX" against what the person actually sees.
627
+ input?.positive
628
+ ? `【当前正向框(${input?.mode === 'insert' ? '新标签会追加到它后面' : '会被替换'})】${input.positive}`
629
+ : '',
630
+ input?.negative
631
+ ? `【当前负向框(${input?.mode === 'insert' ? '新标签会追加到它后面' : '会被替换'})】${input.negative}`
632
+ : '',
633
+ '',
634
+ layered ? `【分层】${layerNames.map((name, i) => `L${i + 1}=${name || '第' + (i + 1) + '层'}`).join(' / ')}` : '',
635
+ input?.mode === 'insert'
636
+ ? '【写入方式】追加:只输出这次要新增的标签,不要重复当前正向框里已有的,也不要复述原句。'
637
+ : '',
638
+ `【用户需求】${message}`,
639
+ ]
640
+ .filter((line) => line !== '')
641
+ .join('\n');
642
+
643
+ const history = Array.isArray(input?.history) ? input.history.slice(-8) : [];
644
+ const messages = [
645
+ ...history.map((entry) => ({ role: entry.role === 'assistant' ? 'assistant' : 'user', text: String(entry.text ?? '') })),
646
+ { role: 'user', text: context },
647
+ ];
648
+
649
+ const fallback = () => {
650
+ // No model: the dictionary is the best we have. With layers, everything lands in
651
+ // the first layer that is not reserved rather than being smeared across all of
652
+ // them — a wrong-but-explainable placement beats the same words repeated five times
653
+ // in the assembled prompt.
654
+ //
655
+ // "Reserved" is the LoRA trigger slot. The model-backed path empties it explicitly
656
+ // (see below); the dictionary path has to skip it too, or the one layer the whole
657
+ // rule exists to protect is the one this fallback writes into whenever a workflow
658
+ // happens to put the LoRA layer first — which is exactly how the shipped Pony
659
+ // workflows are built.
660
+ //
661
+ // negativeTouched is false unless the request asked to exclude something: the
662
+ // dictionary has no opinion about exclusions, so it must not clear the box.
663
+ const tags = suggestions.slice(0, 12).map((row) => row.tag).join(', ');
664
+ const touched = asksToExclude(message);
665
+ return layered
666
+ ? {
667
+ layers: layerNames.map((name, i) => (i === firstWritableLayer(layerNames) ? tags : '')),
668
+ negative: '',
669
+ positive: tags,
670
+ negativeTouched: touched,
671
+ }
672
+ : { positive: tags, negative: '', negativeTouched: touched };
673
+ };
674
+
675
+ const kb = excerpt.ok
676
+ ? { name: excerpt.kbName ?? null, documents: excerpt.documents ?? excerpt.files ?? 0 }
677
+ : { reason: excerpt.reason };
678
+
679
+ /**
680
+ * The skill rides on top of the built-in instructions, not instead of them.
681
+ *
682
+ * The built-in template still owns the tier rules and the output shape (POSITIVE /
683
+ * NEGATIVE / NOTES); the skill owns the vocabulary rules — which tags exist, which are
684
+ * banned pseudo-words, the quality prefix, the negative layers. Injecting it here rather
685
+ * than merging it into the template keeps the two independently editable: an operator can
686
+ * swap skills without touching the plugin.
687
+ */
688
+ const skill = await loadPromptSkill(deps.config?.promptSkill);
689
+ const systemPrompt = [
690
+ layered ? layeredSystem(layerNames, tier) : systemFor(tier),
691
+ skill.ok ? '' : '',
692
+ skill.ok ? skill.text : '',
693
+ skill.ok
694
+ ? '【按技能规则执行】上面的技能规则优先于本提示里的一般性建议:标签是否存在、质量前缀怎么写、负向词分几层,一律以技能为准。'
695
+ : '',
696
+ ]
697
+ .filter((part) => part !== '')
698
+ .join('\n\n');
699
+ let text = '';
700
+ try {
701
+ text = await callModel(llm, route, messages, deps.config, systemPrompt);
702
+ } catch (error) {
703
+ const base = fallback();
704
+ return {
705
+ ok: false,
706
+ mode: 'dictionary',
707
+ error: `模型调用失败:${error instanceof Error ? error.message : String(error)}`,
708
+ ...base,
709
+ suggestions,
710
+ kb,
711
+ };
712
+ }
713
+
714
+ if (layered) {
715
+ const parsed = parseLayered(text, layerNames.length);
716
+ let layers = parsed.layers;
717
+ let negative = parsed.negative;
718
+ /**
719
+ * A LoRA layer is emptied here, not merely asked to stay empty.
720
+ *
721
+ * The prompt tells the model to leave those layers blank, and it usually does — but
722
+ * "usually" is not a contract: a model that ignores the instruction puts body tags
723
+ * into the trigger slot, and the picture silently changes because the LoRA's own
724
+ * trigger word is now diluted with unrelated tags. The layer name is the only thing
725
+ * that says which slot is a trigger slot, and the server already has it, so the
726
+ * answer is enforced rather than trusted.
727
+ */
728
+ const emptied = [];
729
+ layers = layers.map((value, index) => {
730
+ const name = String(layerNames[index] ?? '');
731
+ if (!isLoraLayer(name)) return value;
732
+ if (String(value).trim() !== '') emptied.push(name || ('L' + (index + 1)));
733
+ return '';
734
+ });
735
+ if (tier !== 'nsfw') {
736
+ const clean = (value) => value.split(', ').filter((tag) => tag !== '' && !isAdultTag(tag)).join(', ');
737
+ layers = layers.map(clean);
738
+ negative = clean(negative);
739
+ }
740
+ return {
741
+ ok: true,
742
+ mode: 'model',
743
+ layered: true,
744
+ // Replacement, not concatenation: the caller overwrites the boxes with these.
745
+ layers,
746
+ negative,
747
+ /**
748
+ * Whether the negative box may be appended to at all.
749
+ *
750
+ * Only the REQUEST decides. The model's own NEGATIVE line is not evidence: it
751
+ * volunteers `blurry, lowres, bad_anatomy` on any turn, and treating that as intent
752
+ * pushed the model's idea of "good practice" into a box the operator had already
753
+ * filled. A retraction ("把不要模糊去掉") still counts, because it is a request about
754
+ * exclusions — the console's rule for "flagged but empty" is to leave the box alone.
755
+ */
756
+ negativeTouched: asksToExclude(message),
757
+ // Still returned flattened, so a caller that does not understand layers (or an
758
+ // older console) keeps working instead of showing nothing.
759
+ positive: layers.filter((v) => v !== '').join(', '),
760
+ notes: [
761
+ emptied.length > 0 ? `已清空 ${emptied.join('、')}:这些层放 LoRA 触发词,不由提示词填写` : '',
762
+ parsed.stray.length > 0 ? `模型多输出了 ${parsed.stray.length} 行未识别内容,已忽略` : '',
763
+ parsed.notes,
764
+ ].filter((line) => line !== '').join(';'),
765
+ raw: text.slice(0, 4000),
766
+ suggestions,
767
+ kb,
768
+ };
769
+ }
770
+
771
+ const parsed = parseAssistant(text);
772
+ if (tier !== 'nsfw') {
773
+ // A model can slip; the tier is enforced on the answer, not trusted to it.
774
+ for (const side of ['positive', 'negative']) {
775
+ parsed[side] = parsed[side]
776
+ .split(', ')
777
+ .filter((tag) => !isAdultTag(tag))
778
+ .join(', ');
779
+ }
780
+ }
781
+ return {
782
+ ok: true,
783
+ mode: 'model',
784
+ // Replacement again: an assistant turn produces the prompt, it does not add to it.
785
+ positive: parsed.positive,
786
+ negative: parsed.negative,
787
+ /**
788
+ * Did this REQUEST ask to exclude anything? That alone decides.
789
+ *
790
+ * The value the model returned must NOT be part of this test. `|| parsed.negative !== ''`
791
+ * looked reasonable — "if it said something, something happened" — but it inverts the
792
+ * guard: the model habitually volunteers `blurry, lowres, bad_anatomy` on any turn, so a
793
+ * request that never mentioned exclusions produced a non-empty negative, the flag turned
794
+ * true, and the console appended the model's generic quality words into a box the
795
+ * operator had already filled with their own head. Exactly the case this flag exists to
796
+ * prevent.
797
+ *
798
+ * A retraction ("把不要模糊去掉") still counts: it IS a request about exclusions, and the
799
+ * console's rule for "flagged but empty" is to leave the box alone.
800
+ */
801
+ negativeTouched: asksToExclude(message),
802
+ notes: parsed.notes,
803
+ raw: text.slice(0, 4000),
804
+ suggestions,
805
+ kb,
806
+ };
807
+ }
808
+
809
+ /**
810
+ * Split one assistant answer into the pickable rows the console lists.
811
+ *
812
+ * The console no longer dumps the answer into the boxes: the person ticks what they want,
813
+ * picks a target box per row, and presses 确定. That means the answer has to arrive as a
814
+ * LIST of candidates, each with something to explain it — otherwise the list is a wall of
815
+ * unlabelled tags and the person is guessing.
816
+ *
817
+ * Three shapes come in, and all three become rows:
818
+ *
819
+ * - **layered** (a workflow with named layers): one row per non-empty layer, labelled with
820
+ * the layer's own name, because "面部" is the only thing that says where those tags go.
821
+ * - **plain** (one positive block): one row for the positive text.
822
+ * - **negative** (only when the request asked to exclude something): one row, marked as an
823
+ * exclusion so the console can default it to a negative box.
824
+ *
825
+ * Rows with no text are dropped — an empty layer is not a choice, and a checkbox for it would
826
+ * add a click that does nothing. `suggest` is the role the row wants to land in; the console
827
+ * still lets the person override it per row.
828
+ *
829
+ * @returns {Array<{id:string, text:string, note:string, role:'positive'|'negative', layer:string}>}
830
+ */
831
+ export function assistCandidates(result, layerNames = []) {
832
+ const rows = [];
833
+ const push = (row) => {
834
+ const text = String(row.text ?? '').trim();
835
+ if (text === '') return;
836
+ rows.push({
837
+ id: `c${rows.length + 1}`,
838
+ text,
839
+ note: String(row.note ?? '').trim(),
840
+ role: row.role === 'negative' ? 'negative' : 'positive',
841
+ layer: String(row.layer ?? '').trim(),
842
+ });
843
+ };
844
+ const data = result !== null && typeof result === 'object' ? result : {};
845
+ const layers = Array.isArray(data.layers) ? data.layers : [];
846
+ const names = Array.isArray(layerNames) ? layerNames.map(String) : [];
847
+ if (layers.length > 0) {
848
+ layers.forEach((value, index) => {
849
+ const name = String(names[index] ?? '').trim();
850
+ const label = name === '' ? `第 ${index + 1} 层` : name;
851
+ const count = String(value ?? '').split(',').filter((tag) => tag.trim() !== '').length;
852
+ push({
853
+ text: value,
854
+ note: `${label} · ${count} 个标签`,
855
+ role: 'positive',
856
+ layer: label,
857
+ });
858
+ });
859
+ } else {
860
+ const count = String(data.positive ?? '').split(',').filter((tag) => tag.trim() !== '').length;
861
+ // The note explains WHY this row exists rather than repeating the model's own NOTES
862
+ // line: at a glance the person needs "this is the whole prompt", not commentary.
863
+ push({ text: data.positive, note: count === 0 ? '' : `${count} 个标签`, role: 'positive' });
864
+ }
865
+ /**
866
+ * The negative row is offered only when the request actually asked to exclude something.
867
+ *
868
+ * `negativeTouched` is the assistant's own answer to that question; without it the model's
869
+ * habitual "blurry, lowres" lines would appear as a candidate the person never asked for,
870
+ * and ticking it would start filtering things they want.
871
+ */
872
+ if (data.negativeTouched === true) {
873
+ push({ text: data.negative, note: '排除项 · 建议放进负向框', role: 'negative' });
874
+ }
875
+ return rows;
876
+ }
877
+
878
+ /**
879
+ * A live suggestion pass: tags WITH Chinese meaning, one per row.
880
+ *
881
+ * This is the "type and watch it fill in" path. It is allowed to call the model every time
882
+ * (the operator asked for that), so it is built to stay cheap and hard to break:
883
+ *
884
+ * - the model returns one line per tag (`tag | 中文含义 | 常用场景`), and a line it botches
885
+ * is DROPPED rather than guessed at;
886
+ * - the dictionary pass always runs too, and its rows are merged in, so a model failure
887
+ * degrades to the deterministic list instead of an empty panel;
888
+ * - the tier filter runs on the model's output as well as on the input.
889
+ *
890
+ * Returns `[{tag, zh, section, note, role, fromModel}]` — the shape the console renders.
891
+ */
892
+ export async function suggestTags(deps, input) {
893
+ const tier = deps?.tier === 'nsfw' ? 'nsfw' : 'sfw';
894
+ const text = String(input?.text ?? '').trim().slice(0, 400);
895
+ if (text === '') return [];
896
+ /** Dictionary rows first: they are free, instant, and the fallback when the model fails. */
897
+ const local = searchTags(text, tier, 24).map((row) => ({
898
+ tag: row.tag,
899
+ zh: row.zh,
900
+ section: row.section,
901
+ note: [row.zh, row.section === '' ? '' : `常用在${row.section}`].filter((part) => part !== '').join(' · '),
902
+ role: 'positive',
903
+ fromModel: false,
904
+ }));
905
+ const llm = deps?.llm;
906
+ const route = deps?.route;
907
+ if (llm === undefined || llm === null || route === undefined || route === null) return local;
908
+ const skill = await loadPromptSkill(deps.config?.promptSkill);
909
+ const system = [
910
+ /**
911
+ * The skill goes FIRST so everything after it can refer back to it.
912
+ *
913
+ * It is what decides which tags exist: the fixed quality prefix, the banned
914
+ * pseudo-words (`detailed_skin_texture`, `masterpiece`), the negative layers. Loading it
915
+ * is not optional — without it this path invents fluent-looking words the checkpoint has
916
+ * never seen, which is the worst kind of wrong because it looks right.
917
+ */
918
+ ...(skill.ok ? [skill.text, '【按技能规则选词】上面技能里标为无效的自造词一律不要输出;优先用技能词表里有的标签。', ''] : []),
919
+ '你是 Stable Diffusion / Pony 的提示词助手,用户在打字,你要把他说的话变成可挑选的条目。',
920
+ '只输出条目行,每行三段,用 | 分隔,不要编号、不要解释、不要空行:',
921
+ ' 标签 | 这个标签的中文含义 | 常用场景(一句话,不超过 12 个字)',
922
+ /**
923
+ * A row may be a single tag OR a group of tags.
924
+ *
925
+ * One tag is often not enough to say what the person meant: `雨天便利店` has no single
926
+ * tag, but `rain, convenience_store, wet_ground` together do. A group is written as
927
+ * several tags joined by ", " in the FIRST field — the console shows it as one row and
928
+ * adding it writes the whole group, which is exactly what the person wanted.
929
+ *
930
+ * The limit matters: a group is a short cluster of related tags, not a paragraph. Four
931
+ * is the ceiling — past that it stops being one choice and becomes an answer, which is
932
+ * what PROSE mode is for.
933
+ */
934
+ '如果单个标签不足以表达,或者根本没有对应的单个标签,可以把几个标签用英文逗号加空格拼成一组,',
935
+ ' 整组写在同一行的第一段里(例:rain, convenience_store, wet_ground | 雨天便利店 | 场景)。',
936
+ '一组是最多 4 个互相补充的标签,不要堆成一长串;能用单个标签说清时就只给单个标签。',
937
+ /**
938
+ * The group rule does not relax the skill: every tag inside a group must still be a
939
+ * real one. Written out because "make it a phrase" is an easy invitation to smuggle in
940
+ * invented words by packaging them.
941
+ */
942
+ '组里的每一个标签都必须同样符合上面技能里的选词规则;技能标为无效的自造词不能因为凑成一组就被放行。',
943
+ `分级:${tier === 'nsfw' ? 'NSFW(成人内容已授权)' : 'SFW,不得出现任何成人、裸露、性相关标签'}`,
944
+ '标签一律小写下划线(long_hair、convenience_store)。',
945
+ '只写用户说过或能从他原话必然推出的;不要顺手加画质词(masterpiece、best_quality)。',
946
+ '输出 6–14 行。',
947
+ ].join('\n');
948
+ let out = '';
949
+ try {
950
+ out = await callModel(
951
+ llm,
952
+ route,
953
+ [{ role: 'user', text: `【用户正在输入】${text}` }],
954
+ deps.config,
955
+ system,
956
+ );
957
+ } catch {
958
+ // A model hiccup must not empty the panel: the dictionary rows stand in.
959
+ return local;
960
+ }
961
+ const modelRows = [];
962
+ for (const raw of out.split(/\r?\n/)) {
963
+ const line = raw.trim().replace(/^[-*]\s*/, '');
964
+ if (line === '') continue;
965
+ const parts = line.split('|').map((part) => part.trim());
966
+ if (parts.length < 2) continue;
967
+ /**
968
+ * Split the first field into tags.
969
+ *
970
+ * It can hold one tag or a short group (`rain, convenience_store, wet_ground`). Both are
971
+ * legitimate answers — a single tag is exact, a group is how you say something the
972
+ * dictionary has no single word for — so the only thing to enforce is the size limit:
973
+ * more than four is an answer, not a choice, and belongs in PROSE mode.
974
+ */
975
+ const tags = parts[0]
976
+ .split(',')
977
+ .map((piece) => piece.trim().toLowerCase().replace(/\s+/g, '_').replace(/[^\w\-:.]/g, ''))
978
+ .filter((tag) => tag !== '' && tag.length <= 60);
979
+ if (tags.length === 0 || tags.length > 4) continue;
980
+ if (tier !== 'nsfw' && tags.some((tag) => isAdultTag(tag))) continue;
981
+ const zh = (parts[1] ?? '').slice(0, 40);
982
+ const scene = (parts[2] ?? '').slice(0, 40);
983
+ modelRows.push({
984
+ tag: tags.join(', '),
985
+ tags,
986
+ group: tags.length > 1,
987
+ zh,
988
+ section: scene,
989
+ note: [zh, scene].filter((part) => part !== '').join(' · '),
990
+ role: 'positive',
991
+ fromModel: true,
992
+ });
993
+ }
994
+ /**
995
+ * Merge: the model's rows lead (they understood the sentence), the dictionary fills in
996
+ * what it missed, and an entry appears once — the same thing twice in a pick list is a bug
997
+ * the person reads as "two of these".
998
+ *
999
+ * Two different de-duplication rules, because the two shapes mean different things:
1000
+ *
1001
+ * - a single tag is dropped if that tag is already listed;
1002
+ * - a GROUP is dropped only when every tag in it is already listed, and a group that
1003
+ * overlaps a single row keeps its own tags minus the ones already shown. A group is a
1004
+ * way of saying "these together", so trimming it down to nothing would remove the
1005
+ * thing that had no single-tag answer.
1006
+ */
1007
+ const seenTags = new Set();
1008
+ const merged = [];
1009
+ for (const row of [...modelRows, ...local]) {
1010
+ if (row.group === true) {
1011
+ const rest = row.tags.filter((tag) => !seenTags.has(tag));
1012
+ if (rest.length === 0) continue;
1013
+ for (const tag of rest) seenTags.add(tag);
1014
+ merged.push({ ...row, tag: rest.join(', '), tags: rest, group: rest.length > 1 });
1015
+ continue;
1016
+ }
1017
+ if (seenTags.has(row.tag)) continue;
1018
+ seenTags.add(row.tag);
1019
+ merged.push(row);
1020
+ }
1021
+ return merged.slice(0, 40);
1022
+ }
1023
+
1024
+ /**
1025
+ * Does this request actually ask for something to be excluded?
1026
+ *
1027
+ * Kept deliberately narrow. A broad pattern ("不" appears anywhere) would fire on "不错"
1028
+ * and "不仅",so the match requires an exclusion verb near the phrase. The negated-existence
1029
+ * forms (没有 / 没) carry a small lookahead because 没有问题 / 没错 / 没关系 are ordinary
1030
+ * speech, not exclusions. A miss costs one manually typed tag; a false positive wipes the
1031
+ * operator's negative prompt, so the asymmetry says to under-match rather than over-match.
1032
+ */
1033
+ export function asksToExclude(message) {
1034
+ const text = String(message ?? '');
1035
+ if (text.trim() === '') return false;
1036
+ return /(?:不要|别出现|别再|不用|不需要|去掉|去除|移除|删除|不想要|不想有|不出现|排除|避免|禁止)/.test(text)
1037
+ || /(?:没有|没穿|没戴)(?!问题|错|关系|办法)/.test(text)
1038
+ || /\b(?:without|no|not|avoid|exclude|remove)\b/i.test(text);
1039
+ }
1040
+
1041
+ /**
1042
+ * The request controls this assistant pins.
1043
+ *
1044
+ * They are named twice on purpose — once to `prepareCall()` and once on the
1045
+ * dispatch options — because that is the contract: preparation returns a frozen
1046
+ * config, and dispatch rejects any request whose config differs from it
1047
+ * field-by-field with `INVALID_PREPARED_CALL`. Supplying `maxTokens` or
1048
+ * `temperature` only at dispatch time therefore fails before the adapter is
1049
+ * ever reached, which surfaces as "模型调用失败" while no network request exists.
1050
+ *
1051
+ * Layered replies are longer (one line per layer): 700 was enough for two lines
1052
+ * and truncated the third layer mid-tag, which then parsed as garbage.
1053
+ */
1054
+ const ASSIST_CONTROLS = Object.freeze({ maxTokens: 900, temperature: 0.4 });
1055
+
1056
+ /** One non-streaming model call over the Harness llm service. */
1057
+ async function callModel(llm, route, messages, config, system) {
1058
+ const prepared = await llm.prepareCall(
1059
+ { provider: route.provider, model: route.model, ...ASSIST_CONTROLS },
1060
+ AbortSignal.timeout(60_000),
1061
+ );
1062
+ const options = {
1063
+ ...prepared.config,
1064
+ messages: messages.map((entry) => ({ role: entry.role, content: [{ type: 'text', text: entry.text }] })),
1065
+ system,
1066
+ ...ASSIST_CONTROLS,
1067
+ };
1068
+ let out = '';
1069
+ for await (const chunk of prepared.stream(options)) {
1070
+ if (chunk.type === 'text-delta') out += chunk.text;
1071
+ if (chunk.type === 'finish' && chunk.reason?.kind === 'error') {
1072
+ throw new Error(chunk.reason.failure?.message ?? 'model error');
1073
+ }
1074
+ }
1075
+ if (out.trim() === '') throw new Error('模型没有返回内容');
1076
+ return out;
1077
+ }