@xiaobuyu/nodesign 0.0.1 → 0.0.2
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/README.md +156 -106
- package/bin/nodesign.js +0 -0
- package/package.json +1 -1
- package/server/api/README.md +45 -45
- package/server/api/_guard.js +75 -75
- package/server/api/admin.js +187 -187
- package/server/api/assets/docx-page.js +103 -103
- package/server/api/assets/helpers.js +22 -22
- package/server/api/assets/notes.js +144 -144
- package/server/api/assets.js +896 -896
- package/server/api/board.js +57 -57
- package/server/api/browse.js +138 -138
- package/server/api/canvas.js +444 -444
- package/server/api/chatai.js +324 -324
- package/server/api/exports/build-standalone.js +980 -980
- package/server/api/exports/cards.js +118 -118
- package/server/api/exports/docx-pdf.js +46 -46
- package/server/api/exports/export-page.js +142 -142
- package/server/api/exports/handoff.js +147 -147
- package/server/api/exports.js +690 -690
- package/server/api/instruction.js +75 -75
- package/server/api/me.js +116 -116
- package/server/api/memory.js +185 -185
- package/server/api/pending-changes.js +366 -366
- package/server/api/plugins.js +164 -164
- package/server/api/projects.js +220 -220
- package/server/api/publish.js +64 -64
- package/server/api/recent.js +73 -73
- package/server/api/sessions.js +589 -589
- package/server/api/skills.js +77 -77
- package/server/api/standalone-fit.js +168 -168
- package/server/api/turn-compose.js +180 -180
- package/server/api/turn-inflight.js +44 -44
- package/server/auth/README.md +17 -17
- package/server/auth/middleware.js +138 -138
- package/server/auth/origin-guard.js +71 -71
- package/server/auth/session.js +108 -108
- package/server/auth/tier.js +113 -113
- package/server/auth/users-store.js +348 -348
- package/server/edit/README.md +29 -29
- package/server/engine/README.md +113 -113
- package/server/engine/agent/agent-shared.js +550 -550
- package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -4
- package/server/engine/agent/auto-mode-rules.js +78 -78
- package/server/engine/agent/context.js +418 -418
- package/server/engine/agent/events.js +464 -464
- package/server/engine/agent/hooks/canvas-validate.js +199 -199
- package/server/engine/agent/hooks/failure.js +120 -120
- package/server/engine/agent/hooks/file-events.js +75 -75
- package/server/engine/agent/hooks/lifecycle.js +233 -233
- package/server/engine/agent/hooks/post-canvas-focus.js +80 -80
- package/server/engine/agent/hooks/post-guidance.js +206 -206
- package/server/engine/agent/hooks/post-subagent-report.js +104 -104
- package/server/engine/agent/hooks/post-trim.js +58 -58
- package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -34
- package/server/engine/agent/hooks/pre-defaults.js +84 -84
- package/server/engine/agent/hooks/pre-injectors.js +277 -277
- package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -78
- package/server/engine/agent/hooks/pre-starter-files.js +61 -61
- package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -78
- package/server/engine/agent/hooks/site-validate.js +94 -94
- package/server/engine/agent/hooks/tool-prompts.js +31 -31
- package/server/engine/agent/hooks/turn-state-memory.js +56 -56
- package/server/engine/agent/hooks/user-prompt-submit.js +215 -215
- package/server/engine/agent/hooks.js +354 -354
- package/server/engine/agent/init-contract.js +69 -69
- package/server/engine/agent/isolation.js +159 -159
- package/server/engine/agent/model-table.js +292 -292
- package/server/engine/agent/plugin-loader.js +266 -266
- package/server/engine/agent/prompts/nodesign-prelude.md +336 -336
- package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -71
- package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -98
- package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -583
- package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -483
- package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -100
- package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -270
- package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -70
- package/server/engine/agent/prompts/tools/site-reference.md +106 -106
- package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -125
- package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -48
- package/server/engine/agent/session-loop.js +1024 -1024
- package/server/engine/agent/session-model.js +164 -164
- package/server/engine/agent/skill.js +204 -204
- package/server/engine/agent/task-events.js +78 -78
- package/server/engine/agents/ds-extractor.md +132 -132
- package/server/engine/agents/explorer.md +190 -190
- package/server/engine/agents/index.js +230 -230
- package/server/engine/agents/schemas/design-system.json +156 -156
- package/server/engine/agents/schemas/tweak-schema.json +113 -113
- package/server/engine/agents/tweak-proposer.md +127 -127
- package/server/engine/agents/vision-checker.md +254 -254
- package/server/engine/browse/capture.js +422 -422
- package/server/engine/browse/card.js +137 -137
- package/server/engine/browse/handover.js +59 -59
- package/server/engine/browse/page-digest.js +119 -119
- package/server/engine/browse/refs.js +196 -196
- package/server/engine/browse/registry.js +307 -307
- package/server/engine/browse/screencast.js +170 -170
- package/server/engine/browse/state.js +121 -121
- package/server/engine/chatai/chat-log.js +79 -79
- package/server/engine/chatai/index.js +193 -193
- package/server/engine/chatai/openai-compat.js +152 -152
- package/server/engine/chatai/orchestrate.js +309 -309
- package/server/engine/chatai/perform.js +40 -40
- package/server/engine/chatai/summarize.js +79 -79
- package/server/engine/mcp/capability-gate.js +52 -52
- package/server/engine/mcp/index.js +339 -339
- package/server/engine/mcp/param-sanitizer.js +142 -142
- package/server/engine/mcp/tools/arrange-on-board.js +124 -124
- package/server/engine/mcp/tools/artifact-session.js +278 -278
- package/server/engine/mcp/tools/browse-computer.js +421 -421
- package/server/engine/mcp/tools/browse-find-batch.js +183 -183
- package/server/engine/mcp/tools/browse-screenshot.js +157 -157
- package/server/engine/mcp/tools/browse.js +480 -480
- package/server/engine/mcp/tools/build-docx.js +101 -101
- package/server/engine/mcp/tools/clear-pending-changes.js +99 -99
- package/server/engine/mcp/tools/create-on-board.js +155 -155
- package/server/engine/mcp/tools/crystallize-skill.js +168 -168
- package/server/engine/mcp/tools/explain-style.js +237 -237
- package/server/engine/mcp/tools/export-handoff.js +133 -133
- package/server/engine/mcp/tools/expose-tweaks.js +149 -149
- package/server/engine/mcp/tools/generate-image.js +842 -842
- package/server/engine/mcp/tools/get-computed-styles.js +164 -164
- package/server/engine/mcp/tools/get-pending-changes.js +205 -205
- package/server/engine/mcp/tools/h3box-ssh.js +84 -84
- package/server/engine/mcp/tools/helpers/acquire-page.js +82 -82
- package/server/engine/mcp/tools/helpers/motion-lab.js +554 -554
- package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -112
- package/server/engine/mcp/tools/helpers/perception-page.js +215 -215
- package/server/engine/mcp/tools/helpers/reference-download.js +103 -103
- package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -80
- package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -280
- package/server/engine/mcp/tools/highlight.js +68 -68
- package/server/engine/mcp/tools/list-pages.js +222 -222
- package/server/engine/mcp/tools/lookup-tags.js +59 -59
- package/server/engine/mcp/tools/navigate-to-page.js +60 -60
- package/server/engine/mcp/tools/organize-board.js +64 -64
- package/server/engine/mcp/tools/paint-still.js +434 -434
- package/server/engine/mcp/tools/pin-to-board.js +130 -130
- package/server/engine/mcp/tools/preview-deck.js +130 -130
- package/server/engine/mcp/tools/profile-scroll.js +305 -305
- package/server/engine/mcp/tools/publish-site.js +102 -102
- package/server/engine/mcp/tools/query-elements.js +195 -195
- package/server/engine/mcp/tools/read-board.js +103 -103
- package/server/engine/mcp/tools/read-document.js +93 -93
- package/server/engine/mcp/tools/read-page.js +264 -264
- package/server/engine/mcp/tools/read-tavern-json.js +143 -143
- package/server/engine/mcp/tools/record-decision.js +140 -140
- package/server/engine/mcp/tools/relate-on-board.js +110 -110
- package/server/engine/mcp/tools/report-issue.js +115 -115
- package/server/engine/mcp/tools/roll-film.js +266 -266
- package/server/engine/mcp/tools/screenshot-docx.js +138 -138
- package/server/engine/mcp/tools/screenshot-url.js +195 -195
- package/server/engine/mcp/tools/screenshot.js +583 -583
- package/server/engine/mcp/tools/tier-gate.js +96 -96
- package/server/engine/mcp/tools/trace-motion.js +215 -215
- package/server/engine/mcp/tools/web-search.js +548 -548
- package/server/engine/motion/inventory.js +349 -349
- package/server/engine/perception/session.js +235 -235
- package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -5
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -192
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -667
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -22
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -25
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -23
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -21
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -23
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -21
- package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -162
- package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -407
- package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -80
- package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -228
- package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +78 -78
- package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -268
- package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -323
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -134
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -61
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -163
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -86
- package/server/engine/runs/active-runs.js +698 -698
- package/server/engine/runs/live-turn.js +252 -252
- package/server/engine/runs/store.js +364 -364
- package/server/engine/runs/turn-relay.js +217 -217
- package/server/engine/runtime/workspace.js +164 -164
- package/server/lib/artifact-file-path.js +80 -80
- package/server/lib/artifact-target.js +284 -284
- package/server/lib/asset-refs.js +239 -239
- package/server/lib/async-queue.js +104 -104
- package/server/lib/auto-relations.js +130 -130
- package/server/lib/binding-types.js +60 -60
- package/server/lib/board-kind-sizes.js +62 -62
- package/server/lib/board-relations.js +133 -133
- package/server/lib/browse-proxy.js +180 -180
- package/server/lib/canvas-id.js +36 -36
- package/server/lib/cover.js +227 -227
- package/server/lib/danbooru-tags.js +291 -291
- package/server/lib/doc-extract.js +156 -156
- package/server/lib/docx/build-from-source.js +260 -260
- package/server/lib/docx/build.js +490 -490
- package/server/lib/docx/dump-styles.js +287 -287
- package/server/lib/docx/fonts/nodesign-cjk.conf +164 -164
- package/server/lib/docx/merge-runs.js +129 -129
- package/server/lib/docx/numbering.js +218 -218
- package/server/lib/docx/order.js +152 -152
- package/server/lib/docx/rawzip.js +172 -172
- package/server/lib/docx/render.js +92 -92
- package/server/lib/docx/text-lint.js +225 -225
- package/server/lib/docx/tokens.js +335 -335
- package/server/lib/docx/units.js +29 -29
- package/server/lib/docx/xml.js +291 -291
- package/server/lib/docx-pages.js +187 -187
- package/server/lib/export-collect.js +289 -289
- package/server/lib/export-package.js +210 -210
- package/server/lib/html-srcset.js +145 -145
- package/server/lib/image-variant.js +460 -460
- package/server/lib/ingress/forward-openai-chat.js +305 -305
- package/server/lib/ingress/openai-chat.js +491 -491
- package/server/lib/ingress/session-notice.js +66 -66
- package/server/lib/ingress/session-routes.js +68 -68
- package/server/lib/ingress/slot-probe.js +130 -130
- package/server/lib/ingress/upstream-billing.js +52 -52
- package/server/lib/ingress/upstream-fail-streak.js +72 -72
- package/server/lib/ingress/upstream-truncation.js +48 -48
- package/server/lib/issues-store.js +182 -182
- package/server/lib/kinds/deck.js +72 -72
- package/server/lib/kinds/docx.js +283 -283
- package/server/lib/kinds/file-kinds.js +113 -113
- package/server/lib/kinds/index.js +202 -202
- package/server/lib/kinds/site.js +161 -161
- package/server/lib/model-ingress.js +543 -543
- package/server/lib/moderation.js +255 -255
- package/server/lib/notice-store.js +103 -103
- package/server/lib/plugin-install.js +104 -104
- package/server/lib/plugin-validator.js +721 -721
- package/server/lib/publish-store.js +115 -115
- package/server/lib/quick-summary.js +52 -52
- package/server/lib/quota.js +270 -270
- package/server/lib/rate-window.js +29 -29
- package/server/lib/reference-assets.js +92 -92
- package/server/lib/region-shot.js +135 -135
- package/server/lib/safe-path.js +73 -73
- package/server/lib/sdk-session.js +33 -33
- package/server/lib/showcase-store.js +92 -92
- package/server/lib/site-publish.js +465 -465
- package/server/lib/ssrf-guard.js +432 -432
- package/server/lib/task-scan.js +158 -158
- package/server/lib/tavern-json.js +154 -154
- package/server/lib/video-variant.js +237 -237
- package/server/lib/workspace-path.js +33 -33
- package/server/ops/fix-sdk-musl.mjs +40 -40
- package/server/ops/install-macos-fonts.sh +114 -114
- package/server/ops/macos-fonts.conf +336 -336
- package/server/ops/sandbox-shim/bwrap +57 -57
- package/server/projects/assets-summary.js +119 -119
- package/server/projects/auto-name.js +53 -53
- package/server/projects/board-store.js +640 -640
- package/server/projects/move-entry.js +103 -103
- package/server/projects/store.js +270 -270
- package/server/projects/ui-config.js +54 -54
- package/server/projects/workspace-templates.js +53 -53
- package/server/projects/workspace.js +1025 -1025
- package/server/runtime/local-config.js +177 -177
- package/server/runtime/profile.js +72 -72
- package/server/services/rembg-service.py +334 -334
- package/server/shared/README.md +25 -25
- package/server/shared/deck.js +54 -54
- package/server/shared/time.js +49 -49
- package/server/style-pipeline/README.md +53 -53
- package/server/ws/broker.js +36 -36
- package/server/ws/browse-channel.js +177 -177
- package/server/ws/index.js +386 -386
- package/web/README.md +89 -89
- package/web/dist/assets/BrowserWindow-BYCgizHw.js +11 -0
- package/web/dist/assets/{DeckWindow-4G0xX7P3.js → DeckWindow-pNydEM4L.js} +1 -1
- package/web/dist/assets/{DocxWindow-BXQsoPkh.js → DocxWindow-PPgzh3Gq.js} +1 -1
- package/web/dist/assets/{SiteWindow-D6eS-2gM.js → SiteWindow-DM0LQPCO.js} +1 -1
- package/web/dist/assets/{index-Cbl3ATvp.js → index-BHTBJlpY.js} +32 -32
- package/web/dist/assets/{pending-edit-apply-DTjo2geW.js → pending-edit-apply-C0I13CbD.js} +1 -1
- package/web/dist/assets/{rotate-cw-LMT3x1Zj.js → rotate-cw-B1UQ8Hld.js} +1 -1
- package/web/dist/assets/{square-dashed-mouse-pointer-CBoAkfhi.js → square-dashed-mouse-pointer-8lrgqmfv.js} +1 -1
- package/web/dist/index.html +17 -17
- package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +0 -11
|
@@ -1,291 +1,291 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* server/lib/danbooru-tags.js — danbooru 标签收录量校验 + 查询(2026-08-18 起)
|
|
3
|
-
*
|
|
4
|
-
* ## 为什么需要
|
|
5
|
-
*
|
|
6
|
-
* danbooru 系模型(noobai / noobai-eps / pony)吃的是 danbooru 标签。标签写错
|
|
7
|
-
* **不会报错,只会静默失效** —— 模型收到的等于空气。一个 agent 在一轮长时间
|
|
8
|
-
* 配图里因此烧掉至少五轮出图,每次都是"图不对 → 用户反馈 → 去查标签 → 发现
|
|
9
|
-
* 根本没有这个词"。
|
|
10
|
-
*
|
|
11
|
-
* 最典型的一次:连续三张图里"妆花了"完全不生效,用户反复说"她太干净了",
|
|
12
|
-
* 而 `running_makeup` / `smeared_eyeliner` / `dried_tears` / `ruined_makeup`
|
|
13
|
-
* 四个词**全是 0 收录**。真正有货的是 tears(29万) / crying_with_eyes_open(5.4万)
|
|
14
|
-
* / smeared_lipstick / dirty_face。
|
|
15
|
-
*
|
|
16
|
-
* ⭐ **判据是 post_count 不是"标签存不存在"**:`running_makeup` 在 danbooru 上
|
|
17
|
-
* 确实有这个条目,`post_count` 是 0 —— 存在但没有一张图用过它,训练里等于不存在。
|
|
18
|
-
* 只查"有没有这个条目"会漏掉整类问题(实测过)。
|
|
19
|
-
*
|
|
20
|
-
* ## 两个入口,一份逻辑(2026-08-20)
|
|
21
|
-
*
|
|
22
|
-
* - `lintTags`:paint_still **画完之后**的体检 —— 收录量 + 「这段像句子不像标签」
|
|
23
|
-
* + 「带下划线」两项本地判定(不联网也能说)。
|
|
24
|
-
* - `lookupTags`:`lookup_tags` 工具,**画之前**查 —— 同一套收录量判定,外加
|
|
25
|
-
* 弱词自动给候选替换(danbooru 通配搜索按收录量排序)、自由词搜索、wiki 释义。
|
|
26
|
-
* 目的是把"先理解用户意思 → 查真实标签 → 再画"的纪律变得比 curl 便宜。
|
|
27
|
-
*
|
|
28
|
-
* ## 纪律
|
|
29
|
-
*
|
|
30
|
-
* - **fail-open**:查不到、超时、断网一律不挡出图。这是一个提示,不是闸门。
|
|
31
|
-
* - **只提示不改写**:绝不替 agent 改 prompt —— 「逐字传递」是既有纪律,
|
|
32
|
-
* 而且替换词的选择是创作判断,不是查表能定的。
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
const API = 'https://danbooru.donmai.us';
|
|
36
|
-
const BATCH = 100;
|
|
37
|
-
const LOW = 1000;
|
|
38
|
-
const UA = { 'User-Agent': 'nodesign-tag-lint' };
|
|
39
|
-
|
|
40
|
-
/** 进程内缓存。标签收录量是慢变量,一个进程生命周期内不用重查。 */
|
|
41
|
-
const cache = new Map(); // name → post_count(-1 = 查过但 danbooru 上没有这个条目)
|
|
42
|
-
|
|
43
|
-
// 句子判定用的功能词。⚠️ 刻意不收 of/with/on/in/own:danbooru 真标签里满是
|
|
44
|
-
// `hand_on_own_chest` / `playing_with_own_hair` / `cup_of_tea`,收了就是误伤。
|
|
45
|
-
const STOPWORDS = new Set(['a', 'an', 'the', 'and', 'is', 'are', 'was', 'were',
|
|
46
|
-
'who', 'which', 'she', 'he', 'they', 'her', 'his', 'their', 'being', 'very']);
|
|
47
|
-
|
|
48
|
-
/** 把 prompt 切成逗号片段(剥 <lora>、BREAK),保留原文便于回显。 */
|
|
49
|
-
function splitFragments(text) {
|
|
50
|
-
if (!text || typeof text !== 'string') return [];
|
|
51
|
-
return String(text)
|
|
52
|
-
.replace(/<[^>]*>/g, ' ')
|
|
53
|
-
.replace(/\bBREAK\b/gi, ',')
|
|
54
|
-
.split(',')
|
|
55
|
-
.map(s => s.trim())
|
|
56
|
-
.filter(Boolean);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** 片段是否像自然语言句子而不是标签(模型基本不认这种输入)。 */
|
|
60
|
-
export function isSentenceLike(fragment) {
|
|
61
|
-
const bare = fragment.replace(/[()[\]{}]/g, '').replace(/:\s*[\d.]+\s*$/, '').trim();
|
|
62
|
-
if (!bare) return false;
|
|
63
|
-
if (/[-ヿ㐀-鿿]/.test(bare)) return true; // 中日文
|
|
64
|
-
const words = bare.toLowerCase().split(/[\s_]+/).filter(Boolean);
|
|
65
|
-
if (words.length >= 6) return true;
|
|
66
|
-
return words.length >= 3 && words.some(w => STOPWORDS.has(w));
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* 本地判定(不联网):哪些片段像句子、哪些带下划线。
|
|
71
|
-
* @returns {{sentences:string[], underscored:string[]}}
|
|
72
|
-
*/
|
|
73
|
-
export function classifyFragments(text) {
|
|
74
|
-
const sentences = [], underscored = [];
|
|
75
|
-
for (const f of splitFragments(text)) {
|
|
76
|
-
if (isSentenceLike(f)) sentences.push(f);
|
|
77
|
-
else if (f.includes('_')) underscored.push(f);
|
|
78
|
-
}
|
|
79
|
-
return { sentences, underscored };
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function balanced(s) {
|
|
83
|
-
let d = 0;
|
|
84
|
-
for (const ch of s) { if (ch === '(') d++; else if (ch === ')') { d--; if (d < 0) return false; } }
|
|
85
|
-
return d === 0;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* 标签名归一成 danbooru 的 name:剥反斜杠转义、`(tag:1.2)` 权重、纯强调用的外层括号、
|
|
90
|
-
* `artist:` 一类前缀;空格转下划线。⚠️ 名字自带的括号要留:`lucy \(cyberpunk\)` → `lucy_(cyberpunk)`。
|
|
91
|
-
*/
|
|
92
|
-
export function normalizeTag(raw) {
|
|
93
|
-
let s = String(raw).replace(/\\/g, '').trim();
|
|
94
|
-
s = s.replace(/:\s*[\d.]+\s*(\)*)\s*$/, '$1'); // (tag:1.2) → (tag)
|
|
95
|
-
while (/^[([{][\s\S]*[)\]}]$/.test(s)) { // 外层强调括号
|
|
96
|
-
const inner = s.slice(1, -1).trim();
|
|
97
|
-
if (!balanced(inner)) break;
|
|
98
|
-
s = inner;
|
|
99
|
-
}
|
|
100
|
-
return s.replace(/[[\]{}]/g, '')
|
|
101
|
-
.toLowerCase()
|
|
102
|
-
.replace(/^(artist|character|copyright|meta|general):/, '')
|
|
103
|
-
.trim().replace(/\s+/g, '_');
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* 从一段 danbooru 风格 prompt 里抠出标签名(像句子的片段跳过 —— 它们另有去处)。
|
|
108
|
-
*/
|
|
109
|
-
export function extractTags(text) {
|
|
110
|
-
return splitFragments(text)
|
|
111
|
-
.filter(f => !isSentenceLike(f))
|
|
112
|
-
.map(normalizeTag)
|
|
113
|
-
.filter(t => t
|
|
114
|
-
&& t.length >= 3 && t.length <= 60
|
|
115
|
-
&& /^[a-z0-9_\-'.()!?&+]+$/.test(t) // 中文/自然语言句子不是 danbooru 标签,跳过
|
|
116
|
-
&& !/^\d+$/.test(t));
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async function getJson(url, timeoutMs) {
|
|
120
|
-
const ac = new AbortController();
|
|
121
|
-
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
122
|
-
try {
|
|
123
|
-
const res = await fetch(url, { signal: ac.signal, headers: UA });
|
|
124
|
-
if (!res.ok) return null;
|
|
125
|
-
return await res.json();
|
|
126
|
-
} catch {
|
|
127
|
-
return null;
|
|
128
|
-
} finally {
|
|
129
|
-
clearTimeout(timer);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async function fetchCounts(names, timeoutMs) {
|
|
134
|
-
const out = new Map();
|
|
135
|
-
for (let i = 0; i < names.length; i += BATCH) {
|
|
136
|
-
const chunk = names.slice(i, i + BATCH);
|
|
137
|
-
const rows = await getJson(`${API}/tags.json?search%5Bname_comma%5D=${encodeURIComponent(chunk.join(','))}&limit=${BATCH * 2}`, timeoutMs);
|
|
138
|
-
if (!rows) return null; // fail-open
|
|
139
|
-
for (const row of rows) if (row?.name) out.set(row.name, Number(row.post_count) || 0);
|
|
140
|
-
for (const n of chunk) if (!out.has(n)) out.set(n, -1); // danbooru 上没这条目
|
|
141
|
-
}
|
|
142
|
-
return out;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
async function countsFor(names, timeoutMs) {
|
|
146
|
-
const need = names.filter(n => !cache.has(n));
|
|
147
|
-
if (need.length) {
|
|
148
|
-
const fetched = await fetchCounts(need, timeoutMs);
|
|
149
|
-
if (!fetched) return false;
|
|
150
|
-
for (const [k, v] of fetched) cache.set(k, v);
|
|
151
|
-
}
|
|
152
|
-
return true;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function bucket(names) {
|
|
156
|
-
const zero = [], low = [], missing = [], ok = [];
|
|
157
|
-
for (const n of names) {
|
|
158
|
-
const c = cache.get(n);
|
|
159
|
-
if (c == null) continue;
|
|
160
|
-
if (c === -1) missing.push(n);
|
|
161
|
-
else if (c === 0) zero.push(n);
|
|
162
|
-
else if (c < LOW) low.push([n, c]);
|
|
163
|
-
else ok.push([n, c]);
|
|
164
|
-
}
|
|
165
|
-
return { zero, low, missing, ok };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* @param {string[]} texts prompt / negative 原文
|
|
170
|
-
* @returns {Promise<{zero:string[], low:Array<[string,number]>, missing:string[], checked:number,
|
|
171
|
-
* sentences:string[], underscored:string[], offline:boolean}|null>}
|
|
172
|
-
* null = 既查不了也没什么本地可说的(调用方什么都不要说)
|
|
173
|
-
*/
|
|
174
|
-
export async function lintTags(texts, { timeoutMs = 3000 } = {}) {
|
|
175
|
-
const local = { sentences: [], underscored: [] };
|
|
176
|
-
for (const t of texts) {
|
|
177
|
-
const c = classifyFragments(t);
|
|
178
|
-
local.sentences.push(...c.sentences);
|
|
179
|
-
local.underscored.push(...c.underscored);
|
|
180
|
-
}
|
|
181
|
-
local.sentences = [...new Set(local.sentences)];
|
|
182
|
-
local.underscored = [...new Set(local.underscored)];
|
|
183
|
-
|
|
184
|
-
const names = [...new Set(texts.flatMap(extractTags))];
|
|
185
|
-
const online = names.length ? await countsFor(names, timeoutMs) : true;
|
|
186
|
-
const b = online ? bucket(names) : { zero: [], low: [], missing: [] };
|
|
187
|
-
const r = { ...b, ok: undefined, checked: online ? names.length : 0, ...local, offline: !online };
|
|
188
|
-
delete r.ok;
|
|
189
|
-
if (!r.zero.length && !r.low.length && !r.missing.length && !r.sentences.length && !r.underscored.length) return null;
|
|
190
|
-
return r;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** 把 lint 结果写成给 agent 看的一段话。没什么可说时返回 null。 */
|
|
194
|
-
export function formatTagLint(r) {
|
|
195
|
-
if (!r) return null;
|
|
196
|
-
const parts = [];
|
|
197
|
-
if (r.sentences?.length) {
|
|
198
|
-
parts.push('像句子不像标签(模型基本不认这种输入,还摊薄其它标签的权重 —— 拆成标签):'
|
|
199
|
-
+ r.sentences.map(s => `"${s}"`).join(' · '));
|
|
200
|
-
}
|
|
201
|
-
if (r.underscored?.length) parts.push(`带下划线(手册要求写成空格):${r.underscored.join(', ')}`);
|
|
202
|
-
if (r.missing?.length) parts.push(`不存在的标签:${r.missing.join(', ')}`);
|
|
203
|
-
if (r.zero?.length) parts.push(`0 收录(存在但训练里等于没有):${r.zero.join(', ')}`);
|
|
204
|
-
if (r.low?.length) {
|
|
205
|
-
parts.push(`低收录(<${LOW},弱但不是禁区:主流需求换掉,用户要的本来就偏门就照用、配近义词加固):`
|
|
206
|
-
+ r.low.map(([n, c]) => `${n}(${c})`).join(', '));
|
|
207
|
-
}
|
|
208
|
-
if (!parts.length) return null;
|
|
209
|
-
const head = r.offline ? '⚠️ 标签体检(danbooru 查不到,只做了本地判定):' : `⚠️ 标签体检(查了 ${r.checked} 个):`;
|
|
210
|
-
return `${head}\n ${parts.join('\n ')}\n`
|
|
211
|
-
+ ' 这些词模型收不到,等于没写 —— 用 lookup_tags 换成有收录量的词再滚一次,别按这批图判断方向。';
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// ── 画之前查:lookup_tags ──
|
|
215
|
-
|
|
216
|
-
/** 从一个弱标签里挑最多两个值得拿去通配搜索的词(最长的非功能词优先)。 */
|
|
217
|
-
function searchWordsOf(tag) {
|
|
218
|
-
const words = tag.split(/[_\s\-()]+/).filter(w => w.length >= 3 && !STOPWORDS.has(w)
|
|
219
|
-
&& !['own', 'with', 'and', 'from', 'the', 'out', 'off'].includes(w));
|
|
220
|
-
return [...new Set(words)].sort((a, b) => b.length - a.length).slice(0, 2);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** 弱标签 → 候选:每个关键词各搜一次,合并按收录量排,太弱的(<100)不当候选。 */
|
|
224
|
-
async function candidatesFor(tag, timeoutMs) {
|
|
225
|
-
const seen = new Map();
|
|
226
|
-
for (const w of searchWordsOf(tag)) {
|
|
227
|
-
const hits = await wildcard(w, timeoutMs, 6);
|
|
228
|
-
for (const [n, c] of hits || []) if (n !== tag && c >= 100) seen.set(n, c);
|
|
229
|
-
}
|
|
230
|
-
return [...seen].sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
async function wildcard(term, timeoutMs, limit = 8) {
|
|
234
|
-
const q = `*${normalizeTag(term).replace(/^\*+|\*+$/g, '')}*`;
|
|
235
|
-
const rows = await getJson(`${API}/tags.json?search%5Bname_matches%5D=${encodeURIComponent(q)}`
|
|
236
|
-
+ `&search%5Border%5D=count&search%5Bhide_empty%5D=true&limit=${limit}`, timeoutMs);
|
|
237
|
-
if (!rows) return null;
|
|
238
|
-
return rows.map(r => [r.name, Number(r.post_count) || 0]);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
async function wikiOf(tag, timeoutMs) {
|
|
242
|
-
const rows = await getJson(`${API}/wiki_pages.json?search%5Btitle%5D=${encodeURIComponent(normalizeTag(tag))}&only=title,body`, timeoutMs);
|
|
243
|
-
const body = rows?.[0]?.body;
|
|
244
|
-
if (!body) return null;
|
|
245
|
-
return body.replace(/\[\[([^\]|]+)(\|[^\]]*)?\]\]/g, '$1').replace(/\s+/g, ' ').trim().slice(0, 220);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* @param {{tags?:string[], search?:string[], explain?:string[]}} q
|
|
250
|
-
* @returns {Promise<{offline:boolean, tags:{ok:Array, low:Array, zero:string[], missing:string[]},
|
|
251
|
-
* suggestions:Array<[string, Array<[string,number]>]>, searches:Array<[string, Array<[string,number]>|null]>,
|
|
252
|
-
* wiki:Array<[string,string|null]>}>}
|
|
253
|
-
*/
|
|
254
|
-
export async function lookupTags({ tags = [], search = [], explain = [] } = {}, { timeoutMs = 5000 } = {}) {
|
|
255
|
-
const names = [...new Set(tags.map(normalizeTag).filter(Boolean))].slice(0, 60);
|
|
256
|
-
const online = names.length ? await countsFor(names, timeoutMs) : true;
|
|
257
|
-
const b = online ? bucket(names) : { ok: [], low: [], zero: [], missing: [] };
|
|
258
|
-
|
|
259
|
-
const suggestions = [];
|
|
260
|
-
if (online) {
|
|
261
|
-
const weak = [...b.missing, ...b.zero, ...b.low.map(([n]) => n)].slice(0, 8);
|
|
262
|
-
for (const w of weak) {
|
|
263
|
-
const hits = await candidatesFor(w, timeoutMs);
|
|
264
|
-
if (hits.length) suggestions.push([w, hits]);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
const searches = [];
|
|
268
|
-
for (const s of [...new Set(search)].slice(0, 10)) searches.push([s, await wildcard(s, timeoutMs, 10)]);
|
|
269
|
-
const wiki = [];
|
|
270
|
-
for (const t of [...new Set(explain)].slice(0, 8)) wiki.push([t, await wikiOf(t, timeoutMs)]);
|
|
271
|
-
return { offline: !online, tags: b, suggestions, searches, wiki };
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** lookup_tags 的文本输出。 */
|
|
275
|
-
export function formatLookup(r) {
|
|
276
|
-
const fmt = (arr) => arr.map(([n, c]) => `${n}(${c})`).join(', ');
|
|
277
|
-
const out = [];
|
|
278
|
-
if (r.offline) out.push('⚠️ danbooru 暂时查不到(超时/断网),收录量这部分空着;别因此停工,照手册词表写。');
|
|
279
|
-
const t = r.tags;
|
|
280
|
-
if (t.ok?.length) out.push(`✅ 可用(≥${LOW}):${fmt(t.ok)}`);
|
|
281
|
-
if (t.low?.length) out.push(`⚠️ 低收录(<${LOW}):${fmt(t.low)} —— 弱但不是禁区:主流需求换成候选,用户要的本来就偏门就照用、配近义词加固`);
|
|
282
|
-
if (t.zero?.length) out.push(`✗ 0 收录(等于不存在):${t.zero.join(', ')}`);
|
|
283
|
-
if (t.missing?.length) out.push(`✗ 不存在:${t.missing.join(', ')}`);
|
|
284
|
-
for (const [w, hits] of r.suggestions) out.push(` ↳ ${w} 的候选(按收录量):${fmt(hits) || '(没搜到相近的)'}`);
|
|
285
|
-
for (const [s, hits] of r.searches) {
|
|
286
|
-
out.push(`🔍 *${s}*:${hits == null ? '(查不到)' : hits.length ? fmt(hits) : '(没有匹配的标签)'}`);
|
|
287
|
-
}
|
|
288
|
-
for (const [tag, body] of r.wiki) out.push(`📖 ${tag}:${body || '(没有 wiki)'}`);
|
|
289
|
-
if (!out.length) out.push('(什么都没查 —— tags / search / explain 至少给一个)');
|
|
290
|
-
return out.join('\n');
|
|
291
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* server/lib/danbooru-tags.js — danbooru 标签收录量校验 + 查询(2026-08-18 起)
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要
|
|
5
|
+
*
|
|
6
|
+
* danbooru 系模型(noobai / noobai-eps / pony)吃的是 danbooru 标签。标签写错
|
|
7
|
+
* **不会报错,只会静默失效** —— 模型收到的等于空气。一个 agent 在一轮长时间
|
|
8
|
+
* 配图里因此烧掉至少五轮出图,每次都是"图不对 → 用户反馈 → 去查标签 → 发现
|
|
9
|
+
* 根本没有这个词"。
|
|
10
|
+
*
|
|
11
|
+
* 最典型的一次:连续三张图里"妆花了"完全不生效,用户反复说"她太干净了",
|
|
12
|
+
* 而 `running_makeup` / `smeared_eyeliner` / `dried_tears` / `ruined_makeup`
|
|
13
|
+
* 四个词**全是 0 收录**。真正有货的是 tears(29万) / crying_with_eyes_open(5.4万)
|
|
14
|
+
* / smeared_lipstick / dirty_face。
|
|
15
|
+
*
|
|
16
|
+
* ⭐ **判据是 post_count 不是"标签存不存在"**:`running_makeup` 在 danbooru 上
|
|
17
|
+
* 确实有这个条目,`post_count` 是 0 —— 存在但没有一张图用过它,训练里等于不存在。
|
|
18
|
+
* 只查"有没有这个条目"会漏掉整类问题(实测过)。
|
|
19
|
+
*
|
|
20
|
+
* ## 两个入口,一份逻辑(2026-08-20)
|
|
21
|
+
*
|
|
22
|
+
* - `lintTags`:paint_still **画完之后**的体检 —— 收录量 + 「这段像句子不像标签」
|
|
23
|
+
* + 「带下划线」两项本地判定(不联网也能说)。
|
|
24
|
+
* - `lookupTags`:`lookup_tags` 工具,**画之前**查 —— 同一套收录量判定,外加
|
|
25
|
+
* 弱词自动给候选替换(danbooru 通配搜索按收录量排序)、自由词搜索、wiki 释义。
|
|
26
|
+
* 目的是把"先理解用户意思 → 查真实标签 → 再画"的纪律变得比 curl 便宜。
|
|
27
|
+
*
|
|
28
|
+
* ## 纪律
|
|
29
|
+
*
|
|
30
|
+
* - **fail-open**:查不到、超时、断网一律不挡出图。这是一个提示,不是闸门。
|
|
31
|
+
* - **只提示不改写**:绝不替 agent 改 prompt —— 「逐字传递」是既有纪律,
|
|
32
|
+
* 而且替换词的选择是创作判断,不是查表能定的。
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const API = 'https://danbooru.donmai.us';
|
|
36
|
+
const BATCH = 100;
|
|
37
|
+
const LOW = 1000;
|
|
38
|
+
const UA = { 'User-Agent': 'nodesign-tag-lint' };
|
|
39
|
+
|
|
40
|
+
/** 进程内缓存。标签收录量是慢变量,一个进程生命周期内不用重查。 */
|
|
41
|
+
const cache = new Map(); // name → post_count(-1 = 查过但 danbooru 上没有这个条目)
|
|
42
|
+
|
|
43
|
+
// 句子判定用的功能词。⚠️ 刻意不收 of/with/on/in/own:danbooru 真标签里满是
|
|
44
|
+
// `hand_on_own_chest` / `playing_with_own_hair` / `cup_of_tea`,收了就是误伤。
|
|
45
|
+
const STOPWORDS = new Set(['a', 'an', 'the', 'and', 'is', 'are', 'was', 'were',
|
|
46
|
+
'who', 'which', 'she', 'he', 'they', 'her', 'his', 'their', 'being', 'very']);
|
|
47
|
+
|
|
48
|
+
/** 把 prompt 切成逗号片段(剥 <lora>、BREAK),保留原文便于回显。 */
|
|
49
|
+
function splitFragments(text) {
|
|
50
|
+
if (!text || typeof text !== 'string') return [];
|
|
51
|
+
return String(text)
|
|
52
|
+
.replace(/<[^>]*>/g, ' ')
|
|
53
|
+
.replace(/\bBREAK\b/gi, ',')
|
|
54
|
+
.split(',')
|
|
55
|
+
.map(s => s.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 片段是否像自然语言句子而不是标签(模型基本不认这种输入)。 */
|
|
60
|
+
export function isSentenceLike(fragment) {
|
|
61
|
+
const bare = fragment.replace(/[()[\]{}]/g, '').replace(/:\s*[\d.]+\s*$/, '').trim();
|
|
62
|
+
if (!bare) return false;
|
|
63
|
+
if (/[-ヿ㐀-鿿]/.test(bare)) return true; // 中日文
|
|
64
|
+
const words = bare.toLowerCase().split(/[\s_]+/).filter(Boolean);
|
|
65
|
+
if (words.length >= 6) return true;
|
|
66
|
+
return words.length >= 3 && words.some(w => STOPWORDS.has(w));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 本地判定(不联网):哪些片段像句子、哪些带下划线。
|
|
71
|
+
* @returns {{sentences:string[], underscored:string[]}}
|
|
72
|
+
*/
|
|
73
|
+
export function classifyFragments(text) {
|
|
74
|
+
const sentences = [], underscored = [];
|
|
75
|
+
for (const f of splitFragments(text)) {
|
|
76
|
+
if (isSentenceLike(f)) sentences.push(f);
|
|
77
|
+
else if (f.includes('_')) underscored.push(f);
|
|
78
|
+
}
|
|
79
|
+
return { sentences, underscored };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function balanced(s) {
|
|
83
|
+
let d = 0;
|
|
84
|
+
for (const ch of s) { if (ch === '(') d++; else if (ch === ')') { d--; if (d < 0) return false; } }
|
|
85
|
+
return d === 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 标签名归一成 danbooru 的 name:剥反斜杠转义、`(tag:1.2)` 权重、纯强调用的外层括号、
|
|
90
|
+
* `artist:` 一类前缀;空格转下划线。⚠️ 名字自带的括号要留:`lucy \(cyberpunk\)` → `lucy_(cyberpunk)`。
|
|
91
|
+
*/
|
|
92
|
+
export function normalizeTag(raw) {
|
|
93
|
+
let s = String(raw).replace(/\\/g, '').trim();
|
|
94
|
+
s = s.replace(/:\s*[\d.]+\s*(\)*)\s*$/, '$1'); // (tag:1.2) → (tag)
|
|
95
|
+
while (/^[([{][\s\S]*[)\]}]$/.test(s)) { // 外层强调括号
|
|
96
|
+
const inner = s.slice(1, -1).trim();
|
|
97
|
+
if (!balanced(inner)) break;
|
|
98
|
+
s = inner;
|
|
99
|
+
}
|
|
100
|
+
return s.replace(/[[\]{}]/g, '')
|
|
101
|
+
.toLowerCase()
|
|
102
|
+
.replace(/^(artist|character|copyright|meta|general):/, '')
|
|
103
|
+
.trim().replace(/\s+/g, '_');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 从一段 danbooru 风格 prompt 里抠出标签名(像句子的片段跳过 —— 它们另有去处)。
|
|
108
|
+
*/
|
|
109
|
+
export function extractTags(text) {
|
|
110
|
+
return splitFragments(text)
|
|
111
|
+
.filter(f => !isSentenceLike(f))
|
|
112
|
+
.map(normalizeTag)
|
|
113
|
+
.filter(t => t
|
|
114
|
+
&& t.length >= 3 && t.length <= 60
|
|
115
|
+
&& /^[a-z0-9_\-'.()!?&+]+$/.test(t) // 中文/自然语言句子不是 danbooru 标签,跳过
|
|
116
|
+
&& !/^\d+$/.test(t));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function getJson(url, timeoutMs) {
|
|
120
|
+
const ac = new AbortController();
|
|
121
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
122
|
+
try {
|
|
123
|
+
const res = await fetch(url, { signal: ac.signal, headers: UA });
|
|
124
|
+
if (!res.ok) return null;
|
|
125
|
+
return await res.json();
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
} finally {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function fetchCounts(names, timeoutMs) {
|
|
134
|
+
const out = new Map();
|
|
135
|
+
for (let i = 0; i < names.length; i += BATCH) {
|
|
136
|
+
const chunk = names.slice(i, i + BATCH);
|
|
137
|
+
const rows = await getJson(`${API}/tags.json?search%5Bname_comma%5D=${encodeURIComponent(chunk.join(','))}&limit=${BATCH * 2}`, timeoutMs);
|
|
138
|
+
if (!rows) return null; // fail-open
|
|
139
|
+
for (const row of rows) if (row?.name) out.set(row.name, Number(row.post_count) || 0);
|
|
140
|
+
for (const n of chunk) if (!out.has(n)) out.set(n, -1); // danbooru 上没这条目
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function countsFor(names, timeoutMs) {
|
|
146
|
+
const need = names.filter(n => !cache.has(n));
|
|
147
|
+
if (need.length) {
|
|
148
|
+
const fetched = await fetchCounts(need, timeoutMs);
|
|
149
|
+
if (!fetched) return false;
|
|
150
|
+
for (const [k, v] of fetched) cache.set(k, v);
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function bucket(names) {
|
|
156
|
+
const zero = [], low = [], missing = [], ok = [];
|
|
157
|
+
for (const n of names) {
|
|
158
|
+
const c = cache.get(n);
|
|
159
|
+
if (c == null) continue;
|
|
160
|
+
if (c === -1) missing.push(n);
|
|
161
|
+
else if (c === 0) zero.push(n);
|
|
162
|
+
else if (c < LOW) low.push([n, c]);
|
|
163
|
+
else ok.push([n, c]);
|
|
164
|
+
}
|
|
165
|
+
return { zero, low, missing, ok };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param {string[]} texts prompt / negative 原文
|
|
170
|
+
* @returns {Promise<{zero:string[], low:Array<[string,number]>, missing:string[], checked:number,
|
|
171
|
+
* sentences:string[], underscored:string[], offline:boolean}|null>}
|
|
172
|
+
* null = 既查不了也没什么本地可说的(调用方什么都不要说)
|
|
173
|
+
*/
|
|
174
|
+
export async function lintTags(texts, { timeoutMs = 3000 } = {}) {
|
|
175
|
+
const local = { sentences: [], underscored: [] };
|
|
176
|
+
for (const t of texts) {
|
|
177
|
+
const c = classifyFragments(t);
|
|
178
|
+
local.sentences.push(...c.sentences);
|
|
179
|
+
local.underscored.push(...c.underscored);
|
|
180
|
+
}
|
|
181
|
+
local.sentences = [...new Set(local.sentences)];
|
|
182
|
+
local.underscored = [...new Set(local.underscored)];
|
|
183
|
+
|
|
184
|
+
const names = [...new Set(texts.flatMap(extractTags))];
|
|
185
|
+
const online = names.length ? await countsFor(names, timeoutMs) : true;
|
|
186
|
+
const b = online ? bucket(names) : { zero: [], low: [], missing: [] };
|
|
187
|
+
const r = { ...b, ok: undefined, checked: online ? names.length : 0, ...local, offline: !online };
|
|
188
|
+
delete r.ok;
|
|
189
|
+
if (!r.zero.length && !r.low.length && !r.missing.length && !r.sentences.length && !r.underscored.length) return null;
|
|
190
|
+
return r;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 把 lint 结果写成给 agent 看的一段话。没什么可说时返回 null。 */
|
|
194
|
+
export function formatTagLint(r) {
|
|
195
|
+
if (!r) return null;
|
|
196
|
+
const parts = [];
|
|
197
|
+
if (r.sentences?.length) {
|
|
198
|
+
parts.push('像句子不像标签(模型基本不认这种输入,还摊薄其它标签的权重 —— 拆成标签):'
|
|
199
|
+
+ r.sentences.map(s => `"${s}"`).join(' · '));
|
|
200
|
+
}
|
|
201
|
+
if (r.underscored?.length) parts.push(`带下划线(手册要求写成空格):${r.underscored.join(', ')}`);
|
|
202
|
+
if (r.missing?.length) parts.push(`不存在的标签:${r.missing.join(', ')}`);
|
|
203
|
+
if (r.zero?.length) parts.push(`0 收录(存在但训练里等于没有):${r.zero.join(', ')}`);
|
|
204
|
+
if (r.low?.length) {
|
|
205
|
+
parts.push(`低收录(<${LOW},弱但不是禁区:主流需求换掉,用户要的本来就偏门就照用、配近义词加固):`
|
|
206
|
+
+ r.low.map(([n, c]) => `${n}(${c})`).join(', '));
|
|
207
|
+
}
|
|
208
|
+
if (!parts.length) return null;
|
|
209
|
+
const head = r.offline ? '⚠️ 标签体检(danbooru 查不到,只做了本地判定):' : `⚠️ 标签体检(查了 ${r.checked} 个):`;
|
|
210
|
+
return `${head}\n ${parts.join('\n ')}\n`
|
|
211
|
+
+ ' 这些词模型收不到,等于没写 —— 用 lookup_tags 换成有收录量的词再滚一次,别按这批图判断方向。';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── 画之前查:lookup_tags ──
|
|
215
|
+
|
|
216
|
+
/** 从一个弱标签里挑最多两个值得拿去通配搜索的词(最长的非功能词优先)。 */
|
|
217
|
+
function searchWordsOf(tag) {
|
|
218
|
+
const words = tag.split(/[_\s\-()]+/).filter(w => w.length >= 3 && !STOPWORDS.has(w)
|
|
219
|
+
&& !['own', 'with', 'and', 'from', 'the', 'out', 'off'].includes(w));
|
|
220
|
+
return [...new Set(words)].sort((a, b) => b.length - a.length).slice(0, 2);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 弱标签 → 候选:每个关键词各搜一次,合并按收录量排,太弱的(<100)不当候选。 */
|
|
224
|
+
async function candidatesFor(tag, timeoutMs) {
|
|
225
|
+
const seen = new Map();
|
|
226
|
+
for (const w of searchWordsOf(tag)) {
|
|
227
|
+
const hits = await wildcard(w, timeoutMs, 6);
|
|
228
|
+
for (const [n, c] of hits || []) if (n !== tag && c >= 100) seen.set(n, c);
|
|
229
|
+
}
|
|
230
|
+
return [...seen].sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function wildcard(term, timeoutMs, limit = 8) {
|
|
234
|
+
const q = `*${normalizeTag(term).replace(/^\*+|\*+$/g, '')}*`;
|
|
235
|
+
const rows = await getJson(`${API}/tags.json?search%5Bname_matches%5D=${encodeURIComponent(q)}`
|
|
236
|
+
+ `&search%5Border%5D=count&search%5Bhide_empty%5D=true&limit=${limit}`, timeoutMs);
|
|
237
|
+
if (!rows) return null;
|
|
238
|
+
return rows.map(r => [r.name, Number(r.post_count) || 0]);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function wikiOf(tag, timeoutMs) {
|
|
242
|
+
const rows = await getJson(`${API}/wiki_pages.json?search%5Btitle%5D=${encodeURIComponent(normalizeTag(tag))}&only=title,body`, timeoutMs);
|
|
243
|
+
const body = rows?.[0]?.body;
|
|
244
|
+
if (!body) return null;
|
|
245
|
+
return body.replace(/\[\[([^\]|]+)(\|[^\]]*)?\]\]/g, '$1').replace(/\s+/g, ' ').trim().slice(0, 220);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @param {{tags?:string[], search?:string[], explain?:string[]}} q
|
|
250
|
+
* @returns {Promise<{offline:boolean, tags:{ok:Array, low:Array, zero:string[], missing:string[]},
|
|
251
|
+
* suggestions:Array<[string, Array<[string,number]>]>, searches:Array<[string, Array<[string,number]>|null]>,
|
|
252
|
+
* wiki:Array<[string,string|null]>}>}
|
|
253
|
+
*/
|
|
254
|
+
export async function lookupTags({ tags = [], search = [], explain = [] } = {}, { timeoutMs = 5000 } = {}) {
|
|
255
|
+
const names = [...new Set(tags.map(normalizeTag).filter(Boolean))].slice(0, 60);
|
|
256
|
+
const online = names.length ? await countsFor(names, timeoutMs) : true;
|
|
257
|
+
const b = online ? bucket(names) : { ok: [], low: [], zero: [], missing: [] };
|
|
258
|
+
|
|
259
|
+
const suggestions = [];
|
|
260
|
+
if (online) {
|
|
261
|
+
const weak = [...b.missing, ...b.zero, ...b.low.map(([n]) => n)].slice(0, 8);
|
|
262
|
+
for (const w of weak) {
|
|
263
|
+
const hits = await candidatesFor(w, timeoutMs);
|
|
264
|
+
if (hits.length) suggestions.push([w, hits]);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const searches = [];
|
|
268
|
+
for (const s of [...new Set(search)].slice(0, 10)) searches.push([s, await wildcard(s, timeoutMs, 10)]);
|
|
269
|
+
const wiki = [];
|
|
270
|
+
for (const t of [...new Set(explain)].slice(0, 8)) wiki.push([t, await wikiOf(t, timeoutMs)]);
|
|
271
|
+
return { offline: !online, tags: b, suggestions, searches, wiki };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** lookup_tags 的文本输出。 */
|
|
275
|
+
export function formatLookup(r) {
|
|
276
|
+
const fmt = (arr) => arr.map(([n, c]) => `${n}(${c})`).join(', ');
|
|
277
|
+
const out = [];
|
|
278
|
+
if (r.offline) out.push('⚠️ danbooru 暂时查不到(超时/断网),收录量这部分空着;别因此停工,照手册词表写。');
|
|
279
|
+
const t = r.tags;
|
|
280
|
+
if (t.ok?.length) out.push(`✅ 可用(≥${LOW}):${fmt(t.ok)}`);
|
|
281
|
+
if (t.low?.length) out.push(`⚠️ 低收录(<${LOW}):${fmt(t.low)} —— 弱但不是禁区:主流需求换成候选,用户要的本来就偏门就照用、配近义词加固`);
|
|
282
|
+
if (t.zero?.length) out.push(`✗ 0 收录(等于不存在):${t.zero.join(', ')}`);
|
|
283
|
+
if (t.missing?.length) out.push(`✗ 不存在:${t.missing.join(', ')}`);
|
|
284
|
+
for (const [w, hits] of r.suggestions) out.push(` ↳ ${w} 的候选(按收录量):${fmt(hits) || '(没搜到相近的)'}`);
|
|
285
|
+
for (const [s, hits] of r.searches) {
|
|
286
|
+
out.push(`🔍 *${s}*:${hits == null ? '(查不到)' : hits.length ? fmt(hits) : '(没有匹配的标签)'}`);
|
|
287
|
+
}
|
|
288
|
+
for (const [tag, body] of r.wiki) out.push(`📖 ${tag}:${body || '(没有 wiki)'}`);
|
|
289
|
+
if (!out.length) out.push('(什么都没查 —— tags / search / explain 至少给一个)');
|
|
290
|
+
return out.join('\n');
|
|
291
|
+
}
|