Files
DemonGatanjieu-Anomalous_Mo…/tools/ui_map.mjs
T
DemonGatanjieuandClaude Opus 5.5 b220fc8a31 Call a saved workflow a workflow on screen, not a recipe
- The 54 screen words that said 配方 / recipe for a saved workflow say 工作流 / workflow
  (the Workflows page, saving and exporting, the activity log, backups, the gallery's keep);
  配方包 / recipe package stays the .zip given to others. README and the MCP guide too.
- The Workflows page's empty guide drops its fallback texts: translate() returns the key
  when a word is missing, so they were never shown and still said 配方.
- The glossary retires 配方 and recipe; a retired name may list where it is not that name
  ("配方(配方包、搭配方案除外)"), and {placeholders} do not count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:52:08 +08:00

223 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The UI map: from what is on screen to the code and the backend behind it, read from the code
// each time (tools/ui_map_scan.mjs), so it cannot go stale.
// node tools/ui_map.mjs find <words on screen | key | /anomalous/route | file.js>
// node tools/ui_map.mjs build writes docs/ui-map.md (every page's files, words, routes; not tracked)
// node tools/ui_map.mjs check the hand-written docs/architecture/ui-flows.md against the code
// check_structure.mjs runs the check: the words in 「」 there are on screen, the files and routes
// it names exist, and no screen word uses a name its glossary retired.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { ROOT, buildIndex, routeShape } from './ui_map_scan.mjs';
const FLOWS = 'docs/architecture/ui-flows.md';
const MAP = 'docs/ui-map.md';
const SHOWN = 6; // keys shown for one search
const AREAS = [
['AI 工作台', /^(ui_ai_|ai_|mcp_)/],
['配音与声音', /tts|script|audio|voice/],
['图库与图片', /gallery|image|kept|keep|material|output/],
['模型', /model|scan|folder|civitai|download|import|sidecar|doctor|hash/],
['工作流与搭配', /recipe|combo|notebook|param|workflow|share|backup|sampler|lora/],
['提示词', /prompt|translation|tag/],
['画布', /canvas|node|placement|quick|spotlight|shortcut/],
['外壳与设置', /shell|rail|home|setting|dialog|tour|first_run|update|version|activity|popup|tab|split|sidebar|lifecycle|remote|safe|search|usage|pack|main/],
['其他', /./],
];
const short = (text, size = 40) => (text.length > size ? `${text.slice(0, size - 1)}…` : text);
const plain = value => String(value ?? '').replace(/\s+/g, ' ').trim();
const areaOf = file => AREAS.find(([, test]) => test.test(path.posix.basename(file)))[0];
/** Where `key` shows: [{file, line}] in the web files. */
function placesOf(index, key) {
return index.files.flatMap(one => (one.uses.get(key) || []).map(line => ({ file: one.file, line, one })));
}
/** The CSS classes made just before `line` in `one` (an element is made, then its words go in), with the CSS that styles them. */
function stylesNear(index, one, line) {
return [...one.classes].filter(([, lines]) => lines.some(at => at <= line + 1 && at >= line - 8))
.sort(([, a], [, b]) => Math.min(...a.map(at => Math.abs(at - line))) - Math.min(...b.map(at => Math.abs(at - line))))
.map(([name]) => ({ name, at: index.css.get(name) || [] })).filter(style => style.at.length).slice(0, 3);
}
function routeLine(route) {
return `${route.method} ${route.route} → ${route.file}:${route.line} ${route.func}`;
}
function callsNear(one, line) {
return [...one.calls].sort((a, b) => Math.abs(a.line - line) - Math.abs(b.line - line)).slice(0, 3);
}
function findWords(index, query) {
const want = query.toLowerCase();
const scored = [];
for (const key of index.keys) {
const zh = plain(index.text.zh[key]);
const en = plain(index.text.en[key]);
const values = [zh, en].map(value => value.toLowerCase());
let score = null;
if (key.toLowerCase() === want || values.includes(want)) score = 0;
else if (values.some(value => value.startsWith(want))) score = 1;
else if (values.some(value => value.includes(want))) score = 2;
if (score !== null) scored.push({ key, zh, en, score: score + (zh.length > 60 ? 0.5 : 0) });
}
scored.sort((a, b) => a.score - b.score || a.zh.length - b.zh.length);
if (!scored.length) return [`没有哪条界面文字含有「${query}」。换个说法,或用 key、/anomalous/ 地址、文件名查。`];
const out = [];
for (const item of scored.slice(0, SHOWN)) {
out.push('', `「${short(item.zh, 60)}」 ${item.key}`, ` English: ${short(item.en, 70)} (web/modules/locales.js:${index.text.line('zh', item.key)})`);
const places = placesOf(index, item.key);
if (!places.length) out.push(' 没有文件用它(可能没用了,或是拼出来的 key)');
for (const { file, line, one } of places.slice(0, 4)) {
out.push(` 显示在 ${file}:${line}`);
for (const style of stylesNear(index, one, line)) out.push(` 样式 .${style.name} ${style.at.slice(0, 3).join(' ')}`);
for (const call of callsNear(one, line)) {
for (const route of call.routes.slice(0, 2)) out.push(` 调用 ${routeLine(route)} (第 ${call.line} 行)`);
}
const from = index.importers.get(file) || [];
if (from.length) out.push(` 谁用它 ${from.slice(0, 4).join(' ')}${from.length > 4 ? ` 等 ${from.length} 个` : ''}`);
}
if (places.length > 4) out.push(` …还有 ${places.length - 4} 处`);
}
if (scored.length > SHOWN) out.push('', `还有 ${scored.length - SHOWN} 条文字也含「${query}」,写得更具体一点可以缩小范围。`);
return out;
}
function findRoute(index, query) {
const shape = routeShape(query.replace(/\?.*/, ''));
// Its start (/anomalous/ai_tasks), or one address with its parts filled in (/anomalous/ai_tasks/123/marks).
const filled = route => new RegExp(`^${route.shape.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replaceAll('{}', '[^/]+')}$`).test(shape);
const routes = index.routes.filter(route => route.shape.startsWith(shape) || filled(route));
if (!routes.length) return [`后端没有以 ${query} 开头的接口。`];
return routes.flatMap((route) => {
const callers = index.files.flatMap(one => one.calls.filter(call => call.routes.includes(route)).map(call => `${one.file}:${call.line}`));
return ['', routeLine(route), ` 登记在 ${route.registered}`, callers.length ? ` 前端调用 ${callers.slice(0, 8).join(' ')}` : ' 前端没有调用(给 MCP、ComfyUI 或别的插件用)'];
});
}
function findFile(index, query) {
const one = index.files.find(item => item.file.endsWith(query.replace(/\\/g, '/')));
if (!one) return [`没有前端文件叫 ${query}。`];
const words = [...one.uses.keys()].map(key => `「${short(plain(index.text.zh[key]), 24)}」`);
return [
one.file, one.about ? ` ${short(one.about, 160)}` : '',
` 显示的文字(${words.length}) ${words.slice(0, 16).join(' ')}${words.length > 16 ? ' …' : ''}`,
...one.calls.flatMap(call => call.routes.slice(0, 2).map(route => ` 调用 ${routeLine(route)} (第 ${call.line} 行)`)),
` 谁用它 ${(index.importers.get(one.file) || []).join(' ') || '(入口)'}`,
` 它用到 ${one.imports.map(file => path.posix.basename(file)).join(' ')}`,
].filter(Boolean);
}
export async function find(asked) {
const index = await buildIndex();
// Git Bash turns "/anomalous/x" into "C:/Program Files/Git/anomalous/x"; "anomalous/x" is meant the same.
const query = asked.replace(/^(?:[A-Za-z]:)?[^?]*?\/?(?=anomalous\/)/, '/');
if (query.startsWith('/anomalous')) return findRoute(index, query);
if (/\.(m?js)$/.test(query)) return findFile(index, query);
return findWords(index, query);
}
/** docs/ui-map.md: each page's files with their words and routes, the routes with their callers, and what looks unused. */
export async function build() {
const index = await buildIndex();
const lines = ['# AMB UI map (generated)', '', '由 `node tools/ui_map.mjs build` 从代码生成,不进版本库,别手改。查一个按钮用 `node tools/ui_map.mjs find 屏幕上的字`。', ''];
for (const [area] of AREAS) {
const files = index.files.filter(one => areaOf(one.file) === area);
if (!files.length) continue;
lines.push(`## ${area}`, '');
for (const one of files) {
const words = [...one.uses.keys()].map(key => `「${short(plain(index.text.zh[key]), 20)}」`);
lines.push(`### ${path.posix.basename(one.file)}`, '');
if (one.about) lines.push(short(one.about, 220), '');
if (words.length) lines.push(`- 文字(${words.length}):${words.slice(0, 14).join(' ')}${words.length > 14 ? ' …' : ''}`);
for (const call of one.calls) for (const route of call.routes.slice(0, 2)) lines.push(`- 调用 \`${route.method} ${route.route}\` → \`${route.file}\` ${route.func}(第 ${call.line} 行)`);
const from = index.importers.get(one.file) || [];
if (from.length) lines.push(`- 谁用它:${from.map(file => path.posix.basename(file)).join('、')}`);
lines.push('');
}
}
lines.push('## 后端接口和调用它的前端', '');
for (const route of index.routes) {
const callers = index.files.filter(one => one.calls.some(call => call.routes.includes(route))).map(one => path.posix.basename(one.file));
lines.push(`- \`${route.method} ${route.route}\` → \`${route.file}\` ${route.func}:${callers.join('、') || '前端没有调用'}`);
}
const unused = [...index.keys].filter(key => !index.files.some(one => one.uses.has(key)));
const lost = index.files.flatMap(one => one.calls.filter(call => !call.routes.length).map(call => `${one.file}:${call.line} ${call.shape}`));
lines.push('', '## 看起来没用上的', '', `- 没有文件用到的文字 key(${unused.length}):${unused.join('、') || '无'}`, `- 后端没有的地址(${lost.length}):${lost.join('、') || '无'}`, '');
fs.writeFileSync(path.join(ROOT, MAP), lines.join('\n'));
return [`写好了 ${MAP}:${index.files.length} 个前端文件,${index.routes.length} 个后端接口,${unused.length} 条没用上的文字,${lost.length} 个后端没有的地址。`];
}
/** What is wrong in docs/architecture/ui-flows.md (or `doc`, its text) against the code (check_structure.mjs fails on each). */
export async function check(doc = null) {
if (doc === null && !fs.existsSync(path.join(ROOT, FLOWS))) return [];
doc ??= fs.readFileSync(path.join(ROOT, FLOWS), 'utf8');
const index = await buildIndex();
const failures = [];
const zh = Object.values(index.text.zh).map(plain);
const patterns = zh.map(value => new RegExp(`^${value.split(/\{[^}]*\}/).map(part => part.replace(/[.*+?^$()|[\]\\]/g, '\\$&')).join('.+')}$`));
doc.split('\n').forEach((line, at) => {
const where = `${FLOWS}:${at + 1}`;
for (const [, quoted] of line.matchAll(/「([^」]+)」/g)) {
const text = plain(quoted).replace(/\s*[▾▸›]$/, '');
const prefix = text.endsWith('…') ? text.slice(0, -1) : null;
const found = prefix ? zh.some(value => value.startsWith(prefix)) : zh.includes(text) || patterns.some(pattern => pattern.test(text));
if (!found) failures.push(`${where}: 「${quoted}」 is not a word on screen now (web/modules/locales.js); say it as the page does`);
}
for (const [, token] of line.matchAll(/`([^`]+)`/g)) {
if (/^[\w./-]+\.(m?js|py|css)$/.test(token) && !fileExists(token)) failures.push(`${where}: \`${token}\` does not exist`);
if (token.startsWith('/anomalous/') && !index.routes.some(route => route.shape.startsWith(routeShape(token.replace(/^(GET|POST) /, ''))))) failures.push(`${where}: no backend route \`${token}\``);
}
});
// The glossary: one name for each thing; the names it retired are not on screen. A retired name
// may list where it is not that name ("配方(配方包、搭配方案除外)"); {placeholders} do not count.
const glossary = doc.split(/^## /m).find(part => part.startsWith('统一叫法')) || '';
for (const row of glossary.split('\n').filter(item => /^\|/.test(item) && !/^\|\s*[-:]/.test(item)).slice(1)) {
const cells = row.split('|').slice(1, -1).map(cell => cell.trim());
const retired = [[cells[2], 'zh'], [cells[3], 'en']];
for (const [list, lang] of retired) {
for (const entry of retiredNames(list)) {
const [, word, except = ''] = entry.match(/^([^(]+)(?:((.*)))?$/);
const others = except.replace(/除外|除了/g, '').split(/[、,,/]/).map(item => item.trim()).filter(Boolean);
const bare = value => others.reduce((text, other) => text.split(other).join(' '), String(value).replace(/\{[^}]*\}/g, ' '));
const test = lang === 'en' ? new RegExp(`\\b${word.trim()}\\b`, 'i') : null;
const hits = Object.entries(index.text[lang]).filter(([, value]) => (test ? test.test(bare(value)) : bare(value).includes(word.trim()))).map(([key]) => key);
if (hits.length) failures.push(`${FLOWS} glossary: "${word}" is retired for 「${cells[0]}」 but still on screen: ${hits.slice(0, 6).join(', ')}`);
}
}
}
return failures;
}
/** A glossary cell's retired names, split at 、 , , outside (…); "—" and 无 are none. */
function retiredNames(cell) {
const out = [''];
let depth = 0;
for (const char of String(cell || '').replace(/[「」`]/g, '')) {
if (char === '(') depth += 1;
if (char === ')') depth -= 1;
if (!depth && '、,,'.includes(char)) out.push('');
else out[out.length - 1] += char;
}
return out.map(item => item.trim()).filter(item => item && !/^[-—–无]+$/.test(item));
}
function fileExists(token) {
if (fs.existsSync(path.join(ROOT, token))) return true;
const name = path.posix.basename(token);
return ['web', 'web/modules', 'web/styles', 'api', 'tools', 'tests', '.'].some(dir => fs.existsSync(path.join(ROOT, dir, name)));
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const [command, ...rest] = process.argv.slice(2);
const run = { find: () => find(rest.join(' ')), build, check };
if (!run[command] || (command === 'find' && !rest.length)) {
console.log('node tools/ui_map.mjs find <屏幕上的字 | key | /anomalous/地址 | 文件名.js>\nnode tools/ui_map.mjs build\nnode tools/ui_map.mjs check');
process.exit(command ? 1 : 0);
}
const out = await run[command]();
console.log(out.join('\n') || 'ui-flows.md 和代码一致。');
if (command === 'check' && out.length) process.exit(1);
}