Files
DemonGatanjieu-Anomalous_Mo…/tools/ui_map.mjs
T
DemonGatanjieuandClaude Opus 5.5 342946141e Make the AI's questions and the waiting page say what to press
The user took an option for something to select and then pressed "skip"
to confirm; boxes shown open read as fields to fill.

- Questions (stage panels, the strip over the message box): options are
  buttons with an arrow under "One press sends it to the AI" and show the
  pressed one at once; "Let the AI decide" moves to the question's top
  right (the panel title for ask_user); a line of one's own is folded as
  "None of these, I'll say it…". The Gates card folds its reason the same.
- A waiting task is one page: the sentence for the AI leads and sticks
  under the task header while the page scrolls (the workbench measures the
  header); "Say what to do" and the gallery are optional folds that say
  how many pictures are in. The sentence has the AI do what the task says,
  asking only when it says nothing. The Get started / Put ready tabs and
  their words are gone.
- Gates window texts fixed (canvas covers building, lines are over the
  message box, "Ask me" named as on the button); run_workflow waits up to
  55 s by default, so a quick run comes back with its pictures.
- The UI map counts words the backend names (label_key) as used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:54:20 +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.serverKeys.has(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);
}