* feat(system): configure auto-tagging natively * test(system): compare Auto-tagging with the concept against the real backend
This commit is contained in:
+121
-4
@@ -1504,8 +1504,8 @@ targets of the longest legal path (1024 code points, even escaped as `\uXXXX`) f
|
||||
Its fields show the server's defaults (`GET /prompt_manager/autotag/models`, the
|
||||
D91 Suggestions count included) once that read lands; Start waits for it. A model
|
||||
the status lists as not downloaded keeps Start `aria-disabled` and
|
||||
`#tag-generation-status` says "<model> is not downloaded. Download it from the
|
||||
legacy Auto-tag page." (the server's own sentence), so no job starts only to fail.
|
||||
`#tag-generation-status` says "<model> is not downloaded. Download it in System ›
|
||||
Auto-tagging." (the server's own sentence), so no job starts only to fail.
|
||||
Start validates the fields as Generate does (native validity; nothing is sent).
|
||||
The concept comparison (`images-concept.spec.mjs`) uses the concept page itself as
|
||||
the oracle, with Add to collection and Remove links… removed and a copy of its Add
|
||||
@@ -2587,7 +2587,7 @@ the connection's `total_changes` is 0 at close and the data reads back unchanged
|
||||
`-shm` bytes are not compared: every WAL reader rewrites its read marks there, so no
|
||||
reader can keep it byte-identical (`test_health_check_never_writes`).
|
||||
|
||||
Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–D114; D121–D131 assigned):
|
||||
Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–D114; D121–D134 assigned):
|
||||
|
||||
- **D92** (shell): no "Sample configuration" chip. The Save status is truthful: "No
|
||||
unsaved changes.", "Unsaved changes.", "Saved in this browser." / "Saved to the server
|
||||
@@ -2621,7 +2621,8 @@ Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–
|
||||
output folder is used." when that is the case.
|
||||
- **D94** (all sections): a section not delivered yet shows the hand-off panel "Your
|
||||
current tools are ready" with a link to each legacy tool that still owns it (the
|
||||
plan's legacy coverage table); retired section by section, gone after S12.
|
||||
plan's legacy coverage table); retired section by section, gone after S12. Since S11
|
||||
(#328) only Diagnostics & logs hands off (`SECTION_HANDOFFS`, to the admin page).
|
||||
- **D95** (health report): the concept's `health-check` dialog titled "Health report",
|
||||
with no sample note. It lists every check in the server's order: Integrity check,
|
||||
Foreign keys & pair references, Canonical hashes, Pair uniqueness (added), Missing
|
||||
@@ -2997,6 +2998,59 @@ Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–
|
||||
dialog on that scope. Pinned by the thumbnail dialog comparison (mapped) and its
|
||||
mutation check (system-concept.spec) and `the thumbnail row counts outdated previews`
|
||||
(workspace-system.spec).
|
||||
- **D132** (Auto-tagging, S11, #328, OQ1/OQ5/OQ6/OQ10): "Tagging model" (`#tag-model`)
|
||||
lists the four real engines in the concept's order, each `<label> · <size_label>` from
|
||||
`GET /prompt_manager/autotag/models` ("WD14 SwinV2 · ~400 MB", "WD14 ViT · ~400 MB",
|
||||
"GGUF JoyCaption · ~4.5 GB", "HuggingFace JoyCaption · ~16 GB"; GGUF takes the
|
||||
concept's "Ollama" slot, as D61 does). Its description is the chosen engine's live
|
||||
status instead of the concept's design note: "Loading model status…" (the select is
|
||||
disabled until the read answers), "Model status could not be read.", "Not downloaded.
|
||||
Download size <size>.", "Downloaded. Not loaded.", "Downloaded and loaded." or "Partly
|
||||
downloaded. Download again to finish." (GGUF with one of its two files). The concept's
|
||||
two simulated job buttons are "Download" ("Download again" for an engine on disk),
|
||||
which starts the `model_download` Activity job and opens it, and "Unload", a direct
|
||||
`POST /autotag/unload` with a toast ("<label> unloaded.") and a fresh status read,
|
||||
enabled only while a model is loaded; a busy model's refusal reads "The tagging model
|
||||
is busy: <operation>. Try again when it finishes.". "Preview tagging review" opens the
|
||||
Tag image dialog for the image selected in Images with the section's current model and
|
||||
values; it is `aria-disabled` without a selection (the D115 sentence "Select an image
|
||||
in Images first.") or with an invalid value (the toast names the setting); a saved
|
||||
engine the section does not know reads "The saved tagging model is not one of these
|
||||
engines. Choose one and save preferences.". A part-downloaded GGUF offers "Download
|
||||
again"; an Unload that found nothing loaded only reads the status again. At phone width
|
||||
the concept's 120px select cap cuts every engine label, so `#tag-model` alone keeps its
|
||||
natural width there and its row wraps the select under the text (`workspace.css`
|
||||
`/* D132 */`); Existing tags stays as the concept draws it, cut at 390 as there. The
|
||||
comparison maps `selectsFit` only for the engine select and measures each row's
|
||||
`flexWrap` and whether its control sits under the text. Pinned by `auto-tagging matches
|
||||
the concept` (both themes and sizes), `auto-tagging at 390 wraps only the engine row`,
|
||||
`auto-tagging engine select fits at 320 wide` and the mutation check (it includes a
|
||||
wrapped row) (system-tagging-concept.spec), and the model, Download, Unload and Preview
|
||||
tests of workspace-system-tagging.spec.
|
||||
- **D133** (Auto-tagging, S11): a "Suggestions per image" row (`#tag-suggestions`,
|
||||
number 1–100, host key `autotag_suggestion_limit`) follows the two confidence rows:
|
||||
the persisted count the Tag image dialog's Suggestions field (D91) starts from. Pinned
|
||||
by the comparison's mapping (it inserts the row) and `defaults save through Save
|
||||
preferences and the Tag image dialog uses them`.
|
||||
- **D134** (Auto-tagging, S11, OQ2/OQ3/OQ7/OQ8): an `.operation` "Tag output images"
|
||||
with "Review" follows `.row-actions`: the legacy prompt-tag batch. Review is
|
||||
`aria-disabled` (the press explains) while the chosen engine is not downloaded or a
|
||||
value it uses is invalid; otherwise it posts `/autotag/batch/preview` with the drafted
|
||||
engine and policy (read-only) and opens "Review tag output images" in the concept's
|
||||
record-changing review (its `maintenancePlan` shell): `h3` "Tag output images", the
|
||||
`dl.meta-grid` Model, Existing tags, Images linked, Prompts, Already tagged, Prompts
|
||||
to tag ("up to N"), Settings ("General <g> · Character <c>" or "Template of N
|
||||
characters") and Backup, the upper-bound sentence in the `p.small`, the D55 backup box
|
||||
and Cancel / "Start tagging", which starts the cancellable `autotag` job and opens it.
|
||||
Replace after review, a model not on disk, a short disk, no linked image and nothing to
|
||||
tag each show the rows with only Cancel and the reason ("Replacing tags needs a review
|
||||
of each image. Use Preview tagging review, or choose Skip or Merge for a batch run.",
|
||||
"Every prompt with an output image is already tagged.", "Not enough free disk space:
|
||||
the safety backup needs about X MB and Y MB is free. Nothing is written until there is
|
||||
room.") in the close-only review Integrations shares (`openCloseOnlyReview`). Pinned by
|
||||
`tag output images review matches the concept`, its mutation check and `the
|
||||
Cancel-only review matches the concept review shell` (system-tagging-concept.spec) and
|
||||
the batch tests of workspace-system-tagging.spec.
|
||||
- **D124** (inspector): with "Load metadata on selection" off, the Images panel and
|
||||
the viewer's details show "Metadata loads when you ask for it." and a "Load metadata"
|
||||
button instead of reading the file. The concept has no such state, so no concept
|
||||
@@ -3462,6 +3516,69 @@ npm run --prefix tools/vendor test:workflows -- workspace-system-integrations.sp
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
### Auto-tagging contract (#328, #249 System S11)
|
||||
|
||||
System › Auto-tagging (`web/js/workspace/system-tagging.mjs`, D132–D134) edits one
|
||||
server-side tagging configuration, `autotag.defaults` in config.json: `model_type`
|
||||
(`wd14-swinv2`, `wd14-vit`, `gguf`, `hf`), `existing` (`skip`, `merge`,
|
||||
`replace_after_review`), `general_threshold` and `character_threshold` (0–1),
|
||||
`template` (non-blank, at most 4,000 characters and 16 KiB) and `suggestion_limit`
|
||||
(1–100). They are host drafts (`autotag_model_type`, `autotag_existing`,
|
||||
`autotag_general_threshold`, `autotag_character_threshold`, `autotag_template`,
|
||||
`autotag_suggestion_limit`) saved by the one Save row through `POST
|
||||
/prompt_manager/settings` with `expected_revision`; a bad value is refused with a 400
|
||||
naming the setting by its row label ("General-tag confidence must be between 0 and
|
||||
1."), nothing is written, the Save row shows that sentence (`invalid-setting`, set by
|
||||
`settingRefusal` in system.mjs only for a save that carried `autotag_*` keys; every
|
||||
other section's plain 400 keeps the generic status) and the control is highlighted.
|
||||
The template's limit counts code points, as Python's `len` does. A hand-edited bad value
|
||||
falls back to its default on load, one key at a time. `GET /autotag/models` reports the
|
||||
saved values (`wd14_general_threshold`, `wd14_character_threshold`, `suggestion_limit`,
|
||||
`default_prompt`, `model_type`, `existing`), so the Tag image and Auto-tag selected
|
||||
dialogs start from them; a preset (the bulk review, Preview tagging review) wins.
|
||||
|
||||
`GET /prompt_manager/autotag/models` reads the disk in the executor, never loads or
|
||||
downloads a model and carries no absolute path: per engine `name`, `label`,
|
||||
`size_label`, `description`, `size`, `downloaded`, `loaded` (GGUF also `model_exists`,
|
||||
`mmproj_exists`), plus `model_loaded` and `loaded_model_type`. Opening the section
|
||||
makes exactly one such read per mount (`entering the section makes no model call
|
||||
besides status`).
|
||||
|
||||
One model gate (`py/model_gate.py`) covers download, unload, both batches, the legacy
|
||||
single-image tag, Generate suggestions and Auto-tag selected. A refusal is `409
|
||||
model-busy` with `operation`, the holder's label: the download job's title, "Tag output
|
||||
images", "Tag output images (legacy page)", "Tagging one image (legacy page)",
|
||||
"Unloading the tagging model", "Auto-tag selected images", "Generate suggestions", else
|
||||
"Another tagging operation". `ApiError.operation` keeps it (only on `model-busy`).
|
||||
|
||||
- **Download**: `POST /prompt_manager/jobs/model-download {model_type}` starts the
|
||||
`model_download` job (202), title "Tagging model download · <label>", admission
|
||||
`read`, not cancellable, indeterminate (Activity's generic held view: no Cancel, a
|
||||
`<progress>` without a value), holding the gate until it ends. A second download is
|
||||
`409 busy`; the legacy SSE route attaches to the same job and keeps its frames.
|
||||
- **Unload**: `POST /prompt_manager/autotag/unload` answers `{success, message}` or
|
||||
`409 model-busy`.
|
||||
- **Tag output images**: `POST /prompt_manager/autotag/batch/preview {model_type,
|
||||
existing}` writes nothing and opens no file; it answers `images`, `prompts`,
|
||||
`already_tagged`, `to_tag` (an upper bound), `model {type, label, size_label,
|
||||
downloaded}`, `existing`, `review_required`, `backup {policy}` and `disk`. `POST
|
||||
/prompt_manager/jobs/autotag {model_type, existing, options}` (WD14: both thresholds;
|
||||
GGUF/HF: `instructions`, the template) starts the cancellable `autotag` job (202),
|
||||
title "Tag output images", admission `records`, a safety backup by the policy first.
|
||||
Refusals: `400 review-required` (Replace after review never runs as a batch), `409
|
||||
model-unavailable` ("<label> is not downloaded. Download it in System ›
|
||||
Auto-tagging."), `507 insufficient-space`, `409 busy`, `409 model-busy`. The section
|
||||
toasts `model-busy` with its operation, the other three with the server's sentence,
|
||||
`busy` with the running job (D57), anything else "<what> could not start. Try again.".
|
||||
Outcomes: Tagged / Unchanged / Skipped / Errors (the `image-tags` descriptor, D111).
|
||||
|
||||
No browser test downloads or runs a model: the injected `TestModel` answers, its
|
||||
`download_model` raises unless `/__test__/tagging/download {mode: hold | succeed |
|
||||
fail}` installs a fake that waits on a fixture event, and every tagging reset releases
|
||||
it. Every workspace-system-tagging test asserts no `/autotag/download/` request.
|
||||
Changing the routes or `py/config.py` needs a ComfyUI restart; the section itself is
|
||||
HTML, CSS and JS only.
|
||||
|
||||
### Browser cache ownership (#222)
|
||||
|
||||
The legacy gallery's Clear cache used to delete every localStorage key starting `pm_` or
|
||||
|
||||
@@ -27,7 +27,7 @@ _COMMON = frozenset({"suggestion_limit", "use_gpu"})
|
||||
_WD14_REQUIRED = frozenset({"general_threshold", "character_threshold"})
|
||||
_LLM_REQUIRED = frozenset({"instructions"})
|
||||
|
||||
UNAVAILABLE_HINT = "Download it from the legacy Auto-tag page."
|
||||
UNAVAILABLE_HINT = "Download it in System › Auto-tagging."
|
||||
|
||||
|
||||
class OptionError(ValueError):
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
|
||||
|
||||
// #328 System S11 Task 8: System › Auto-tagging (app.js renderSystem 685–686) and the Tag output images review against
|
||||
// the immutable concept, on the concept-shaped fixture with the fake tagging model (every engine downloaded, none
|
||||
// loaded). Each concept measurement is mapped only by the recorded deviations (D132, D133, D134), each commented where
|
||||
// it applies; after that the difference must be empty, and a mutated label, option, order, class, select width or
|
||||
// token must be noticed. The concept has no batch review, so the Tag output images review is compared with the
|
||||
// concept's own record-changing review, `maintenancePlan` (953–958), whose shell, grid, note and actions it shares.
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC' });
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
// The tagging reset restores the fake model (nothing loaded, every engine on disk) and the saved tagging defaults;
|
||||
// the concept fixture then replaces its rows and files.
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
|
||||
await seedConcept(request);
|
||||
await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
page.conceptErrors = [];
|
||||
page.on('pageerror', error => page.conceptErrors.push(error.message));
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, request, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
await request.post('/__test__/jobs/restart');
|
||||
await request.post('/__test__/system/reset');
|
||||
await request.post('/__test__/tagging/reset', { data: {} });
|
||||
expect(page.conceptErrors).toEqual([]);
|
||||
});
|
||||
|
||||
const PREVIEW_URL = '/prompt_manager/autotag/batch/preview';
|
||||
const MODEL_OPTIONS = ['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB'];
|
||||
const SUGGESTIONS = 'From 1 to 100: the most suggestions Generate shows for one image.';
|
||||
const BATCH = 'Add tags to each prompt from its linked output images with the model above. Review the scope first.';
|
||||
|
||||
/** Runs in the page: Auto-tagging's surface under `mainSelector`: rows, field, actions, operation, geometry and tokens. */
|
||||
function measureTagging(mainSelector) {
|
||||
const layout = document.querySelector(`${mainSelector} .settings-layout`);
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
const text = node => node?.textContent.trim() ?? null;
|
||||
const control = row => {
|
||||
const select = row.querySelector(':scope > select');
|
||||
if (select) return { kind: 'select', id: select.id, options: [...select.options].map(option => [option.textContent, option.disabled]),
|
||||
selected: select.selectedOptions[0]?.textContent ?? null, disabled: select.disabled };
|
||||
const input = row.querySelector(':scope > input');
|
||||
return input ? { kind: `input.${input.type}`, id: input.id, value: input.value, disabled: input.disabled } : null;
|
||||
};
|
||||
const surface = layout.querySelector(':scope > section.surface');
|
||||
const firstRow = surface.querySelector('.setting-row');
|
||||
const field = surface.querySelector(':scope > .field');
|
||||
const actions = surface.querySelector(':scope > .row-actions');
|
||||
return {
|
||||
layout: [...layout.children].map(name),
|
||||
tree: [...surface.children].map(name),
|
||||
heading: text(surface.querySelector(':scope > h2')),
|
||||
intro: text(surface.querySelector(':scope > p')),
|
||||
// Per row: whether it may wrap, and whether its control sits under the text (a wrapped row) or beside it.
|
||||
rows: [...surface.querySelectorAll(':scope > .setting-row')].map(row => ({ tree: [...row.children].map(name),
|
||||
text: [...row.firstElementChild.children].map(node => [name(node), text(node), node.localName === 'label' ? node.htmlFor : null]),
|
||||
control: control(row), flexWrap: getComputedStyle(row).flexWrap,
|
||||
controlBelow: row.lastElementChild.getBoundingClientRect().top >= row.firstElementChild.getBoundingClientRect().bottom - 0.5 })),
|
||||
field: { tree: [...field.children].map(name), label: [text(field.querySelector('label')), field.querySelector('label').htmlFor],
|
||||
textarea: { id: field.querySelector('textarea').id, value: field.querySelector('textarea').value,
|
||||
...css(field.querySelector('textarea'), 'fontSize', 'minHeight', 'backgroundColor', 'color', 'borderTopColor') },
|
||||
...css(field, 'display', 'rowGap', 'fontSize', 'marginTop', 'marginBottom') },
|
||||
actions: { buttons: [...actions.children].map(item => [text(item), item.className]),
|
||||
...css(actions, 'display', 'flexWrap', 'columnGap', 'justifyContent'),
|
||||
primary: css(actions.querySelector('.ca-primary'), 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft'),
|
||||
plain: css(actions.querySelector('button:not(.ca-primary)'), 'backgroundColor', 'color', 'fontSize', 'borderTopColor') },
|
||||
operations: [...surface.querySelectorAll(':scope > .operation')].map(row => ({ tree: [...row.children].map(name),
|
||||
title: text(row.querySelector('h3')), description: text(row.querySelector('p')),
|
||||
actions: [...row.querySelectorAll('.row-actions > button')].map(item => [text(item), item.className]) })),
|
||||
surface: css(surface, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius'),
|
||||
row: css(firstRow, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom', 'borderTopWidth',
|
||||
'borderTopColor', 'marginTop'),
|
||||
label: css(firstRow.querySelector('label'), 'fontSize', 'fontWeight', 'color'),
|
||||
description: css(firstRow.querySelector('p'), 'fontSize', 'color', 'marginTop', 'maxWidth'),
|
||||
select: css(firstRow.querySelector('select'), 'fontSize', 'minWidth', 'backgroundColor', 'color', 'borderTopColor'),
|
||||
number: css(surface.querySelector('.setting-row input[type="number"]'), 'fontSize', 'minWidth', 'maxWidth', 'backgroundColor',
|
||||
'color', 'borderTopColor'),
|
||||
// Whether each select is as wide as the browser lays out a copy holding only its chosen option, i.e. it shows
|
||||
// that option whole (the browser's own select sizing, arrow included).
|
||||
selectsFit: [...surface.querySelectorAll('.setting-row > select')].map(select => {
|
||||
const copy = select.cloneNode(false);
|
||||
copy.removeAttribute('id');
|
||||
copy.append(new Option(select.selectedOptions[0]?.textContent ?? ''));
|
||||
// Important inline values: the phone cap is an !important rule (concept.css), which a plain inline style loses to.
|
||||
for (const [key, value] of [['position', 'absolute'], ['visibility', 'hidden'], ['width', 'max-content'], ['min-width', '0'],
|
||||
['max-width', 'none'], ['flex', 'none']]) copy.style.setProperty(key, value, 'important');
|
||||
select.parentElement.append(copy);
|
||||
const needed = copy.getBoundingClientRect().width;
|
||||
copy.remove();
|
||||
return select.getBoundingClientRect().width + 0.5 >= needed;
|
||||
}),
|
||||
pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The concept's Auto-tagging with the recorded deviations applied, for the fixture (all engines on disk, none loaded).
|
||||
* `phone` is a viewport at the concept's 700px breakpoint or below.
|
||||
*/
|
||||
function mapTaggingConcept(reference, { template, phone }) {
|
||||
const [model, existing, general, character] = reference.rows;
|
||||
const [preview] = reference.actions.buttons;
|
||||
return {
|
||||
...reference,
|
||||
// D133 adds a row after the thresholds; D134 adds the operation after the actions.
|
||||
tree: [...reference.tree.slice(0, 6), 'div.setting-row', ...reference.tree.slice(6), 'div.operation'],
|
||||
rows: [
|
||||
// D132 (OQ5, OQ6): the four real engines with their download size, and the chosen one's live status.
|
||||
{ ...model, text: [model.text[0], ['p', 'Downloaded. Not loaded.', null]],
|
||||
control: { ...model.control, options: MODEL_OPTIONS.map(label => [label, false]), selected: MODEL_OPTIONS[0] },
|
||||
// D132: at phone width the row wraps, so the engine select sits whole under its text.
|
||||
...(phone ? { flexWrap: 'wrap', controlBelow: true } : {}) },
|
||||
existing, general, character,
|
||||
// D133: the persisted suggestion count, on the concept's number row.
|
||||
{ ...general, text: [['label', 'Suggestions per image', 'tag-suggestions'], ['p', SUGGESTIONS, null]],
|
||||
control: { ...general.control, id: 'tag-suggestions', value: '10' } },
|
||||
],
|
||||
// The saved template (the server's default until one is saved) replaces the concept's sample text.
|
||||
field: { ...reference.field, textarea: { ...reference.field.textarea, value: template } },
|
||||
// D132: Download (for an engine already on disk, "Download again") and Unload replace the two simulated jobs.
|
||||
actions: { ...reference.actions, buttons: [preview, ['Download again', ''], ['Unload', '']] },
|
||||
// D134: the prompt-tag batch, reviewed first.
|
||||
operations: [{ tree: ['div', 'div.row-actions'], title: 'Tag output images', description: BATCH, actions: [['Review', '']] }],
|
||||
// D132: the engine select shows its whole chosen option at every width (at 390 the concept's 120px cap cuts it;
|
||||
// `#tag-model` alone may grow there). Existing tags keeps the concept's own fit, cut or not.
|
||||
selectsFit: reference.selectsFit.map((fit, index) => (index === 0 ? true : fit)),
|
||||
};
|
||||
}
|
||||
|
||||
async function conceptTagging(page, theme) {
|
||||
await openConcept(page, 'system', theme);
|
||||
await page.locator('#main .section-nav').getByRole('button', { name: 'Auto-tagging', exact: true }).click();
|
||||
await expect(page.locator('#main #tag-template')).toBeVisible();
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureTagging, '#main');
|
||||
}
|
||||
|
||||
async function productionTagging(page, theme) {
|
||||
await openProduction(page, 'system/tagging', theme);
|
||||
// Compare once the settings and the model status answered.
|
||||
await expect(page.locator('#workspace-main #tag-model')).toBeEnabled();
|
||||
await expect(page.locator('#workspace-main .setting-row').first().locator('p')).toHaveText('Downloaded. Not loaded.');
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureTagging, '#workspace-main');
|
||||
}
|
||||
|
||||
async function savedTemplate(request, page) {
|
||||
const response = await request.get('/prompt_manager/settings');
|
||||
await expect(response).toBeOK();
|
||||
return { template: (await response.json()).settings.autotag_template, phone: page.viewportSize().width <= 700 };
|
||||
}
|
||||
|
||||
for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||||
test(`auto-tagging matches the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
const concept = await conceptTagging(page, theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`concept-tagging-${theme}-${width}.png`), fullPage: true });
|
||||
const production = await productionTagging(page, theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`production-tagging-${theme}-${width}.png`), fullPage: true });
|
||||
expect(diffInventory(mapTaggingConcept(concept, await savedTemplate(request, page)), production)).toEqual([]);
|
||||
expect(production.pageScroll).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
// D132 below the concept's smallest size: on a 320px phone the engine select still shows its whole option.
|
||||
for (const theme of THEMES) test(`auto-tagging engine select fits at 320 wide ${theme}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 700 });
|
||||
const production = await productionTagging(page, theme);
|
||||
for (const model of ['hf', 'gguf', 'wd14-swinv2']) {
|
||||
await page.locator('#workspace-main #tag-model').selectOption(model);
|
||||
expect((await page.evaluate(measureTagging, '#workspace-main')).selectsFit[0], model).toBe(true);
|
||||
}
|
||||
expect(production.pageScroll).toBe(false);
|
||||
});
|
||||
|
||||
test('auto-tagging comparison detects mutations', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const concept = await conceptTagging(page, 'dark');
|
||||
const saved = await savedTemplate(request, page);
|
||||
const expected = mapTaggingConcept(concept, saved);
|
||||
const production = await productionTagging(page, 'dark');
|
||||
expect(diffInventory(expected, production)).toEqual([]);
|
||||
// Unmapped, the section differs only where D132, D133 and D134 are recorded (and the saved template's text).
|
||||
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 3).join('.'))
|
||||
.filter((path, index, all) => all.indexOf(path) === index).sort()).toEqual([
|
||||
'actions.buttons.1', 'actions.buttons.2', 'field.textarea.value', 'operations.0.actions', 'operations.0.description',
|
||||
'operations.0.title', 'operations.0.tree', 'operations.length', 'rows.0.control', 'rows.0.text', 'rows.4.control', 'rows.4.controlBelow',
|
||||
'rows.4.flexWrap', 'rows.4.text', 'rows.4.tree', 'rows.length', 'tree.6', 'tree.7', 'tree.8', 'tree.9', 'tree.length'].sort());
|
||||
const measure = () => page.evaluate(measureTagging, '#workspace-main');
|
||||
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
|
||||
// A removed row.
|
||||
const undoRow = await mutate(page, { remove: ['#workspace-main section.surface > .setting-row:nth-of-type(2)'] });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['rows.length', 'tree.length']));
|
||||
await undoRow();
|
||||
// A renamed label.
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main label[for="tag-existing"]').textContent = 'Existing'; });
|
||||
expect(await paths()).toEqual(['rows.1.text.0.1']);
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main label[for="tag-existing"]').textContent = 'Existing tags'; });
|
||||
// A reordered option.
|
||||
await page.evaluate(() => { const select = document.querySelector('#workspace-main #tag-existing'); select.append(select.options[0]); });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['rows.1.control.options.0.0']));
|
||||
await page.evaluate(() => { const select = document.querySelector('#workspace-main #tag-existing'); select.prepend(select.options[2]); });
|
||||
// ca-primary moved to another action.
|
||||
await page.evaluate(() => {
|
||||
const [first, second] = document.querySelectorAll('#workspace-main section.surface > .row-actions > button');
|
||||
first.classList.remove('ca-primary');
|
||||
second.classList.add('ca-primary');
|
||||
});
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['actions.buttons.0.1', 'actions.buttons.1.1']));
|
||||
await page.evaluate(() => {
|
||||
const [first, second] = document.querySelectorAll('#workspace-main section.surface > .row-actions > button');
|
||||
second.classList.remove('ca-primary');
|
||||
first.classList.add('ca-primary');
|
||||
});
|
||||
// A select squeezed until its chosen option is cut.
|
||||
const undoSqueeze = await mutate(page, { css: '#workspace-main #tag-model { max-width: 60px !important; min-width: 0 !important; }' });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['selectsFit.0']));
|
||||
await undoSqueeze();
|
||||
// A row that wraps where the concept's does not.
|
||||
const undoWrap = await mutate(page, { css: '#workspace-main .setting-row:has(> #tag-existing) { flex-wrap: wrap; }' });
|
||||
expect(await paths()).toEqual(['rows.1.flexWrap']);
|
||||
await undoWrap();
|
||||
// A swapped token.
|
||||
const undoToken = await mutate(page, { css: '#workspace-main .settings-layout { --line: rgb(255, 0, 0); }' });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['row.borderTopColor']));
|
||||
await undoToken();
|
||||
expect(diffInventory(expected, await measure())).toEqual([]);
|
||||
});
|
||||
|
||||
// D132 at phone width, unmapped: only the engine row wraps; Existing tags keeps the concept's cut select.
|
||||
test('auto-tagging at 390 wraps only the engine row', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const concept = await conceptTagging(page, 'dark');
|
||||
const production = await productionTagging(page, 'dark');
|
||||
expect(diffInventory(mapTaggingConcept(concept, await savedTemplate(request, page)), production)).toEqual([]);
|
||||
expect(production.rows.map(row => row.flexWrap)).toEqual(['wrap', 'nowrap', 'nowrap', 'nowrap', 'nowrap']);
|
||||
expect(production.selectsFit[1]).toBe(concept.selectsFit[1]);
|
||||
});
|
||||
|
||||
/** Runs in the page: the open review dialog: its title, body and the concept's tokens. */
|
||||
function measureReview() {
|
||||
const dialog = document.querySelector('dialog#modal[open]');
|
||||
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
|
||||
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
|
||||
const text = node => node?.textContent.trim() ?? null;
|
||||
const body = dialog.querySelector('#modal-body');
|
||||
const grid = body.querySelector(':scope > dl.meta-grid');
|
||||
const note = [...body.querySelectorAll(':scope > p.small')].at(-1);
|
||||
const actions = body.querySelector(':scope > .dialog-actions');
|
||||
return {
|
||||
title: text(dialog.querySelector('#modal-title')),
|
||||
tree: [...body.children].map(name),
|
||||
heading: { text: text(body.querySelector(':scope > h3')),
|
||||
...css(body.querySelector(':scope > h3'), 'fontSize', 'fontWeight', 'color', 'marginBottom') },
|
||||
grid: { rows: [...grid.children].map(row => [text(row.querySelector('dt')), text(row.querySelector('dd'))]),
|
||||
...css(grid, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'),
|
||||
dt: css(grid.querySelector('dt'), 'fontSize', 'color', 'textTransform'), dd: css(grid.querySelector('dd'), 'fontSize', 'color') },
|
||||
backup: body.querySelector(':scope > .notice') && { tree: [...body.querySelector(':scope > .notice').children].map(name),
|
||||
text: [...body.querySelectorAll(':scope > .notice > div:first-child > *')].map(node => [node.localName, text(node)]),
|
||||
buttons: [...body.querySelectorAll(':scope > .notice button')].map(text) },
|
||||
note: { text: text(note), ...css(note, 'fontSize', 'color', 'marginTop') },
|
||||
actions: { tree: [...actions.children].map(name), ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
|
||||
buttons: [...actions.children].map(item => ({ text: text(item), className: item.className,
|
||||
...css(item, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') })) },
|
||||
};
|
||||
}
|
||||
|
||||
const number = value => value.toLocaleString('en-US');
|
||||
|
||||
/**
|
||||
* D134: the server's batch preview in the concept's review: "Review tag output images" with "Tag output images" as its
|
||||
* `h3`, the model, policy, counts and settings in the concept's `dl.meta-grid` (Backup in the concept's wording), the
|
||||
* upper-bound sentence in the concept's `p.small`, the D55 backup box and Cancel / "Start tagging".
|
||||
*/
|
||||
function mapBatchConcept(reference, plan) {
|
||||
const [cancel, start] = reference.actions.buttons;
|
||||
return {
|
||||
...reference,
|
||||
title: 'Review tag output images',
|
||||
tree: ['h3', 'dl.meta-grid', 'p.small', 'div.notice', 'div.dialog-actions'],
|
||||
heading: { ...reference.heading, text: 'Tag output images' },
|
||||
grid: { ...reference.grid, rows: [['Model', 'WD14 SwinV2 · ~400 MB'], ['Existing tags', 'Merge with existing'],
|
||||
['Images linked', number(plan.images)], ['Prompts', number(plan.prompts)], ['Already tagged', number(plan.already_tagged)],
|
||||
['Prompts to tag', `up to ${number(plan.to_tag)}`], ['Settings', 'General 0.35 · Character 0.85'],
|
||||
['Backup', 'Create and verify before changes']] },
|
||||
backup: { tree: ['div', 'div.row-actions'], text: [['h3', 'Back up the database first (recommended)'],
|
||||
['p', 'Downloads a copy of the prompts database before this operation changes any records.']], buttons: ['Back up now'] },
|
||||
note: { ...reference.note, text: 'Nothing is written until you start. Missing files and files outside the output folders '
|
||||
+ 'are counted, so the prompts to tag are an upper bound; Activity reports what was tagged.' },
|
||||
actions: { ...reference.actions, buttons: [cancel, { ...start, text: 'Start tagging' }] },
|
||||
};
|
||||
}
|
||||
|
||||
async function conceptReview(page, theme) {
|
||||
await openConcept(page, 'system', theme);
|
||||
await page.locator('#main .section-nav').getByRole('button', { name: 'Maintenance', exact: true }).click();
|
||||
await page.locator('#main [data-plan="duplicate prompts"]').click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review maintenance plan');
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureReview);
|
||||
}
|
||||
|
||||
async function productionReview(page, theme) {
|
||||
await productionTagging(page, theme);
|
||||
// Every concept-fixture prompt with an image is already tagged, so under the saved Skip there is nothing to start
|
||||
// (the Cancel-only review). Merge reviews a batch with work: the full review the concept's shell is compared with.
|
||||
await page.locator('#workspace-main #tag-existing').selectOption('merge');
|
||||
const answer = page.waitForResponse(response => response.url().endsWith(PREVIEW_URL));
|
||||
await page.locator('#workspace-main .operation').getByRole('button', { name: 'Review', exact: true }).click();
|
||||
const plan = await (await answer).json();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review tag output images');
|
||||
await page.mouse.move(0, 0);
|
||||
return { plan, production: await page.evaluate(measureReview) };
|
||||
}
|
||||
|
||||
for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||||
test(`tag output images review matches the concept ${theme} ${width}x${height}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
const concept = await conceptReview(page, theme);
|
||||
expect(concept.tree).toEqual(['p.demo-note', 'h3', 'dl.meta-grid', 'p.small', 'div.dialog-actions']);
|
||||
await page.screenshot({ path: testInfo.outputPath(`concept-plan-review-${theme}-${width}.png`) });
|
||||
const { plan, production } = await productionReview(page, theme);
|
||||
// Under Merge every prompt with a linked image is tagged again: the batch has work to review.
|
||||
expect(plan.to_tag).toBeGreaterThan(0);
|
||||
await page.screenshot({ path: testInfo.outputPath(`production-tagging-review-${theme}-${width}.png`) });
|
||||
expect(diffInventory(mapBatchConcept(concept, plan), production)).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* D134 (CUJ 5): Replace after review never runs as a batch: the same review shell with the counts, the reason as its
|
||||
* `p.small`, no backup box and only Cancel (the shared close-only review Integrations also uses).
|
||||
*/
|
||||
function mapCancelOnlyConcept(reference, plan) {
|
||||
const [cancel] = reference.actions.buttons;
|
||||
const mapped = mapBatchConcept(reference, plan);
|
||||
return {
|
||||
...mapped,
|
||||
tree: ['h3', 'dl.meta-grid', 'p.small', 'div.dialog-actions'],
|
||||
grid: { ...mapped.grid, rows: mapped.grid.rows.map(([term, value]) => [term, term === 'Existing tags' ? 'Replace after review' : value]) },
|
||||
backup: null,
|
||||
note: { ...reference.note, text: 'Replacing tags needs a review of each image. Use Preview tagging review, or choose Skip or '
|
||||
+ 'Merge for a batch run.' },
|
||||
actions: { ...reference.actions, tree: [reference.actions.tree[0]], buttons: [cancel] },
|
||||
};
|
||||
}
|
||||
|
||||
test('the Cancel-only review matches the concept review shell', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const concept = await conceptReview(page, 'dark');
|
||||
await productionTagging(page, 'dark');
|
||||
await page.locator('#workspace-main #tag-existing').selectOption('replace_after_review');
|
||||
const answer = page.waitForResponse(response => response.url().endsWith(PREVIEW_URL));
|
||||
await page.locator('#workspace-main .operation').getByRole('button', { name: 'Review', exact: true }).click();
|
||||
const plan = await (await answer).json();
|
||||
expect(plan.review_required).toBe(true);
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review tag output images');
|
||||
await page.mouse.move(0, 0);
|
||||
expect(diffInventory(mapCancelOnlyConcept(concept, plan), await page.evaluate(measureReview))).toEqual([]);
|
||||
});
|
||||
|
||||
test('tag output images review comparison detects mutations', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const concept = await conceptReview(page, 'dark');
|
||||
const { plan } = await productionReview(page, 'dark');
|
||||
const expected = mapBatchConcept(concept, plan);
|
||||
const paths = async () => diffInventory(expected, await page.evaluate(measureReview)).map(item => item.path);
|
||||
expect(await paths()).toEqual([]);
|
||||
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[5].textContent = 'To tag'; });
|
||||
expect(await paths()).toEqual(['grid.rows.5.0']);
|
||||
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[5].textContent = 'Prompts to tag'; });
|
||||
const undoGrid = await mutate(page, { css: 'dialog#modal dl.meta-grid { column-gap: 3px; }' });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['grid.columnGap']));
|
||||
await undoGrid();
|
||||
const undoBox = await mutate(page, { remove: ['dialog#modal[open] #modal-body > .notice'] });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['backup', 'tree.length']));
|
||||
await undoBox();
|
||||
expect(await paths()).toEqual([]);
|
||||
});
|
||||
@@ -236,7 +236,7 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
await generateWith(page, 'production').catch(() => {});
|
||||
await expect(status()).toHaveText('WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.');
|
||||
await expect(status()).toHaveText('WD14 SwinV2 is not downloaded. Download it in System › Auto-tagging.');
|
||||
await shot('production-unavailable');
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: [] } })).toBeOK();
|
||||
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
// System › Auto-tagging (#328, System S11 Tasks 6–7) on the tagging fixture (workspace_tagging_fixture.py) with the
|
||||
// injected fake model: no model file exists and nothing is downloaded or run for real. The fake download waits on the
|
||||
// fixture (`/__test__/tagging/download {mode}`), and every reset releases it. Every test asserts that no legacy
|
||||
// download stream (`/autotag/download/`) was requested: the section downloads only through the Activity job.
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const MODELS_URL = '/prompt_manager/autotag/models';
|
||||
const PREVIEW_URL = '/prompt_manager/autotag/batch/preview';
|
||||
const SETTINGS_URL = '/prompt_manager/settings';
|
||||
const MODEL_LABELS = ['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB'];
|
||||
const FIRST = 'tagging/dunes_00001.png';
|
||||
const SECOND = 'tagging/dunes_00002.png';
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
|
||||
await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
page.systemErrors = [];
|
||||
page.on('pageerror', error => page.systemErrors.push(error.message));
|
||||
page.downloads = [];
|
||||
page.modelCalls = [];
|
||||
page.on('request', item => {
|
||||
const { pathname } = new URL(item.url());
|
||||
if (pathname.includes('/autotag/download/')) page.downloads.push(pathname);
|
||||
if (pathname.startsWith('/prompt_manager/autotag/') || pathname.startsWith('/prompt_manager/image-annotations/suggest')
|
||||
|| pathname.startsWith('/prompt_manager/jobs/model-download')) page.modelCalls.push(`${item.method()} ${pathname}`);
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, request, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
// The reset releases a held fake download, so no runner outlives the test.
|
||||
await request.post('/__test__/tagging/reset', { data: {} });
|
||||
await request.post('/__test__/jobs/restart');
|
||||
await request.post('/__test__/system/reset');
|
||||
expect(page.systemErrors).toEqual([]);
|
||||
expect(page.downloads).toEqual([]);
|
||||
});
|
||||
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const layoutOf = page => page.locator('#workspace-main .settings-layout');
|
||||
const dialog = page => page.locator('dialog#modal[open]');
|
||||
const status = page => page.locator('#settings-status');
|
||||
const save = page => page.getByRole('button', { name: 'Save preferences', exact: true });
|
||||
const surface = page => layoutOf(page).locator(':scope > section.surface');
|
||||
const settingOf = (page, label) => layoutOf(page).locator('.setting-row').filter({ has: page.locator(`label:text-is("${label}")`) });
|
||||
const modelRow = page => settingOf(page, 'Tagging model');
|
||||
const actionOf = (page, name) => surface(page).locator(':scope > .row-actions').getByRole('button', { name, exact: true });
|
||||
const reviewButton = page => surface(page).locator('.operation').getByRole('button', { name: 'Review', exact: true });
|
||||
const card = (page, path) => page.locator(`article[data-image-key="0:${path}"]`);
|
||||
const tagDialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
|
||||
const gridOf = page => dialog(page).locator('dl.meta-grid > div').evaluateAll(items => items.map(item =>
|
||||
[item.querySelector('dt').textContent, item.querySelector('dd').textContent]));
|
||||
|
||||
async function serverJson(request, url) {
|
||||
const response = await request.get(url);
|
||||
await expect(response).toBeOK();
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fakeDownload(request, mode) {
|
||||
await expect(await request.post('/__test__/tagging/download', { data: { mode } })).toBeOK();
|
||||
}
|
||||
|
||||
async function unavailable(request, models) {
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: models } })).toBeOK();
|
||||
}
|
||||
|
||||
async function ended(request, id) {
|
||||
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).not.toBe('running');
|
||||
return (await serverJson(request, `/prompt_manager/jobs/${id}`)).job;
|
||||
}
|
||||
|
||||
/** System › Auto-tagging on a fresh page (the drafts live for the page session, Q10), settled on the model status. */
|
||||
async function openTagging(page) {
|
||||
if (page.url().endsWith('#/system/tagging')) await page.reload();
|
||||
else await page.goto(`${workspace}#/system/tagging`);
|
||||
await expect(surface(page)).toHaveCount(1);
|
||||
await expect(page.locator('#tag-model')).toBeEnabled();
|
||||
await expect(modelRow(page).locator('p').first()).not.toHaveText('Loading model status…');
|
||||
}
|
||||
|
||||
/** Select `path` in Images (the concept's `state.selectedImage`), then open System › Auto-tagging from the sidebar. */
|
||||
async function selectThenOpen(page, path) {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await card(page, path).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true }).click();
|
||||
await expect(card(page, path)).toHaveClass(/\bselected\b/);
|
||||
await page.locator('aside.sidebar a[href="#/system"]').click();
|
||||
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Auto-tagging', exact: true }).click();
|
||||
await expect(page.locator('#tag-model')).toBeEnabled();
|
||||
await expect(modelRow(page).locator('p').first()).not.toHaveText('Loading model status…');
|
||||
}
|
||||
|
||||
test('model options are the four real engines with size and the description shows live status', async ({ page, request }) => {
|
||||
await unavailable(request, ['wd14-vit', 'hf']);
|
||||
await openTagging(page);
|
||||
expect(await surface(page).locator(':scope > h2').textContent()).toBe('Auto-tagging');
|
||||
expect(await page.locator('#tag-model option').allTextContents()).toEqual(MODEL_LABELS);
|
||||
await expect(page.locator('#tag-model')).toHaveValue('wd14-swinv2');
|
||||
const line = modelRow(page).locator('p').first();
|
||||
await expect(line).toHaveText('Downloaded. Not loaded.');
|
||||
await expect(actionOf(page, 'Download again')).toBeVisible();
|
||||
// Choosing another engine drafts it and tells its own status; nothing is downloaded or loaded.
|
||||
await page.locator('#tag-model').selectOption('wd14-vit');
|
||||
await expect(line).toHaveText('Not downloaded. Download size ~400 MB.');
|
||||
await expect(actionOf(page, 'Download')).toBeVisible();
|
||||
await page.locator('#tag-model').selectOption('hf');
|
||||
await expect(line).toHaveText('Not downloaded. Download size ~16 GB.');
|
||||
await expect(status(page)).toHaveText('Unsaved changes.');
|
||||
// The other rows keep the concept's copy and the saved values (D133 adds Suggestions per image).
|
||||
expect(await surface(page).locator(':scope > .setting-row label').allTextContents()).toEqual(['Tagging model', 'Existing tags',
|
||||
'General-tag confidence', 'Character-tag confidence', 'Suggestions per image']);
|
||||
await expect(page.locator('#tag-existing')).toHaveValue('skip');
|
||||
await expect(page.locator('#threshold-general')).toHaveValue('0.35');
|
||||
await expect(page.locator('#threshold-character')).toHaveValue('0.85');
|
||||
await expect(page.locator('#tag-suggestions')).toHaveValue('10');
|
||||
const settings = await serverJson(request, SETTINGS_URL);
|
||||
await expect(page.locator('#tag-template')).toHaveValue(settings.settings.autotag_template);
|
||||
// Unload is offered only while a model is loaded (none is).
|
||||
await expect(actionOf(page, 'Unload')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('entering the section makes no model call besides status', async ({ page }) => {
|
||||
await openTagging(page);
|
||||
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Integrations', exact: true }).click();
|
||||
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Auto-tagging', exact: true }).click();
|
||||
await expect(page.locator('#tag-model')).toBeEnabled();
|
||||
await expect(modelRow(page).locator('p').first()).toHaveText('Downloaded. Not loaded.');
|
||||
// One status read per mount (two mounts), and nothing else that could load, run or download a model.
|
||||
expect(page.modelCalls).toEqual([`GET ${MODELS_URL}`, `GET ${MODELS_URL}`]);
|
||||
const models = await (await page.request.get(MODELS_URL)).json();
|
||||
expect(models.model_loaded).toBe(false);
|
||||
});
|
||||
|
||||
test('defaults save through Save preferences and the Tag image dialog uses them', async ({ page, request }) => {
|
||||
await openTagging(page);
|
||||
await page.locator('#tag-existing').selectOption('merge');
|
||||
await page.locator('#threshold-general').fill('0.4');
|
||||
await page.locator('#threshold-general').press('Tab');
|
||||
await page.locator('#tag-suggestions').fill('15');
|
||||
await page.locator('#tag-suggestions').press('Tab');
|
||||
const posted = page.waitForRequest(item => item.method() === 'POST' && new URL(item.url()).pathname === SETTINGS_URL);
|
||||
await save(page).click();
|
||||
const body = (await posted).postDataJSON();
|
||||
expect(body).toMatchObject({ autotag_existing: 'merge', autotag_general_threshold: 0.4, autotag_suggestion_limit: 15 });
|
||||
expect(typeof body.expected_revision).toBe('string');
|
||||
await expect(status(page)).toHaveText('Saved to the server configuration.');
|
||||
const saved = (await serverJson(request, SETTINGS_URL)).settings;
|
||||
expect([saved.autotag_existing, saved.autotag_general_threshold, saved.autotag_character_threshold, saved.autotag_suggestion_limit])
|
||||
.toEqual(['merge', 0.4, 0.85, 15]);
|
||||
const models = await serverJson(request, MODELS_URL);
|
||||
expect([models.existing, models.wd14_general_threshold, models.suggestion_limit]).toEqual(['merge', 0.4, 15]);
|
||||
// The Tag image dialog in a fresh page starts from them.
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await card(page, FIRST).getByRole('button', { name: `Tag or auto-tag ${FIRST.split('/').pop()}`, exact: true }).click();
|
||||
await expect(tagDialog(page)).toBeVisible();
|
||||
await expect(tagDialog(page).locator('#image-general-threshold')).toHaveValue('0.4');
|
||||
await expect(tagDialog(page).locator('#image-character-threshold')).toHaveValue('0.85');
|
||||
await expect(tagDialog(page).locator('#image-suggestion-limit')).toHaveValue('15');
|
||||
});
|
||||
|
||||
test('a refused value shows the server\'s message', async ({ page, request }) => {
|
||||
const before = (await serverJson(request, SETTINGS_URL)).settings;
|
||||
// An image is selected, so Preview tagging review is held back only by the invalid value below.
|
||||
await selectThenOpen(page, FIRST);
|
||||
await page.locator('#threshold-general').fill('1.5');
|
||||
await page.locator('#threshold-general').press('Tab');
|
||||
await save(page).click();
|
||||
await expect(status(page)).toHaveText('General-tag confidence must be between 0 and 1.');
|
||||
await expect(page.locator('#threshold-general')).toHaveAttribute('aria-invalid', 'true');
|
||||
// Nothing was written; the draft is kept for the next Save.
|
||||
expect((await serverJson(request, SETTINGS_URL)).settings.autotag_general_threshold).toBe(before.autotag_general_threshold);
|
||||
await expect(page.locator('#threshold-general')).toHaveValue('1.5');
|
||||
// While the value is invalid, Preview tagging review is aria-disabled and explains instead of opening (Playwright
|
||||
// waits on an aria-disabled control, so it is pressed from the keyboard, as a user can).
|
||||
await expect(actionOf(page, 'Preview tagging review')).toHaveAttribute('aria-disabled', 'true');
|
||||
await actionOf(page, 'Preview tagging review').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(toast(page)).toHaveText('General-tag confidence must be between 0 and 1.');
|
||||
await expect(tagDialog(page)).toHaveCount(0);
|
||||
await page.locator('#threshold-general').fill('0.5');
|
||||
await page.locator('#threshold-general').press('Tab');
|
||||
await expect(page.locator('#threshold-general')).not.toHaveAttribute('aria-invalid', 'true');
|
||||
await expect(status(page)).toHaveText('Unsaved changes.');
|
||||
await expect(actionOf(page, 'Preview tagging review')).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
test('Download starts the job and Activity shows it without Cancel', async ({ page, request }) => {
|
||||
await unavailable(request, ['wd14-vit']);
|
||||
await fakeDownload(request, 'hold');
|
||||
await openTagging(page);
|
||||
await page.locator('#tag-model').selectOption('wd14-vit');
|
||||
const started = page.waitForResponse(item => new URL(item.url()).pathname === '/prompt_manager/jobs/model-download');
|
||||
await actionOf(page, 'Download').click();
|
||||
const answer = await started;
|
||||
expect(answer.status()).toBe(202);
|
||||
expect(answer.request().postDataJSON()).toEqual({ model_type: 'wd14-vit' });
|
||||
const { job } = await answer.json();
|
||||
expect(job.title).toBe('Tagging model download · WD14 ViT');
|
||||
await expect(toast(page)).toHaveText('Tagging model download · WD14 ViT started.');
|
||||
await expect(page).toHaveURL(`${workspace}#/activity/${job.id}`);
|
||||
const main = page.locator('#workspace-main');
|
||||
await expect(main.locator('h1')).toHaveText('Tagging model download · WD14 ViT');
|
||||
await expect(main.getByRole('button', { name: /^Cancel/ })).toHaveCount(0);
|
||||
// Indeterminate: the server knows no byte progress.
|
||||
await expect(main.locator('progress').first()).toBeVisible();
|
||||
expect(await main.locator('progress').first().evaluate(node => node.hasAttribute('value'))).toBe(false);
|
||||
await fakeDownload(request, 'succeed');
|
||||
expect((await ended(request, job.id)).state).toBe('finished');
|
||||
await expect(toast(page)).toHaveText('Tagging model download · WD14 ViT finished.');
|
||||
// Back in the section, the engine reads as on disk (the fake marks it downloaded on success).
|
||||
await page.goto(`${workspace}#/system/tagging`);
|
||||
await expect(page.locator('#tag-model')).toBeEnabled();
|
||||
await page.locator('#tag-model').selectOption('wd14-vit');
|
||||
await expect(modelRow(page).locator('p').first()).toHaveText('Downloaded. Not loaded.');
|
||||
expect(page.modelCalls.filter(call => call.startsWith('POST'))).toEqual(['POST /prompt_manager/jobs/model-download']);
|
||||
});
|
||||
|
||||
test('Unload is enabled only while a model is loaded, and a held gate\'s refusal names the operation', async ({ page, request }) => {
|
||||
await openTagging(page);
|
||||
await expect(actionOf(page, 'Unload')).toBeDisabled();
|
||||
// Load WD14 SwinV2 the way a user does: Generate suggestions in the Tag image dialog.
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await card(page, FIRST).getByRole('button', { name: `Tag or auto-tag ${FIRST.split('/').pop()}`, exact: true }).click();
|
||||
await expect(tagDialog(page)).toBeVisible();
|
||||
const suggested = page.waitForResponse(item => item.url().endsWith('/image-annotations/suggest'));
|
||||
await tagDialog(page).getByRole('button', { name: 'Generate suggestions', exact: true }).click();
|
||||
expect((await suggested).status()).toBe(200);
|
||||
await tagDialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await openTagging(page);
|
||||
await expect(modelRow(page).locator('p').first()).toHaveText('Downloaded and loaded.');
|
||||
await expect(actionOf(page, 'Unload')).toBeEnabled();
|
||||
// A download holds the model gate: Unload is refused and the toast names it; nothing is unloaded.
|
||||
await unavailable(request, ['wd14-vit']);
|
||||
await fakeDownload(request, 'hold');
|
||||
const held = await request.post('/prompt_manager/jobs/model-download', { data: { model_type: 'wd14-vit' } });
|
||||
expect(held.status()).toBe(202);
|
||||
const { job } = await held.json();
|
||||
const refused = page.waitForResponse(item => item.url().endsWith('/autotag/unload'));
|
||||
await actionOf(page, 'Unload').click();
|
||||
const refusal = await refused;
|
||||
expect(refusal.status()).toBe(409);
|
||||
expect(await refusal.json()).toMatchObject({ code: 'model-busy', operation: 'Tagging model download · WD14 ViT' });
|
||||
await expect(toast(page)).toHaveText('The tagging model is busy: Tagging model download · WD14 ViT. Try again when it finishes.');
|
||||
expect((await serverJson(request, MODELS_URL)).loaded_model_type).toBe('wd14-swinv2');
|
||||
await expect(actionOf(page, 'Unload')).toBeEnabled();
|
||||
// Once the download ended, Unload works, says so and the row follows.
|
||||
await fakeDownload(request, 'succeed');
|
||||
await ended(request, job.id);
|
||||
await actionOf(page, 'Unload').click();
|
||||
await expect(toast(page)).toHaveText('WD14 SwinV2 unloaded.');
|
||||
await expect(modelRow(page).locator('p').first()).toHaveText('Downloaded. Not loaded.');
|
||||
await expect(actionOf(page, 'Unload')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('Preview tagging review opens the Tag image dialog for the selected image', async ({ page }) => {
|
||||
await selectThenOpen(page, SECOND);
|
||||
// The section's current (unsaved) values are what the dialog starts from.
|
||||
await page.locator('#threshold-character').fill('0.6');
|
||||
await page.locator('#threshold-character').press('Tab');
|
||||
await page.locator('#tag-model').selectOption('wd14-vit');
|
||||
const preview = actionOf(page, 'Preview tagging review');
|
||||
await expect(preview).toHaveClass(/ca-primary/);
|
||||
await expect(preview).not.toHaveAttribute('aria-disabled', 'true');
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await preview.click();
|
||||
expect((await observed).status()).toBe(200);
|
||||
await expect(tagDialog(page)).toBeVisible();
|
||||
await expect(tagDialog(page).locator('.tag-image-heading p.small')).toHaveText('dunes_00002.png');
|
||||
await expect(tagDialog(page).getByRole('combobox', { name: 'Tagging model', exact: true })).toHaveValue('wd14-vit');
|
||||
await expect(tagDialog(page).locator('#image-character-threshold')).toHaveValue('0.6');
|
||||
await expect(tagDialog(page).locator('#image-general-threshold')).toHaveValue('0.35');
|
||||
});
|
||||
|
||||
test('Preview tagging review without a selection toasts the D115 sentence', async ({ page }) => {
|
||||
await openTagging(page);
|
||||
const preview = actionOf(page, 'Preview tagging review');
|
||||
await expect(preview).toHaveAttribute('aria-disabled', 'true');
|
||||
// An aria-disabled control still takes a press; it only explains.
|
||||
await preview.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(toast(page)).toHaveText('Select an image in Images first.');
|
||||
await expect(tagDialog(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Tag output images reviews the scope and starts a batch job', async ({ page, request }) => {
|
||||
const before = await serverJson(request, '/__test__/tagging/snapshot');
|
||||
await openTagging(page);
|
||||
// The fixture's one prompt is already tagged: under Merge it is tagged again with what its images give.
|
||||
await page.locator('#tag-existing').selectOption('merge');
|
||||
const previewed = page.waitForResponse(item => new URL(item.url()).pathname === PREVIEW_URL);
|
||||
await reviewButton(page).click();
|
||||
const answer = await previewed;
|
||||
expect(answer.request().postDataJSON()).toEqual({ model_type: 'wd14-swinv2', existing: 'merge' });
|
||||
const plan = await answer.json();
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Review tag output images');
|
||||
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Tag output images');
|
||||
expect(await gridOf(page)).toEqual([['Model', 'WD14 SwinV2 · ~400 MB'], ['Existing tags', 'Merge with existing'],
|
||||
['Images linked', String(plan.images)], ['Prompts', String(plan.prompts)], ['Already tagged', String(plan.already_tagged)],
|
||||
['Prompts to tag', `up to ${plan.to_tag}`], ['Settings', 'General 0.35 · Character 0.85'],
|
||||
['Backup', 'Create and verify before changes']]);
|
||||
await expect(dialog(page).locator('#modal-body > .notice h3')).toHaveText('Back up the database first (recommended)');
|
||||
// The review wrote nothing.
|
||||
expect(await serverJson(request, '/__test__/tagging/snapshot')).toEqual(before);
|
||||
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Start tagging']);
|
||||
const started = page.waitForResponse(item => new URL(item.url()).pathname === '/prompt_manager/jobs/autotag');
|
||||
await dialog(page).getByRole('button', { name: 'Start tagging', exact: true }).click();
|
||||
const start = await started;
|
||||
expect(start.request().postDataJSON()).toEqual({ model_type: 'wd14-swinv2', existing: 'merge',
|
||||
options: { general_threshold: 0.35, character_threshold: 0.85 } });
|
||||
const { job } = await start.json();
|
||||
await expect(page).toHaveURL(`${workspace}#/activity/${job.id}`);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Tag output images');
|
||||
const finished = await ended(request, job.id);
|
||||
expect(finished.state).toBe('finished');
|
||||
const after = await serverJson(request, '/__test__/tagging/snapshot');
|
||||
expect(after.prompt_tags.length).toBeGreaterThan(before.prompt_tags.length);
|
||||
});
|
||||
|
||||
test('Replace after review shows only Cancel with the reason', async ({ page, request }) => {
|
||||
const before = await serverJson(request, '/__test__/tagging/snapshot');
|
||||
await openTagging(page);
|
||||
await page.locator('#tag-existing').selectOption('replace_after_review');
|
||||
await reviewButton(page).click();
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Review tag output images');
|
||||
expect((await gridOf(page)).map(([term]) => term)).toEqual(['Model', 'Existing tags', 'Images linked', 'Prompts', 'Already tagged',
|
||||
'Prompts to tag', 'Settings', 'Backup']);
|
||||
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('Replacing tags needs a review of each image. '
|
||||
+ 'Use Preview tagging review, or choose Skip or Merge for a batch run.');
|
||||
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel']);
|
||||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
// Under Skip the fixture's only prompt is already tagged: nothing to start either.
|
||||
await page.locator('#tag-existing').selectOption('skip');
|
||||
await reviewButton(page).click();
|
||||
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('Every prompt with an output image is already tagged.');
|
||||
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel']);
|
||||
expect(await serverJson(request, '/__test__/tagging/snapshot')).toEqual(before);
|
||||
expect((await serverJson(request, '/prompt_manager/jobs')).jobs.filter(item => item.kind === 'autotag')).toEqual([]);
|
||||
});
|
||||
|
||||
test('a model that is not downloaded cannot be reviewed for a batch; the button says why', async ({ page, request }) => {
|
||||
await unavailable(request, ['gguf']);
|
||||
await openTagging(page);
|
||||
await page.locator('#tag-model').selectOption('gguf');
|
||||
await expect(reviewButton(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
// Playwright waits on an aria-disabled control: press it from the keyboard, as a user can.
|
||||
await reviewButton(page).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(toast(page)).toHaveText('GGUF JoyCaption is not downloaded. Download it first.');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
expect(page.modelCalls.filter(call => call.includes('/batch/preview'))).toEqual([]);
|
||||
});
|
||||
@@ -412,11 +412,11 @@ test('out-of-order overview responses never repaint a newer section', async ({ p
|
||||
await nav.getByRole('button', { name: 'Appearance', exact: true }).click();
|
||||
await nav.getByRole('button', { name: 'Overview', exact: true }).click();
|
||||
await expect.poll(held).toBe(2);
|
||||
// Auto-tagging still hands off (S11), so its body is one known panel.
|
||||
await nav.getByRole('button', { name: 'Auto-tagging', exact: true }).click();
|
||||
// Diagnostics still hands off (S12), so its body is one known panel.
|
||||
await nav.getByRole('button', { name: 'Diagnostics & logs', exact: true }).click();
|
||||
await expect(await request.post('/__test__/release')).toBeOK();
|
||||
await expect.poll(held).toBe(0);
|
||||
// Both late answers reached a section that had left: Auto-tagging keeps its own body.
|
||||
// Both late answers reached a section that had left: Diagnostics keeps its own body.
|
||||
await expect(layout.locator(':scope > section.handoff')).toBeVisible();
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await expect(layout.locator('.summary-metrics')).toHaveCount(0);
|
||||
|
||||
@@ -17,7 +17,7 @@ const chips = page => modal(page).locator('#tag-suggestions .chip.tag-suggestion
|
||||
const actionButtons = page => modal(page).locator('.dialog-actions button');
|
||||
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
||||
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
|
||||
const NOT_DOWNLOADED = 'WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.';
|
||||
const NOT_DOWNLOADED = 'WD14 SwinV2 is not downloaded. Download it in System › Auto-tagging.';
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
|
||||
@@ -246,7 +246,7 @@ test('unavailable model shows inline status and never downloads', async ({ page,
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
|
||||
await openTag(page);
|
||||
expect((await generated(page)).status()).toBe(409);
|
||||
await expect(status(page)).toHaveText('WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.');
|
||||
await expect(status(page)).toHaveText('WD14 SwinV2 is not downloaded. Download it in System › Auto-tagging.');
|
||||
await expect(chips(page)).toHaveCount(0);
|
||||
await expect(dialog(page).locator('#suggestion-count')).toHaveText('Not generated');
|
||||
// D116: Retry leads the actions while the controller allows it, and generates again.
|
||||
|
||||
@@ -413,7 +413,7 @@ for (const width of [1600, 390]) test(`overview and handoff render with concept
|
||||
|
||||
await sidebarLink(page, 'System').click();
|
||||
// System's Overview is native (S2 Task 6); a section not delivered yet still hands off (D94).
|
||||
await main.locator('.section-nav').getByRole('button', { name: 'Auto-tagging', exact: true }).click();
|
||||
await main.locator('.section-nav').getByRole('button', { name: 'Diagnostics & logs', exact: true }).click();
|
||||
const handoff = main.locator('section.handoff');
|
||||
await expect(handoff).toHaveClass(/\bsurface\b/);
|
||||
expect(await handoff.evaluate(surfaceStyle)).toMatchObject(SURFACE);
|
||||
@@ -465,8 +465,9 @@ test('System is native: the concept shell, section navigation, sections, hand-of
|
||||
// S2 Task 6: Overview is native (workspace-system.spec.mjs), and so are Appearance (S3 Task 8) and Sources & scans
|
||||
// (S4 Task 11, workspace-system-sources.spec.mjs, its update notice shown in a fresh browser), Integrations (S10
|
||||
// Task 27, workspace-system-integrations.spec.mjs), Maintenance (S7 Task 19,
|
||||
// workspace-system-maintenance.spec.mjs) and Backup & export (S9 Task 24, workspace-system-backups.spec.mjs);
|
||||
// every other section hands off.
|
||||
// workspace-system-maintenance.spec.mjs), Backup & export (S9 Task 24, workspace-system-backups.spec.mjs) and
|
||||
// Auto-tagging (S11 Task 6, workspace-system-tagging.spec.mjs); every other section hands off.
|
||||
if (hash === 'tagging') await expect(layout.locator('#tag-model')).toBeVisible();
|
||||
if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible();
|
||||
if (hash === 'sources') await expect(layout.locator('#source-list .operation')).toHaveCount(2);
|
||||
expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
|
||||
@@ -474,9 +475,10 @@ test('System is native: the concept shell, section navigation, sections, hand-of
|
||||
sources: ['div.notice', 'section.surface', 'div.save-row'],
|
||||
integrations: ['section.surface', 'section.surface', 'div.save-row'],
|
||||
maintenance: ['div.notice', 'section.surface', 'section.surface', 'div.save-row'],
|
||||
backups: ['div.two-col', 'section.surface', 'div.save-row'] }[hash]
|
||||
backups: ['div.two-col', 'section.surface', 'div.save-row'],
|
||||
tagging: ['section.surface', 'div.save-row'] }[hash]
|
||||
?? ['section.surface handoff', 'div.save-row']);
|
||||
if (['overview', 'appearance', 'sources', 'integrations', 'maintenance', 'backups'].includes(hash)) continue;
|
||||
if (['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups'].includes(hash)) continue;
|
||||
const handoff = layout.locator('section.handoff');
|
||||
await expect(handoff.locator('h2')).toHaveText('Your current tools are ready');
|
||||
expect(await handoff.locator('a.workspace-link').evaluateAll(links => links.map(link => link.getAttribute('href'))))
|
||||
|
||||
@@ -388,6 +388,16 @@ test("ApiError keeps a validated running job on busy and a validated source on i
|
||||
assert.deepEqual([new ApiError(409, "old caller").running, new ApiError(409, "old caller").source], [null, null]);
|
||||
});
|
||||
|
||||
test("ApiError keeps a validated operation on model-busy only (#328)", () => {
|
||||
const body = { operation: "Tagging model download · WD14 ViT" };
|
||||
assert.equal(new ApiError(409, "busy", "model-busy", null, body).operation, "Tagging model download · WD14 ViT");
|
||||
assert.equal(new ApiError(409, "busy", "busy", null, body).operation, null);
|
||||
for (const operation of [null, 7, "", "x".repeat(201)]) {
|
||||
assert.equal(new ApiError(409, "busy", "model-busy", null, { operation }).operation, null);
|
||||
}
|
||||
assert.equal(new ApiError(409, "busy", "model-busy").operation, null);
|
||||
});
|
||||
|
||||
test("ApiError says whether the server itself refused (a parsed success:false body; S7 review I1)", () => {
|
||||
assert.equal(new ApiError(400, "bad", null, null, { success: false, error: "bad" }).refused, true);
|
||||
assert.equal(new ApiError(409, "busy", "busy", null, { success: false, code: "busy" }).refused, true);
|
||||
|
||||
@@ -588,7 +588,7 @@ test('model-unavailable and model-busy set the status line only', async () => {
|
||||
await c.generate();
|
||||
const before = c.state;
|
||||
for (const [code, message] of [
|
||||
['model-unavailable', 'WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.'],
|
||||
['model-unavailable', 'WD14 SwinV2 is not downloaded. Download it in System › Auto-tagging.'],
|
||||
['model-busy', 'A tagging model is busy with another operation. Try again when it finishes.'],
|
||||
['unsupported-media', 'Images larger than 4096 px per side or 16,777,216 px are not supported.'],
|
||||
]) {
|
||||
|
||||
@@ -129,9 +129,9 @@ test('a model that is not downloaded is named with the server sentence', () => {
|
||||
const { modelUnavailable } = state;
|
||||
assert.equal(modelUnavailable(null, 'wd14-vit'), '');
|
||||
assert.equal(modelUnavailable({ 'wd14-vit': { downloaded: true } }, 'wd14-vit'), '');
|
||||
assert.equal(modelUnavailable({ 'wd14-vit': { downloaded: false } }, 'wd14-vit'), 'WD14 ViT is not downloaded. Download it from the legacy Auto-tag page.');
|
||||
assert.equal(modelUnavailable({ 'wd14-vit': { downloaded: false } }, 'wd14-vit'), 'WD14 ViT is not downloaded. Download it in System › Auto-tagging.');
|
||||
assert.equal(modelUnavailable({ gguf: { downloaded: false, name: 'GGUF (Recommended)' } }, 'gguf'),
|
||||
'GGUF (Recommended) is not downloaded. Download it from the legacy Auto-tag page.');
|
||||
'GGUF (Recommended) is not downloaded. Download it in System › Auto-tagging.');
|
||||
assert.throws(() => modelUnavailable({}, 'nope'), TypeError);
|
||||
});
|
||||
|
||||
|
||||
@@ -341,6 +341,22 @@ test('a host save names the settings that apply after a restart (D128)', async (
|
||||
assert.equal(drafts.statusText(), 'Unsaved changes.');
|
||||
});
|
||||
|
||||
test('a host save refused for one setting says the server reason and names no source (S11 CUJ 2a)', async () => {
|
||||
const { INVALID_SETTING_CODE } = await source;
|
||||
const domains = adapters({ host: { autotag_general_threshold: 0.35 } });
|
||||
const drafts = await loaded(domains);
|
||||
const message = 'General-tag confidence must be between 0 and 1.';
|
||||
domains.outcome.host = () => { throw Object.assign(new Error(message), { code: INVALID_SETTING_CODE }); };
|
||||
drafts.set('host', 'autotag_general_threshold', 1.5);
|
||||
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'failed', runtime: 'clean', invalid: { source: null, message } });
|
||||
assert.equal(drafts.statusText(), message);
|
||||
assert.equal(drafts.invalidSource(), null);
|
||||
assert.deepEqual([...drafts.failed('host')], ['autotag_general_threshold']);
|
||||
assert.equal(drafts.get('host', 'autotag_general_threshold'), 1.5);
|
||||
drafts.set('host', 'autotag_general_threshold', 0.4);
|
||||
assert.equal(drafts.statusText(), 'Unsaved changes.');
|
||||
});
|
||||
|
||||
test('a host save refused for one source says the server reason and names it until the next edit', async () => {
|
||||
const domains = adapters({ host: { gallery_root_paths: ['output', 'output/gone'] } });
|
||||
const drafts = await loaded(domains);
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
// System › Auto-tagging (#328, System S11 Tasks 6–7): the concept's rows with real data (D132 model options and live
|
||||
// status, D133 suggestions per image), host drafts saved by the one Save row, and the actions: Download (an Activity
|
||||
// job), Unload (direct), Preview tagging review (D115) and the reviewed Tag output images batch (D134).
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const tagging = import('../../web/js/workspace/system-tagging.mjs');
|
||||
const stateModule = import('../../web/js/workspace/system-state.mjs');
|
||||
const coreModule = import('../../web/js/shared/toolkit/core.js');
|
||||
|
||||
const CONCEPT = fs.readFileSync(path.join(__dirname, '../../docs/concepts/workspace/app.js'), 'utf8');
|
||||
const SOURCE = fs.readFileSync(path.join(__dirname, '../../web/js/workspace/system-tagging.mjs'), 'utf8');
|
||||
|
||||
/** A minimal DOM whose elements count their live listeners (a listener's signal aborts it, as in a browser). */
|
||||
function fakeDOM() {
|
||||
const previous = global.document;
|
||||
const elements = [];
|
||||
class Element {
|
||||
constructor(tag) {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.attributes = {};
|
||||
this.listeners = [];
|
||||
this.dataset = {};
|
||||
this.className = '';
|
||||
this.id = '';
|
||||
this.value = '';
|
||||
this.disabled = false;
|
||||
this._text = '';
|
||||
elements.push(this);
|
||||
}
|
||||
get localName() { return this.tagName.toLowerCase(); }
|
||||
get options() { return this.children.filter(child => child.tagName === 'OPTION'); }
|
||||
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
|
||||
set textContent(text) { this.children = []; this._text = String(text); }
|
||||
append(...nodes) {
|
||||
for (const node of nodes) {
|
||||
node.parentNode?.children && (node.parentNode.children = node.parentNode.children.filter(child => child !== node));
|
||||
node.parentNode = this;
|
||||
this.children.push(node);
|
||||
}
|
||||
}
|
||||
remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); this.parentNode = null; }
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||||
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
|
||||
removeAttribute(name) { delete this.attributes[name]; }
|
||||
addEventListener(type, callback, options = {}) {
|
||||
if (options.signal?.aborted) return;
|
||||
const entry = { type, callback };
|
||||
this.listeners.push(entry);
|
||||
options.signal?.addEventListener('abort', () => { this.listeners = this.listeners.filter(item => item !== entry); });
|
||||
}
|
||||
removeEventListener(type, callback) { this.listeners = this.listeners.filter(item => item.type !== type || item.callback !== callback); }
|
||||
dispatch(type) { return Promise.all(this.listeners.filter(item => item.type === type).map(({ callback }) => callback({ type, target: this }))); }
|
||||
}
|
||||
global.document = { createElement: tag => new Element(tag), activeElement: null };
|
||||
const walk = (node, found = []) => { found.push(node); node.children.forEach(child => walk(child, found)); return found; };
|
||||
return {
|
||||
listenerCount: () => elements.reduce((sum, element) => sum + element.listeners.length, 0),
|
||||
find: (root, predicate) => walk(root).find(predicate),
|
||||
restore() { global.document = previous; },
|
||||
};
|
||||
}
|
||||
|
||||
const HOST = { autotag_model_type: 'wd14-swinv2', autotag_existing: 'skip', autotag_general_threshold: 0.35,
|
||||
autotag_character_threshold: 0.85, autotag_suggestion_limit: 10, autotag_template: 'Describe the image as tags.' };
|
||||
const entry = (label, size, extra = {}) => ({ name: label, label, size_label: size, downloaded: true, loaded: false, ...extra });
|
||||
const MODELS = {
|
||||
success: true,
|
||||
models: { 'wd14-swinv2': entry('WD14 SwinV2', '~400 MB'), 'wd14-vit': entry('WD14 ViT', '~400 MB', { downloaded: false }),
|
||||
gguf: entry('GGUF JoyCaption', '~4.5 GB', { downloaded: false, model_exists: true, mmproj_exists: false }),
|
||||
hf: entry('HuggingFace JoyCaption', '~16 GB', { downloaded: false }) },
|
||||
model_loaded: false, loaded_model_type: null,
|
||||
};
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
const IMAGE = { filename: 'dunes_00001.png' };
|
||||
|
||||
async function mounted({ host = HOST, models = MODELS, modelsFail = false, image = IMAGE, start, post, hostFails = false } = {}) {
|
||||
const { mountTagging } = await tagging;
|
||||
const { createSystemDrafts } = await stateModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
const drafts = createSystemDrafts({
|
||||
readBrowser: () => ({}), readRuntime: () => ({}),
|
||||
readHost: async () => { if (hostFails) throw new Error('offline'); return { values: host, revision: 'sha256:one' }; },
|
||||
writeBrowser: async () => ({ persisted: true }), writeHost: async () => ({ success: true, persisted: true }),
|
||||
writeRuntime: async () => ({}),
|
||||
});
|
||||
const scope = createScope();
|
||||
const calls = { get: [], post: [], toasts: [], navigated: [], opened: [], shown: [], started: [] };
|
||||
let answer = models;
|
||||
const api = {
|
||||
get: async (url, options) => {
|
||||
calls.get.push(url);
|
||||
assert.equal(options.cache, 'no-store');
|
||||
if (modelsFail) throw new Error('offline');
|
||||
return answer;
|
||||
},
|
||||
post: async (url, body) => { calls.post.push([url, body]); return post ? post(url, body) : { success: true }; },
|
||||
};
|
||||
const activity = { start: async (kind, body) => { calls.started.push([kind, body]); return start ? start(kind, body) : { job: { id: 'job1' } }; } };
|
||||
const [surface] = mountTagging(null, { api, activity, scope, drafts, notify: text => calls.toasts.push(text),
|
||||
navigate: hash => calls.navigated.push(hash), selected: () => image, openTagging: options => calls.opened.push(options),
|
||||
show: (plan, options) => calls.shown.push([plan, options]) });
|
||||
const byId = id => dom.find(surface, node => node.id === id);
|
||||
const button = label => dom.find(surface, node => node.tagName === 'BUTTON' && node.textContent === label);
|
||||
const description = () => byId('tag-model').parentNode.children[0].children[1].textContent;
|
||||
await drafts.reloadHost();
|
||||
await tick();
|
||||
return { dom, drafts, scope, surface, byId, button, description, calls, setModels: value => { answer = value; } };
|
||||
}
|
||||
|
||||
test('the section keeps the concept surface, rows, labels and actions in order', () => {
|
||||
const start = CONCEPT.indexOf('case "tagging":');
|
||||
const body = CONCEPT.slice(start, CONCEPT.indexOf('break;', start));
|
||||
const rows = [...body.matchAll(/setting\("([^"]+)", "([^"]+)", "([^"]+)"/g)].map(([, id, label, description]) => [id, label, description]);
|
||||
assert.deepEqual(rows.map(([id]) => id), ['tag-model', 'tag-existing', 'threshold-general', 'threshold-character']);
|
||||
for (const [id, label] of rows) assert.ok(SOURCE.includes(`id: '${id}', label: '${label}'`), `${id} keeps "${label}"`);
|
||||
// OQ6: the true descriptions are kept verbatim; the model's design note is replaced by its live status (D132).
|
||||
for (const [id, , description] of rows.slice(1)) assert.ok(SOURCE.includes(`'${description}'`), `${id} description`);
|
||||
assert.ok(!SOURCE.includes(rows[0][2]));
|
||||
for (const option of ['Skip already tagged', 'Merge with existing', 'Replace after review']) assert.ok(body.includes(option) && SOURCE.includes(`'${option}'`));
|
||||
assert.ok(body.includes('<h2>Auto-tagging</h2>') && SOURCE.includes("'h2', 'Auto-tagging'"));
|
||||
assert.ok(body.includes('One tagging configuration for images and prompt-library actions. Every result can be reviewed before applying.'));
|
||||
assert.ok(body.includes('LLM tag template') && SOURCE.includes("'LLM tag template'"));
|
||||
assert.ok(body.includes('"Preview tagging review", "tag-review", "", "ca-primary"'));
|
||||
// Prototype copy is never shipped.
|
||||
for (const sample of ['Simulate download', 'Simulate unload', 'Ollama']) assert.ok(!SOURCE.includes(sample), sample);
|
||||
});
|
||||
|
||||
test('model options are the four real engines with their size, in the concept order (D132, OQ5)', async () => {
|
||||
const { modelOptions, MODEL_IDS, modelLabel, modelSize } = await tagging;
|
||||
assert.deepEqual(MODEL_IDS, ['wd14-swinv2', 'wd14-vit', 'gguf', 'hf']);
|
||||
assert.deepEqual(modelOptions(MODELS.models).map(([, label]) => label),
|
||||
['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB']);
|
||||
// Before the status answered (or when it cannot be read) the documented labels stand in.
|
||||
assert.deepEqual(modelOptions(null), modelOptions(MODELS.models));
|
||||
assert.equal(modelLabel('hf', { label: '' }), 'HuggingFace JoyCaption');
|
||||
assert.equal(modelLabel('other', null), 'other');
|
||||
assert.equal(modelSize('other', null), '');
|
||||
});
|
||||
|
||||
test('the Tagging model description is the chosen model’s live status (D132)', async () => {
|
||||
const { modelDescription, readStatus, STATUS_COPY } = await tagging;
|
||||
const status = readStatus(MODELS);
|
||||
assert.equal(modelDescription({ phase: 'loading' }, 'wd14-vit'), 'Loading model status…');
|
||||
assert.equal(modelDescription({ phase: 'failed' }, 'wd14-vit'), 'Model status could not be read.');
|
||||
assert.equal(modelDescription(status, 'wd14-swinv2'), 'Downloaded. Not loaded.');
|
||||
assert.equal(modelDescription(status, 'wd14-vit'), 'Not downloaded. Download size ~400 MB.');
|
||||
assert.equal(modelDescription(status, 'hf'), 'Not downloaded. Download size ~16 GB.');
|
||||
assert.equal(modelDescription(status, 'gguf'), 'Partly downloaded. Download again to finish.');
|
||||
const both = readStatus({ ...MODELS, models: { ...MODELS.models, gguf: entry('GGUF JoyCaption', '~4.5 GB', { downloaded: false,
|
||||
model_exists: false, mmproj_exists: false }) } });
|
||||
assert.equal(modelDescription(both, 'gguf'), 'Not downloaded. Download size ~4.5 GB.');
|
||||
const loaded = readStatus({ ...MODELS, model_loaded: true, loaded_model_type: 'wd14-swinv2',
|
||||
models: { ...MODELS.models, 'wd14-swinv2': entry('WD14 SwinV2', '~400 MB', { loaded: true }) } });
|
||||
assert.equal(loaded.loadedType, 'wd14-swinv2');
|
||||
assert.equal(modelDescription(loaded, 'wd14-swinv2'), STATUS_COPY.loaded);
|
||||
assert.equal(modelDescription(status, 'unknown'), STATUS_COPY.failed);
|
||||
assert.deepEqual(readStatus(null), { phase: 'failed', models: null, loadedType: null });
|
||||
assert.equal(readStatus({ models: {}, model_loaded: true, loaded_model_type: '/abs/path' }).loadedType, null);
|
||||
});
|
||||
|
||||
test('controls draft their host keys; numbers as numbers, other text as typed', async () => {
|
||||
const { SETTING_KEYS, coerceNumber, draftValue } = await tagging;
|
||||
assert.deepEqual(SETTING_KEYS, { 'tag-model': 'autotag_model_type', 'tag-existing': 'autotag_existing',
|
||||
'threshold-general': 'autotag_general_threshold', 'threshold-character': 'autotag_character_threshold',
|
||||
'tag-suggestions': 'autotag_suggestion_limit', 'tag-template': 'autotag_template' });
|
||||
assert.deepEqual(['0.4', ' 1.5 ', '15', '', 'abc', '1e-1'].map(coerceNumber), [0.4, 1.5, 15, '', 'abc', 0.1]);
|
||||
assert.equal(draftValue('autotag_suggestion_limit', '15'), 15);
|
||||
assert.equal(draftValue('autotag_template', ' 12 '), ' 12 ');
|
||||
assert.equal(draftValue('autotag_existing', undefined), '');
|
||||
const { dom, drafts, scope, byId } = await mounted();
|
||||
try {
|
||||
assert.equal(byId('tag-model').value, 'wd14-swinv2');
|
||||
assert.equal(byId('threshold-general').value, '0.35');
|
||||
assert.equal(byId('tag-template').value, HOST.autotag_template);
|
||||
assert.deepEqual([byId('threshold-general').min, byId('threshold-general').max, byId('threshold-general').step], ['0', '1', '0.05']);
|
||||
assert.deepEqual([byId('tag-suggestions').min, byId('tag-suggestions').max], ['1', '100']);
|
||||
byId('tag-existing').value = 'merge'; await byId('tag-existing').dispatch('change');
|
||||
byId('threshold-general').value = '0.4'; await byId('threshold-general').dispatch('change');
|
||||
byId('tag-suggestions').value = '15'; await byId('tag-suggestions').dispatch('change');
|
||||
byId('tag-template').value = 'Tags only.'; await byId('tag-template').dispatch('input');
|
||||
assert.deepEqual(drafts.changes('host'), { autotag_existing: 'merge', autotag_general_threshold: 0.4,
|
||||
autotag_suggestion_limit: 15, autotag_template: 'Tags only.' });
|
||||
// Every control names its stored key, so a refused save highlights it (D92).
|
||||
for (const id of Object.keys(SETTING_KEYS)) assert.equal(byId(id).dataset.key, SETTING_KEYS[id]);
|
||||
assert.equal(byId('tag-template').dataset.setting, 'tag-template');
|
||||
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
|
||||
});
|
||||
|
||||
test('opening the section reads the model status once; the select waits for it', async () => {
|
||||
const { dom, scope, byId, description, calls, surface } = await mounted();
|
||||
try {
|
||||
assert.deepEqual(calls.get, ['/prompt_manager/autotag/models']);
|
||||
assert.deepEqual(calls.post, []);
|
||||
assert.equal(byId('tag-model').disabled, false);
|
||||
assert.equal(description(), 'Downloaded. Not loaded.');
|
||||
assert.deepEqual(byId('tag-model').options.map(option => option.textContent),
|
||||
['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB']);
|
||||
assert.deepEqual(surface.children.map(node => node.className),
|
||||
['', '', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'field', 'row-actions', 'operation']);
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
const failed = await mounted({ modelsFail: true });
|
||||
try {
|
||||
assert.equal(failed.description(), 'Model status could not be read.');
|
||||
assert.equal(failed.button('Download').getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(failed.button('Unload').disabled, true);
|
||||
await failed.button('Download').dispatch('click');
|
||||
assert.deepEqual(failed.calls.toasts, ['Model status could not be read.']);
|
||||
assert.deepEqual(failed.calls.started, []);
|
||||
} finally { failed.scope.dispose(); failed.dom.restore(); }
|
||||
});
|
||||
|
||||
test('server settings that cannot be read leave the rows disabled and say so', async () => {
|
||||
const { dom, scope, byId, button, description, calls } = await mounted({ hostFails: true });
|
||||
try {
|
||||
assert.equal(byId('tag-existing').disabled, true);
|
||||
assert.equal(byId('tag-existing').parentNode.children[0].children.at(-1).textContent, 'Server settings could not be read.');
|
||||
assert.equal(description(), 'Model status could not be read.');
|
||||
await button('Preview tagging review').dispatch('click');
|
||||
assert.deepEqual(calls.toasts, ['Server settings could not be read.']);
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('Download starts the model download job and opens it; the label follows the disk (OQ10)', async () => {
|
||||
const { dom, drafts, scope, button, calls, byId } = await mounted();
|
||||
try {
|
||||
assert.ok(button('Download again'));
|
||||
byId('tag-model').value = 'wd14-vit'; await byId('tag-model').dispatch('change');
|
||||
assert.equal(drafts.get('host', 'autotag_model_type'), 'wd14-vit');
|
||||
await button('Download').dispatch('click');
|
||||
assert.deepEqual(calls.started, [['model-download', { model_type: 'wd14-vit' }]]);
|
||||
assert.deepEqual(calls.navigated, ['#/activity/job1']);
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('startJob toasts by code and never navigates on a refusal', async () => {
|
||||
const { startJob, startRefusal } = await tagging;
|
||||
const { createScope } = await coreModule;
|
||||
const error = (code, message = 'Server says.', extra = {}) => Object.assign(new Error(message), { code, ...extra });
|
||||
assert.equal(startRefusal(error('model-busy', 'x', { operation: 'Tagging model download · WD14 ViT' }), 'X'),
|
||||
'The tagging model is busy: Tagging model download · WD14 ViT. Try again when it finishes.');
|
||||
assert.equal(startRefusal(error('model-busy', 'x'), 'X'),
|
||||
'The tagging model is busy: Another tagging operation. Try again when it finishes.');
|
||||
for (const code of ['model-unavailable', 'review-required', 'insufficient-space']) assert.equal(startRefusal(error(code), 'X'), 'Server says.');
|
||||
assert.equal(startRefusal(error('weird'), 'Tag output images'), 'Tag output images could not start. Try again.');
|
||||
assert.equal(startRefusal(new Error('offline'), 'Tag output images'), 'Tag output images could not start. Try again.');
|
||||
const scope = createScope();
|
||||
const toasts = [];
|
||||
const navigated = [];
|
||||
const run = start => startJob({ activity: { start }, scope, kind: 'autotag', body: {}, what: 'Tag output images',
|
||||
notify: text => toasts.push(text), navigate: hash => navigated.push(hash) });
|
||||
assert.equal(await run(async () => { throw error('model-busy', 'busy', { operation: 'Generate suggestions' }); }), null);
|
||||
assert.equal(await run(async () => ({ busy: { id: 'a1', title: 'Scan for metadata' } })), null);
|
||||
assert.equal(await run(async () => ({ busy: null })), null);
|
||||
assert.deepEqual(toasts, ['The tagging model is busy: Generate suggestions. Try again when it finishes.',
|
||||
['Another job is running: ', { text: 'Scan for metadata', href: '#/activity/a1' }, '.'],
|
||||
'Another job is running. Try again when it ends.']);
|
||||
assert.deepEqual(await run(async () => ({ job: { id: 'j2' } })), { id: 'j2' });
|
||||
assert.deepEqual(navigated, ['#/activity/j2']);
|
||||
scope.dispose();
|
||||
assert.equal(await run(async () => { throw error('weird'); }), null);
|
||||
assert.deepEqual(await run(async () => ({ job: { id: 'j3' } })), { id: 'j3' });
|
||||
assert.deepEqual(navigated, ['#/activity/j2']);
|
||||
assert.equal(toasts.length, 3);
|
||||
});
|
||||
|
||||
test('Unload is enabled only while a model is loaded; a busy refusal names the operation', async () => {
|
||||
const loadedModels = { ...MODELS, model_loaded: true, loaded_model_type: 'wd14-swinv2' };
|
||||
let refuse = null;
|
||||
const post = url => {
|
||||
if (refuse) throw refuse;
|
||||
return url.endsWith('/unload') ? { success: true, message: 'WD14SWINV2 model unloaded successfully', model_loaded: false } : {};
|
||||
};
|
||||
const { dom, scope, button, calls, setModels } = await mounted({ models: loadedModels, post });
|
||||
try {
|
||||
assert.equal(button('Unload').disabled, false);
|
||||
refuse = Object.assign(new Error('A tagging model is busy'), { code: 'model-busy', operation: 'Tagging model download · WD14 ViT' });
|
||||
await button('Unload').dispatch('click');
|
||||
assert.deepEqual(calls.toasts, ['The tagging model is busy: Tagging model download · WD14 ViT. Try again when it finishes.']);
|
||||
refuse = new Error('offline');
|
||||
await button('Unload').dispatch('click');
|
||||
assert.equal(calls.toasts.at(-1), 'The tagging model could not be unloaded. Try again.');
|
||||
refuse = null;
|
||||
setModels(MODELS);
|
||||
await button('Unload').dispatch('click');
|
||||
assert.equal(calls.toasts.at(-1), 'WD14 SwinV2 unloaded.');
|
||||
assert.deepEqual(calls.post.map(([url]) => url), Array(3).fill('/prompt_manager/autotag/unload'));
|
||||
// Each attempt reads the status again; nothing is loaded now.
|
||||
assert.equal(calls.get.length, 4);
|
||||
assert.equal(button('Unload').disabled, true);
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('Preview tagging review opens the Tag image dialog with the section’s values, else says why (D115)', async () => {
|
||||
const { NO_SELECTION, taggingPreset, draftProblem } = await tagging.then(async module => ({ ...module,
|
||||
NO_SELECTION: (await import('../../web/js/workspace/vocabulary.mjs')).NO_SELECTION }));
|
||||
assert.equal(NO_SELECTION, 'Select an image in Images first.');
|
||||
assert.deepEqual(taggingPreset(HOST), { model: 'wd14-swinv2', options: { general_threshold: 0.35, character_threshold: 0.85,
|
||||
suggestion_limit: 10 } });
|
||||
assert.deepEqual(taggingPreset({ ...HOST, autotag_model_type: 'gguf' }), { model: 'gguf',
|
||||
options: { instructions: HOST.autotag_template, suggestion_limit: 10 } });
|
||||
assert.equal(draftProblem(HOST), null);
|
||||
assert.deepEqual(draftProblem({ ...HOST, autotag_general_threshold: 1.5 }),
|
||||
{ key: 'autotag_general_threshold', message: 'General-tag confidence must be between 0 and 1.' });
|
||||
assert.equal(draftProblem({ ...HOST, autotag_character_threshold: '' }).key, 'autotag_character_threshold');
|
||||
assert.equal(draftProblem({ ...HOST, autotag_suggestion_limit: 2.5 }).key, 'autotag_suggestion_limit');
|
||||
assert.equal(draftProblem({ ...HOST, autotag_suggestion_limit: 0 }, { limit: false }), null);
|
||||
assert.deepEqual(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: ' ' }),
|
||||
{ key: 'autotag_template', message: 'LLM tag template must be non-empty text.' });
|
||||
assert.deepEqual(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: 'x'.repeat(4001) }),
|
||||
{ key: 'autotag_template', message: 'LLM tag template must be at most 4000 characters.' });
|
||||
// Characters are code points, as Python's len counts them: 4000 emoji (8000 UTF-16 units) are allowed.
|
||||
assert.equal(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: '🏜'.repeat(4000) }), null);
|
||||
assert.equal(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: '🏜'.repeat(4001) }).key, 'autotag_template');
|
||||
// A WD14 run never reads the template, nor a language model the thresholds.
|
||||
assert.equal(draftProblem({ ...HOST, autotag_template: '' }), null);
|
||||
assert.equal(draftProblem({ ...HOST, autotag_model_type: 'gguf', autotag_general_threshold: 7 }), null);
|
||||
|
||||
const none = await mounted({ image: null });
|
||||
try {
|
||||
const preview = none.button('Preview tagging review');
|
||||
assert.equal(preview.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(preview.className, 'ca-primary');
|
||||
await preview.dispatch('click');
|
||||
assert.deepEqual(none.calls.toasts, ['Select an image in Images first.']);
|
||||
assert.deepEqual(none.calls.opened, []);
|
||||
} finally { none.scope.dispose(); none.dom.restore(); }
|
||||
|
||||
const { dom, scope, button, byId, calls } = await mounted();
|
||||
try {
|
||||
await button('Preview tagging review').dispatch('click');
|
||||
assert.equal(calls.opened.length, 1);
|
||||
assert.equal(calls.opened[0].image, IMAGE);
|
||||
assert.equal(calls.opened[0].source, 'tags');
|
||||
assert.deepEqual(calls.opened[0].preset, taggingPreset(HOST));
|
||||
byId('threshold-general').value = '1.5'; await byId('threshold-general').dispatch('change');
|
||||
assert.equal(button('Preview tagging review').getAttribute('aria-disabled'), 'true');
|
||||
await button('Preview tagging review').dispatch('click');
|
||||
assert.deepEqual(calls.toasts, ['General-tag confidence must be between 0 and 1.']);
|
||||
assert.equal(calls.opened.length, 1);
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
const PLAN = { images: 3, prompts: 2, already_tagged: 1, to_tag: 1,
|
||||
model: { type: 'wd14-swinv2', label: 'WD14 SwinV2', size_label: '~400 MB', downloaded: true }, existing: 'skip',
|
||||
review_required: false, backup: { policy: 'on' }, disk: { free_bytes: 10 * 2 ** 30, required_bytes: 2 ** 20, sufficient: true } };
|
||||
|
||||
test('the batch review grid, body and Cancel-only reasons (D134, CUJ 4–5)', async () => {
|
||||
const { batchGrid, batchBody, batchCloseNote, existingLabel, REPLACE_NOTE, NOTHING_TO_TAG, NO_LINKED_IMAGES } = await tagging;
|
||||
assert.deepEqual(batchGrid(PLAN, HOST), [['Model', 'WD14 SwinV2 · ~400 MB'], ['Existing tags', 'Skip already tagged'],
|
||||
['Images linked', '3'], ['Prompts', '2'], ['Already tagged', '1'], ['Prompts to tag', 'up to 1'],
|
||||
['Settings', 'General 0.35 · Character 0.85'], ['Backup', 'Create and verify before changes']]);
|
||||
const llm = { ...HOST, autotag_model_type: 'gguf' };
|
||||
assert.deepEqual(batchGrid({ ...PLAN, model: { type: 'gguf' }, backup: { policy: 'off' } }, llm).slice(-2),
|
||||
[['Settings', 'Template of 27 characters'], ['Backup', 'No safety backup (the policy is off)']]);
|
||||
assert.equal(batchGrid({}, HOST)[2][1], '0');
|
||||
assert.equal(existingLabel('odd'), 'odd');
|
||||
assert.deepEqual(batchBody(HOST), { model_type: 'wd14-swinv2', existing: 'skip',
|
||||
options: { general_threshold: 0.35, character_threshold: 0.85 } });
|
||||
assert.deepEqual(batchBody(llm), { model_type: 'gguf', existing: 'skip', options: { instructions: HOST.autotag_template } });
|
||||
assert.equal(batchCloseNote(PLAN), null);
|
||||
assert.equal(batchCloseNote({ ...PLAN, review_required: true }), REPLACE_NOTE);
|
||||
assert.equal(REPLACE_NOTE, 'Replacing tags needs a review of each image. Use Preview tagging review, or choose Skip or Merge for a batch run.');
|
||||
assert.equal(batchCloseNote({ ...PLAN, model: { ...PLAN.model, downloaded: false } }), 'WD14 SwinV2 is not downloaded. Download it first.');
|
||||
assert.equal(batchCloseNote({ ...PLAN, disk: { free_bytes: 2 ** 20, required_bytes: 2 ** 21, sufficient: false } }),
|
||||
'Not enough free disk space: the safety backup needs about 2.0 MB and 1.0 MB is free. Nothing is written until there is room.');
|
||||
assert.equal(batchCloseNote({ ...PLAN, prompts: 0, to_tag: 0 }), NO_LINKED_IMAGES);
|
||||
assert.equal(batchCloseNote({ ...PLAN, to_tag: 0 }), NOTHING_TO_TAG);
|
||||
assert.equal(NOTHING_TO_TAG, 'Every prompt with an output image is already tagged.');
|
||||
});
|
||||
|
||||
test('showBatchPlan opens the review with the backup box, or the Cancel-only review', async () => {
|
||||
const { showBatchPlan, REVIEW_TITLE, BATCH_SENTENCE } = await tagging;
|
||||
const { createScope } = await coreModule;
|
||||
const scope = createScope();
|
||||
const reviews = [];
|
||||
const closed = [];
|
||||
const started = [];
|
||||
const options = { values: HOST, scope, activity: { start: async (kind, body) => { started.push([kind, body]); return { job: { id: 'b1' } }; } },
|
||||
notify: () => {}, navigate: () => {}, openReview: review => reviews.push(review), openCloseOnly: review => closed.push(review) };
|
||||
showBatchPlan(PLAN, options);
|
||||
assert.equal(reviews.length, 1);
|
||||
const [review] = reviews;
|
||||
assert.equal(review.title, REVIEW_TITLE);
|
||||
assert.equal(review.title, 'Review tag output images');
|
||||
assert.equal(review.heading, 'Tag output images');
|
||||
assert.equal(review.sentence, BATCH_SENTENCE);
|
||||
assert.equal(review.primary.label, 'Start tagging');
|
||||
assert.equal(review.cancelLabel, 'Cancel');
|
||||
assert.equal(review.backup.copy.heading, 'Back up the database first (recommended)');
|
||||
await review.onStart();
|
||||
assert.deepEqual(started, [['autotag', { model_type: 'wd14-swinv2', existing: 'skip',
|
||||
options: { general_threshold: 0.35, character_threshold: 0.85 } }]]);
|
||||
showBatchPlan({ ...PLAN, backup: { policy: 'off' } }, options);
|
||||
assert.equal(reviews[1].backup, null);
|
||||
showBatchPlan({ ...PLAN, review_required: true }, options);
|
||||
assert.equal(closed.length, 1);
|
||||
assert.equal(closed[0].note.startsWith('Replacing tags needs a review'), true);
|
||||
assert.equal(closed[0].grid[0][0], 'Model');
|
||||
scope.dispose();
|
||||
});
|
||||
|
||||
test('Tag output images › Review previews the drafts, else says why; a refused preview is toasted', async () => {
|
||||
let refuse = null;
|
||||
const post = url => {
|
||||
if (refuse) throw refuse;
|
||||
assert.equal(url, '/prompt_manager/autotag/batch/preview');
|
||||
return PLAN;
|
||||
};
|
||||
const { dom, scope, button, byId, calls } = await mounted({ post });
|
||||
try {
|
||||
const review = button('Review');
|
||||
assert.equal(review.getAttribute('aria-disabled'), null);
|
||||
await review.dispatch('click');
|
||||
await tick();
|
||||
assert.deepEqual(calls.post, [['/prompt_manager/autotag/batch/preview', { model_type: 'wd14-swinv2', existing: 'skip' }]]);
|
||||
assert.equal(calls.shown.length, 1);
|
||||
assert.equal(calls.shown[0][0], PLAN);
|
||||
assert.deepEqual(calls.shown[0][1].values, HOST);
|
||||
assert.equal(review.textContent, 'Review');
|
||||
refuse = Object.assign(new Error('busy'), { code: 'busy', running: { id: 'r1', title: 'Optimize database' } });
|
||||
await review.dispatch('click');
|
||||
await tick();
|
||||
assert.deepEqual(calls.toasts.at(-1), ['Another job is running: ', { text: 'Optimize database', href: '#/activity/r1' }, '.']);
|
||||
refuse = new Error('offline');
|
||||
await review.dispatch('click');
|
||||
await tick();
|
||||
assert.equal(calls.toasts.at(-1), 'The plan could not be reviewed. Nothing was changed.');
|
||||
// A model that is not on disk cannot be reviewed: the button explains instead.
|
||||
byId('tag-model').value = 'wd14-vit'; await byId('tag-model').dispatch('change');
|
||||
assert.equal(review.getAttribute('aria-disabled'), 'true');
|
||||
await review.dispatch('click');
|
||||
assert.equal(calls.toasts.at(-1), 'WD14 ViT is not downloaded. Download it first.');
|
||||
assert.equal(calls.post.length, 3);
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('settingRefusal marks only an Auto-tagging save\'s plain 400 as invalid-setting (CUJ 2a)', async () => {
|
||||
const { settingRefusal } = await import('../../web/js/workspace/system.mjs');
|
||||
const refusal = (extra = {}) => Object.assign(new Error('General-tag confidence must be between 0 and 1.'),
|
||||
{ status: 400, refused: true, code: null, ...extra });
|
||||
const tagging = { autotag_general_threshold: 1.5, expected_revision: 'sha256:one' };
|
||||
assert.equal(settingRefusal(refusal(), tagging).code, 'invalid-setting');
|
||||
// Another section's refusal keeps the generic copy, even with a plain 400.
|
||||
const other = refusal({ message: 'Result timeout must be between 0 and 300.' });
|
||||
assert.equal(settingRefusal(other, { result_timeout: 999, expected_revision: 'sha256:one' }).code, null);
|
||||
assert.equal(settingRefusal(refusal(), undefined).code, null);
|
||||
for (const extra of [{ status: 409 }, { refused: false }, { code: 'jxl-unavailable' }]) {
|
||||
assert.notEqual(settingRefusal(refusal(extra), tagging).code, 'invalid-setting');
|
||||
}
|
||||
assert.equal(settingRefusal(undefined, tagging), undefined);
|
||||
});
|
||||
|
||||
test('a part-downloaded GGUF offers Download again', async () => {
|
||||
const { downloadLabel, partlyDownloaded } = await tagging;
|
||||
const partial = MODELS.models.gguf;
|
||||
assert.equal(partlyDownloaded('gguf', partial), true);
|
||||
assert.equal(downloadLabel('gguf', partial), 'Download again');
|
||||
assert.equal(downloadLabel('gguf', { ...partial, model_exists: false }), 'Download');
|
||||
assert.equal(downloadLabel('hf', MODELS.models.hf), 'Download');
|
||||
assert.equal(downloadLabel('wd14-swinv2', MODELS.models['wd14-swinv2']), 'Download again');
|
||||
assert.equal(partlyDownloaded('hf', { model_exists: true, mmproj_exists: false }), false);
|
||||
const { dom, scope, byId, button, description } = await mounted();
|
||||
try {
|
||||
byId('tag-model').value = 'gguf'; await byId('tag-model').dispatch('change');
|
||||
assert.equal(description(), 'Partly downloaded. Download again to finish.');
|
||||
assert.ok(button('Download again'));
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('the disk note says what is short in the server\'s words, and guesses no unknown byte counts', async () => {
|
||||
const { diskShortNote, DISK_SHORT_UNKNOWN } = await tagging;
|
||||
assert.equal(diskShortNote({ required_bytes: 3 * 2 ** 20, free_bytes: 2 ** 19, sufficient: false }),
|
||||
'Not enough free disk space: the safety backup needs about 3.0 MB and 0.5 MB is free. Nothing is written until there is room.');
|
||||
for (const disk of [{ required_bytes: NaN, free_bytes: 1 }, { required_bytes: 1, free_bytes: Infinity }, { required_bytes: -1, free_bytes: 1 },
|
||||
{ required_bytes: '1', free_bytes: 1 }, null]) {
|
||||
assert.equal(diskShortNote(disk), DISK_SHORT_UNKNOWN);
|
||||
}
|
||||
assert.equal(DISK_SHORT_UNKNOWN, 'Not enough free disk space for the safety backup. Nothing is written until there is room.');
|
||||
assert.ok(!diskShortNote({ required_bytes: 1, free_bytes: 0 }).includes('apply'));
|
||||
});
|
||||
|
||||
test('a drafts repaint never re-enables Download while its start is in flight', async () => {
|
||||
let answer;
|
||||
const start = () => new Promise(resolve => { answer = resolve; });
|
||||
const { dom, scope, byId, button, calls } = await mounted({ start });
|
||||
try {
|
||||
const download = button('Download again');
|
||||
const pressed = download.dispatch('click');
|
||||
await tick();
|
||||
assert.equal(download.getAttribute('aria-disabled'), 'true');
|
||||
byId('tag-existing').value = 'merge'; await byId('tag-existing').dispatch('change');
|
||||
assert.equal(download.getAttribute('aria-disabled'), 'true');
|
||||
// A second press while it starts is ignored.
|
||||
await download.dispatch('click');
|
||||
assert.equal(calls.started.length, 1);
|
||||
answer({ job: { id: 'd1' } });
|
||||
await pressed;
|
||||
assert.equal(download.getAttribute('aria-disabled'), null);
|
||||
assert.deepEqual(calls.navigated, ['#/activity/d1']);
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('an Unload that found nothing loaded says nothing and reads the status again', async () => {
|
||||
const { dom, scope, button, calls } = await mounted({ models: { ...MODELS, model_loaded: true, loaded_model_type: 'wd14-swinv2' },
|
||||
post: () => ({ success: true, message: 'No model was loaded' }) });
|
||||
try {
|
||||
await button('Unload').dispatch('click');
|
||||
assert.deepEqual(calls.toasts, []);
|
||||
assert.equal(calls.get.length, 2);
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('an unknown saved engine has its own reason', async () => {
|
||||
const { UNKNOWN_MODEL } = await tagging;
|
||||
const { dom, scope, button, calls } = await mounted({ host: { ...HOST, autotag_model_type: 'wd14-convnext' } });
|
||||
try {
|
||||
await button('Preview tagging review').dispatch('click');
|
||||
await button('Download').dispatch('click');
|
||||
await button('Review').dispatch('click');
|
||||
assert.deepEqual(calls.toasts, [UNKNOWN_MODEL, UNKNOWN_MODEL, UNKNOWN_MODEL]);
|
||||
assert.equal(UNKNOWN_MODEL, 'The saved tagging model is not one of these engines. Choose one and save preferences.');
|
||||
assert.deepEqual(calls.started, []);
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('a review whose dialog fails to open is toasted, not lost', async () => {
|
||||
const { dom, scope, button, calls } = await mounted({ post: () => PLAN });
|
||||
try {
|
||||
calls.shown.push = () => { throw new Error('dialog failed'); };
|
||||
await button('Review').dispatch('click');
|
||||
await tick();
|
||||
assert.equal(calls.toasts.at(-1), 'The plan could not be reviewed. Nothing was changed.');
|
||||
assert.equal(button('Review').getAttribute('aria-disabled'), null);
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('a status read, an unload and a preview that finish after the section left do nothing', async () => {
|
||||
const { mountTagging } = await tagging;
|
||||
const { createSystemDrafts } = await stateModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
const pending = [];
|
||||
const later = value => new Promise(resolve => pending.push(() => resolve(value)));
|
||||
const drafts = createSystemDrafts({ readBrowser: () => ({}), readRuntime: () => ({}),
|
||||
readHost: async () => ({ values: HOST, revision: 'sha256:one' }), writeBrowser: async () => ({}), writeHost: async () => ({}),
|
||||
writeRuntime: async () => ({}) });
|
||||
await drafts.load('host');
|
||||
const scope = createScope();
|
||||
const toasts = [];
|
||||
const shown = [];
|
||||
let gets = 0;
|
||||
const loaded = { ...MODELS, model_loaded: true, loaded_model_type: 'wd14-swinv2',
|
||||
models: { ...MODELS.models, 'wd14-swinv2': entry('WD14 SwinV2', '~400 MB', { loaded: true }) } };
|
||||
const api = { get: () => { gets += 1; return gets === 1 ? Promise.resolve(loaded) : later(MODELS); }, post: url => later(url.endsWith('/unload') ? { success: true, model_loaded: false } : PLAN) };
|
||||
const [surface] = mountTagging(null, { api, activity: {}, scope, drafts, notify: text => toasts.push(text), navigate: () => {},
|
||||
selected: () => IMAGE, openTagging: () => {}, show: plan => shown.push(plan) });
|
||||
try {
|
||||
await tick();
|
||||
const find = label => dom.find(surface, node => node.tagName === 'BUTTON' && node.textContent === label);
|
||||
const description = () => dom.find(surface, node => node.id === 'tag-model').parentNode.children[0].children[1].textContent;
|
||||
assert.equal(description(), 'Downloaded and loaded.');
|
||||
const unloading = find('Unload').dispatch('click');
|
||||
const reviewing = find('Review').dispatch('click');
|
||||
await tick();
|
||||
scope.dispose();
|
||||
for (const settle of pending.splice(0)) settle();
|
||||
await unloading;
|
||||
await reviewing;
|
||||
await tick();
|
||||
for (const settle of pending.splice(0)) settle();
|
||||
await tick();
|
||||
assert.deepEqual(toasts, []);
|
||||
assert.deepEqual(shown, []);
|
||||
// The status read after the unload never reached the page either.
|
||||
assert.equal(description(), 'Downloaded and loaded.');
|
||||
assert.equal(dom.listenerCount(), 0);
|
||||
} finally { dom.restore(); }
|
||||
});
|
||||
@@ -36,7 +36,7 @@ SUGGEST = "/prompt_manager/image-annotations/suggest"
|
||||
WAIT = 30.0
|
||||
WD14 = {"general_threshold": 0.35, "character_threshold": 0.85}
|
||||
LLM = {"instructions": "Generate tags."}
|
||||
UNAVAILABLE = "Download it from the legacy Auto-tag page."
|
||||
UNAVAILABLE = "Download it in System › Auto-tagging."
|
||||
|
||||
SCORED = [("sandstone", 0.98), ("desert", 0.96)] + [
|
||||
(f"tag_{i:03d}", round(0.9 - i * 0.005, 4)) for i in range(150)
|
||||
|
||||
@@ -310,7 +310,7 @@ class TestAutoTag(ImageTagJobTestCase):
|
||||
self.assertTrue(
|
||||
any(
|
||||
line.endswith(
|
||||
"is not downloaded. Download it from the legacy Auto-tag page."
|
||||
"is not downloaded. Download it in System › Auto-tagging."
|
||||
)
|
||||
for line in job.log
|
||||
),
|
||||
|
||||
@@ -545,6 +545,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/system-tagging.mjs": {
|
||||
"lines": 80,
|
||||
"branches": 70,
|
||||
"functions": 75,
|
||||
"statements": 80
|
||||
},
|
||||
"workspace/system-thumbnails.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
|
||||
@@ -67,6 +67,12 @@
|
||||
current.source.length > 0 && current.source.length <= 4096) {
|
||||
this.source = current.source;
|
||||
}
|
||||
// A model-busy answer names the operation that holds the tagging model (#328).
|
||||
this.operation = null;
|
||||
if (this.code === "model-busy" && current && typeof current.operation === "string" &&
|
||||
current.operation.length > 0 && current.operation.length <= 200) {
|
||||
this.operation = current.operation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ async function readDefaults(api, signal) {
|
||||
}
|
||||
}
|
||||
|
||||
const UNAVAILABLE_HINT = 'Download it from the legacy Auto-tag page.';
|
||||
export const UNAVAILABLE_HINT = 'Download it in System › Auto-tagging.';
|
||||
/**
|
||||
* Why `model` cannot run, from the `/autotag/models` status (the server's own `model-unavailable` sentence), else ''
|
||||
* (downloaded, or the status is unknown: the job then reports a missing model itself).
|
||||
|
||||
@@ -24,7 +24,7 @@ import { createButton } from '../shared/toolkit/core.js';
|
||||
import { createBridgeClient } from '../shared/toolkit/prompt-bridge.js';
|
||||
import { settingRow, operationRow } from './system-controls.mjs';
|
||||
import { HOST_UNAVAILABLE } from './system-appearance.mjs';
|
||||
import { closeWorkspaceDialog, openContentDialog, openReviewDialog } from './workspace-dialogs.mjs';
|
||||
import { openCloseOnlyReview, openReviewDialog } from './workspace-dialogs.mjs';
|
||||
import { BACKUP_COPY, applyPlan, backupText, planNote, protectedText, reviewRefusal } from './maintenance-review.mjs';
|
||||
import { downloadDatabaseBackup } from './activity-scan.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
@@ -232,26 +232,10 @@ function namesList(plan) {
|
||||
return [list];
|
||||
}
|
||||
|
||||
/** The settings file (home shown as `~`) as the LoraManager row's hover title. */
|
||||
function settingsTitle(dd, plan) {
|
||||
if (typeof plan.layout?.settings_file === 'string') dd.title = plan.layout.settings_file;
|
||||
}
|
||||
|
||||
function closeOnly({ scope, trigger, plan, note }) {
|
||||
const grid = el('dl', undefined, 'meta-grid');
|
||||
for (const [term, value] of importGrid(plan)) {
|
||||
const entry = el('div');
|
||||
const dd = el('dd', value);
|
||||
if (term === LAYOUT_TERM) settingsTitle(dd, plan);
|
||||
entry.append(el('dt', term), dd);
|
||||
grid.append(entry);
|
||||
}
|
||||
const close = createButton('Close');
|
||||
close.addEventListener('click', () => closeWorkspaceDialog());
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
actions.append(close);
|
||||
openContentDialog({ scope, trigger, title: IMPORT_TITLE, nodes: [el('h3', 'LoRA reference data'), grid, ...namesList(plan),
|
||||
el('p', note, 'small'), actions] });
|
||||
const titles = typeof plan.layout?.settings_file === 'string' ? { [LAYOUT_TERM]: plan.layout.settings_file } : {};
|
||||
openCloseOnlyReview({ scope, trigger, title: IMPORT_TITLE, heading: 'LoRA reference data', grid: importGrid(plan),
|
||||
options: namesList(plan), sentence: note, titles });
|
||||
}
|
||||
|
||||
/** Show the reviewed import; Start applies exactly this token (D107). */
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
* write that saved part of its changes throws with `partial: {saved: [keys], revision}`: those keys are saved, the
|
||||
* revision is adopted and only the rest fail (`partial`, S10). A host
|
||||
* write refused as `invalid-source` (its error's `source` and server message) makes that message the status and
|
||||
* `invalidSource()` that source, so Sources marks its row (S4).
|
||||
* `invalidSource()` that source, so Sources marks its row (S4). One refused as `invalid-setting` (S11: the server
|
||||
* named the setting it refused) makes that message the status too, naming no source.
|
||||
*/
|
||||
export { SYSTEM_SECTIONS, isSystemSection } from './state.mjs';
|
||||
|
||||
@@ -48,6 +49,9 @@ export const RESTART_LABELS = Object.freeze({ monitoring_enabled: 'Watch for new
|
||||
export const DRAFT_DOMAINS = Object.freeze(['browser', 'host', 'runtime']);
|
||||
const CONFLICT_CODE = 'settings-changed';
|
||||
const INVALID_SOURCE_CODE = 'invalid-source';
|
||||
/** S11: a `/settings` save refused for one value (a 400 whose message names the setting, e.g. a confidence of 1.5). */
|
||||
export const INVALID_SETTING_CODE = 'invalid-setting';
|
||||
const MESSAGE_CODES = new Set([INVALID_SOURCE_CODE, INVALID_SETTING_CODE]);
|
||||
const ADAPTERS = ['readBrowser', 'readHost', 'readRuntime', 'writeBrowser', 'writeHost', 'writeRuntime'];
|
||||
|
||||
/**
|
||||
@@ -182,8 +186,9 @@ export function createSystemDrafts(adapters) {
|
||||
commit(domain, written);
|
||||
return 'applied';
|
||||
} catch (error) {
|
||||
if (domain === 'host' && error?.code === INVALID_SOURCE_CODE && typeof error.message === 'string') {
|
||||
hostDetail.invalid = { source: typeof error.source === 'string' ? error.source : null, message: error.message };
|
||||
if (domain === 'host' && MESSAGE_CODES.has(error?.code) && typeof error.message === 'string') {
|
||||
const source = error.code === INVALID_SOURCE_CODE && typeof error.source === 'string' ? error.source : null;
|
||||
hostDetail.invalid = { source, message: error.message };
|
||||
}
|
||||
const partial = domain === 'host' ? error?.partial : null;
|
||||
if (partial && Array.isArray(partial.saved)) {
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* System › Auto-tagging (#328, System S11 Tasks 6–7): the concept's `renderSystem` "tagging" body (app.js 685–686).
|
||||
*
|
||||
* One `section.surface` "Auto-tagging" with the concept's rows, in its order, built with system-controls.mjs:
|
||||
* - "Tagging model" (`#tag-model`, host key `autotag_model_type`). D132: the four real engines in the concept's order,
|
||||
* each `<label> · <size_label>` from `GET /prompt_manager/autotag/models` (read once per mount; it never loads or
|
||||
* downloads a model), and the row's description is the chosen model's live status instead of the concept's design
|
||||
* note. While the read is pending the select is disabled and says "Loading model status…".
|
||||
* - "Existing tags", "General-tag confidence", "Character-tag confidence": the concept's labels and descriptions
|
||||
* verbatim (they state the shipped defaults); host keys `autotag_existing`, `autotag_general_threshold`,
|
||||
* `autotag_character_threshold`. D133 adds "Suggestions per image" (`autotag_suggestion_limit`, 1–100) after them.
|
||||
* - The `.field` "LLM tag template" (`#tag-template`, `autotag_template`).
|
||||
* - `.row-actions`: "Preview tagging review" (`ca-primary`) opens the Tag image dialog for the image selected in Images
|
||||
* with the section's current model and values, aria-disabled without one (the D115 sentence) or with an invalid
|
||||
* value (the toast names it). D132: the concept's two simulated job buttons are "Download" (or
|
||||
* "Download again" for a model on disk), which starts the `model_download` Activity job and opens it, and "Unload",
|
||||
* a direct `POST /autotag/unload` (enabled while a model is loaded; a busy model's refusal names the operation).
|
||||
* - D134: the `.operation` "Tag output images" › "Review" previews the prompt-tag batch
|
||||
* (`POST /autotag/batch/preview`, read-only) and shows it in the shared review dialog with the D55 backup box; "Start
|
||||
* tagging" runs the cancellable `autotag` Activity job. A review that cannot start (Replace after review, a short
|
||||
* disk, nothing to tag) offers only Cancel and says why.
|
||||
*
|
||||
* Every value is a host draft saved by the one Save row (system.mjs); every listener sits on the section's scope.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { settingRow, operationRow } from './system-controls.mjs';
|
||||
import { HOST_UNAVAILABLE } from './system-appearance.mjs';
|
||||
import { openCloseOnlyReview, openReviewDialog } from './workspace-dialogs.mjs';
|
||||
import { BACKUP_COPY, backupText, holding, reviewRefusal } from './maintenance-review.mjs';
|
||||
import { busyToast, downloadDatabaseBackup } from './activity-scan.mjs';
|
||||
import { MODEL_OPTIONS } from './image-tagging-state.mjs';
|
||||
import { openImageTagging } from './image-tagging.mjs';
|
||||
import { selectedImage } from './images.mjs';
|
||||
import { NO_SELECTION } from './vocabulary.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { navigateTo } from './state.mjs';
|
||||
|
||||
export const MODELS_URL = '/prompt_manager/autotag/models';
|
||||
export const UNLOAD_URL = '/prompt_manager/autotag/unload';
|
||||
export const BATCH_PREVIEW_URL = '/prompt_manager/autotag/batch/preview';
|
||||
export const DOWNLOAD_KIND = 'model-download';
|
||||
export const BATCH_KIND = 'autotag';
|
||||
export const REVIEW_TITLE = 'Review tag output images';
|
||||
export const BATCH_TITLE = 'Tag output images';
|
||||
export const START_LABEL = 'Start tagging';
|
||||
export const INTRO = 'One tagging configuration for images and prompt-library actions. Every result can be reviewed before applying.';
|
||||
export const BATCH_DESCRIPTION = 'Add tags to each prompt from its linked output images with the model above. Review the scope first.';
|
||||
export const SUGGESTIONS_DESCRIPTION = 'From 1 to 100: the most suggestions Generate shows for one image.';
|
||||
|
||||
/** D132: the engines in the concept's order, with the labels and sizes the server states when it cannot be read. */
|
||||
export const MODEL_LABELS = Object.freeze({
|
||||
'wd14-swinv2': Object.freeze({ label: 'WD14 SwinV2', size: '~400 MB' }),
|
||||
'wd14-vit': Object.freeze({ label: 'WD14 ViT', size: '~400 MB' }),
|
||||
gguf: Object.freeze({ label: 'GGUF JoyCaption', size: '~4.5 GB' }),
|
||||
hf: Object.freeze({ label: 'HuggingFace JoyCaption', size: '~16 GB' }),
|
||||
});
|
||||
export const MODEL_IDS = Object.freeze(MODEL_OPTIONS.map(option => option.id));
|
||||
const CLASSIFIERS = new Set(MODEL_OPTIONS.filter(option => option.classifier).map(option => option.id));
|
||||
|
||||
export const EXISTING_OPTIONS = Object.freeze([['skip', 'Skip already tagged'], ['merge', 'Merge with existing'],
|
||||
['replace_after_review', 'Replace after review']]);
|
||||
|
||||
/** The host draft key of each control (`id` → key). */
|
||||
export const SETTING_KEYS = Object.freeze({
|
||||
'tag-model': 'autotag_model_type',
|
||||
'tag-existing': 'autotag_existing',
|
||||
'threshold-general': 'autotag_general_threshold',
|
||||
'threshold-character': 'autotag_character_threshold',
|
||||
'tag-suggestions': 'autotag_suggestion_limit',
|
||||
'tag-template': 'autotag_template',
|
||||
});
|
||||
const NUMERIC = new Set(['autotag_general_threshold', 'autotag_character_threshold', 'autotag_suggestion_limit']);
|
||||
const MAX_TEMPLATE_CHARS = 4000;
|
||||
|
||||
export const STATUS_COPY = Object.freeze({
|
||||
loading: 'Loading model status…',
|
||||
failed: 'Model status could not be read.',
|
||||
downloaded: 'Downloaded. Not loaded.',
|
||||
loaded: 'Downloaded and loaded.',
|
||||
partial: 'Partly downloaded. Download again to finish.',
|
||||
missing: size => `Not downloaded. Download size ${size}.`,
|
||||
});
|
||||
|
||||
/** The messages that name a setting the section cannot use (the server's own wording). */
|
||||
export const SETTING_PROBLEMS = Object.freeze({
|
||||
autotag_general_threshold: 'General-tag confidence must be between 0 and 1.',
|
||||
autotag_character_threshold: 'Character-tag confidence must be between 0 and 1.',
|
||||
autotag_suggestion_limit: 'Suggestions per image must be a whole number from 1 to 100.',
|
||||
// The server's two template sentences (`validate_template`), blank first.
|
||||
autotag_template: 'LLM tag template must be non-empty text.',
|
||||
autotag_template_length: `LLM tag template must be at most ${MAX_TEMPLATE_CHARS} characters.`,
|
||||
});
|
||||
/** Characters as Python's `len` counts them (code points), not UTF-16 units. */
|
||||
export const characters = text => [...String(text ?? '')].length;
|
||||
|
||||
export const REPLACE_NOTE = 'Replacing tags needs a review of each image. Use Preview tagging review, or choose Skip or Merge for a batch run.';
|
||||
export const NOTHING_TO_TAG = 'Every prompt with an output image is already tagged.';
|
||||
export const NO_LINKED_IMAGES = 'No output image is linked to a prompt yet, so there is nothing to tag.';
|
||||
export const STATUS_UNREAD = 'The model status is still being read. Try again in a moment.';
|
||||
export const SETTINGS_UNREAD = 'The settings are still being read. Try again in a moment.';
|
||||
export const UNLOAD_FAILED = 'The tagging model could not be unloaded. Try again.';
|
||||
export const UNKNOWN_MODEL = 'The saved tagging model is not one of these engines. Choose one and save preferences.';
|
||||
export const DISK_SHORT_UNKNOWN = 'Not enough free disk space for the safety backup. Nothing is written until there is room.';
|
||||
export const modelBusy = operation => `The tagging model is busy: ${operation || 'Another tagging operation'}. Try again when it finishes.`;
|
||||
export const notDownloaded = label => `${label} is not downloaded. Download it first.`;
|
||||
const START_SAYS = new Set(['model-unavailable', 'review-required', 'insufficient-space']);
|
||||
|
||||
const number = value => (Number.isSafeInteger(value) ? value : 0).toLocaleString('en-US');
|
||||
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** The engine's display name: the server's label, else the documented one. */
|
||||
export function modelLabel(id, entry) {
|
||||
return typeof entry?.label === 'string' && entry.label ? entry.label : MODEL_LABELS[id]?.label ?? id;
|
||||
}
|
||||
|
||||
/** The download size the server states, else the documented one. */
|
||||
export function modelSize(id, entry) {
|
||||
return typeof entry?.size_label === 'string' && entry.size_label ? entry.size_label : MODEL_LABELS[id]?.size ?? '';
|
||||
}
|
||||
|
||||
/** D132 (OQ5): `<label> · <size_label>` per engine, in the concept's order. */
|
||||
export function modelOptions(models) {
|
||||
return MODEL_IDS.map(id => [id, `${modelLabel(id, models?.[id])} · ${modelSize(id, models?.[id])}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* D132: the Tagging model description for `status` (`{phase: loading|loaded|failed, models}`) and the chosen `model`.
|
||||
* GGUF with only one of its two files on disk is partly downloaded.
|
||||
*/
|
||||
export function modelDescription(status, model) {
|
||||
if (status?.phase === 'loading') return STATUS_COPY.loading;
|
||||
const entry = status?.models?.[model];
|
||||
if (status?.phase !== 'loaded' || !object(entry)) return STATUS_COPY.failed;
|
||||
if (entry.downloaded === true) return entry.loaded === true ? STATUS_COPY.loaded : STATUS_COPY.downloaded;
|
||||
if (partlyDownloaded(model, entry)) return STATUS_COPY.partial;
|
||||
return STATUS_COPY.missing(modelSize(model, entry));
|
||||
}
|
||||
|
||||
/** GGUF with only one of its two files on disk (a download that did not finish). */
|
||||
export const partlyDownloaded = (model, entry) => model === 'gguf' && object(entry) && entry.downloaded !== true
|
||||
&& entry.model_exists !== entry.mmproj_exists;
|
||||
|
||||
/** D132 (OQ10): "Download again" for an engine on disk or partly on disk, else "Download". */
|
||||
export const downloadLabel = (model, entry) => (entry?.downloaded === true || partlyDownloaded(model, entry) ? 'Download again' : 'Download');
|
||||
|
||||
/** The `/autotag/models` answer as section state: the per-engine entries and the loaded engine, if any. */
|
||||
export function readStatus(value) {
|
||||
if (!object(value) || !object(value.models)) return { phase: 'failed', models: null, loadedType: null };
|
||||
const loadedType = value.model_loaded === true && MODEL_IDS.includes(value.loaded_model_type) ? value.loaded_model_type : null;
|
||||
return { phase: 'loaded', models: value.models, loadedType };
|
||||
}
|
||||
|
||||
/** A number control's draft: a number when its text is one, else the trimmed text (the server then names the setting). */
|
||||
export function coerceNumber(text) {
|
||||
const value = String(text ?? '').trim();
|
||||
if (value === '') return value;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : value;
|
||||
}
|
||||
|
||||
/** The draft value a control's text stands for. */
|
||||
export function draftValue(key, text) {
|
||||
return NUMERIC.has(key) ? coerceNumber(text) : String(text ?? '');
|
||||
}
|
||||
|
||||
const unit = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
|
||||
|
||||
/**
|
||||
* The first setting the chosen model would use that the section cannot send, as `{key, message}`, else null: WD14
|
||||
* reads both thresholds, the language models the template; Generate also reads the suggestion count (`limit`).
|
||||
*/
|
||||
export function draftProblem(values, { limit = true } = {}) {
|
||||
const model = values.autotag_model_type;
|
||||
const keys = CLASSIFIERS.has(model) ? ['autotag_general_threshold', 'autotag_character_threshold'] : ['autotag_template'];
|
||||
if (limit) keys.push('autotag_suggestion_limit');
|
||||
for (const key of keys) {
|
||||
const value = values[key];
|
||||
if (key === 'autotag_template') {
|
||||
if (typeof value !== 'string' || value.trim() === '') return { key, message: SETTING_PROBLEMS.autotag_template };
|
||||
if (characters(value) > MAX_TEMPLATE_CHARS) return { key, message: SETTING_PROBLEMS.autotag_template_length };
|
||||
continue;
|
||||
}
|
||||
const valid = key === 'autotag_suggestion_limit' ? Number.isInteger(value) && value >= 1 && value <= 100 : unit(value);
|
||||
if (!valid) return { key, message: SETTING_PROBLEMS[key] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The Tag image dialog's preset (`{model, options}`) from the drafts: only the chosen model's own options. */
|
||||
export function taggingPreset(values) {
|
||||
const model = values.autotag_model_type;
|
||||
const options = CLASSIFIERS.has(model)
|
||||
? { general_threshold: values.autotag_general_threshold, character_threshold: values.autotag_character_threshold }
|
||||
: { instructions: values.autotag_template };
|
||||
return { model, options: { ...options, suggestion_limit: values.autotag_suggestion_limit } };
|
||||
}
|
||||
|
||||
/** The `POST /jobs/autotag` body for the drafts (WD14: both thresholds; the language models: the template). */
|
||||
export function batchBody(values) {
|
||||
const model = values.autotag_model_type;
|
||||
const options = CLASSIFIERS.has(model)
|
||||
? { general_threshold: values.autotag_general_threshold, character_threshold: values.autotag_character_threshold }
|
||||
: { instructions: values.autotag_template };
|
||||
return { model_type: model, existing: values.autotag_existing, options };
|
||||
}
|
||||
|
||||
/** The toast for a start the server refused or that failed (`what` names the action). */
|
||||
export function startRefusal(error, what) {
|
||||
if (error?.code === 'model-busy') return modelBusy(error.operation);
|
||||
if (START_SAYS.has(error?.code) && error.message) return error.message;
|
||||
return `${what} could not start. Try again.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a job of route `kind` (`model-download`, `autotag`) through the Activity store, which toasts the start; open
|
||||
* its view. A slot held by another job names it (D57); any other refusal is toasted by its code. Resolves with the
|
||||
* job, or null when none started.
|
||||
*/
|
||||
export async function startJob({ activity, scope, kind, body, what, notify = showWorkspaceToast, navigate = navigateTo }) {
|
||||
let result;
|
||||
try {
|
||||
result = await activity.start(kind, body);
|
||||
} catch (error) {
|
||||
if (!scope.signal.aborted) notify(startRefusal(error, what));
|
||||
return null;
|
||||
}
|
||||
if (scope.signal.aborted) return result.job ?? null;
|
||||
if (result.busy !== undefined) {
|
||||
notify(busyToast(result.busy));
|
||||
return null;
|
||||
}
|
||||
navigate(`#/activity/${result.job.id}`);
|
||||
return result.job;
|
||||
}
|
||||
|
||||
/** The existing-tags policy's label. */
|
||||
export const existingLabel = value => EXISTING_OPTIONS.find(([id]) => id === value)?.[1] ?? String(value ?? '');
|
||||
|
||||
/** D134: the review's rows from the server's preview and the drafts it was made with. */
|
||||
export function batchGrid(plan, values) {
|
||||
const id = plan.model?.type ?? values.autotag_model_type;
|
||||
const settings = CLASSIFIERS.has(id)
|
||||
? `General ${values.autotag_general_threshold} · Character ${values.autotag_character_threshold}`
|
||||
: `Template of ${number(characters(values.autotag_template))} characters`;
|
||||
return [
|
||||
['Model', `${modelLabel(id, plan.model)} · ${modelSize(id, plan.model)}`],
|
||||
['Existing tags', existingLabel(plan.existing ?? values.autotag_existing)],
|
||||
['Images linked', number(plan.images)],
|
||||
['Prompts', number(plan.prompts)],
|
||||
['Already tagged', number(plan.already_tagged)],
|
||||
['Prompts to tag', `up to ${number(plan.to_tag)}`],
|
||||
['Settings', settings],
|
||||
['Backup', backupText(plan.backup)],
|
||||
];
|
||||
}
|
||||
|
||||
const megabytes = bytes => `${(bytes / 2 ** 20).toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })} MB`;
|
||||
|
||||
/**
|
||||
* A disk without room for the batch's safety backup, in the server's insufficient-space wording
|
||||
* (`short_space_message`); byte counts that are not numbers are not guessed.
|
||||
*/
|
||||
export function diskShortNote(disk) {
|
||||
const required = disk?.required_bytes;
|
||||
const free = disk?.free_bytes;
|
||||
if (!Number.isFinite(required) || !Number.isFinite(free) || required < 0 || free < 0) return DISK_SHORT_UNKNOWN;
|
||||
return `Not enough free disk space: the safety backup needs about ${megabytes(required)} and ${megabytes(free)} is free. `
|
||||
+ 'Nothing is written until there is room.';
|
||||
}
|
||||
|
||||
/** Why the reviewed batch cannot start (the Cancel-only review's sentence), or null. */
|
||||
export function batchCloseNote(plan) {
|
||||
if (plan.review_required === true) return REPLACE_NOTE;
|
||||
if (plan.model?.downloaded === false) return notDownloaded(modelLabel(plan.model.type, plan.model));
|
||||
if (plan.disk?.sufficient === false) return diskShortNote(plan.disk);
|
||||
if (!Number.isSafeInteger(plan.prompts) || plan.prompts === 0) return NO_LINKED_IMAGES;
|
||||
if (plan.to_tag === 0) return NOTHING_TO_TAG;
|
||||
return null;
|
||||
}
|
||||
|
||||
export const BATCH_SENTENCE = 'Nothing is written until you start. Missing files and files outside the output folders are '
|
||||
+ 'counted, so the prompts to tag are an upper bound; Activity reports what was tagged.';
|
||||
|
||||
/** The Cancel-only review: the counts and why the batch cannot start (CUJ 5), in the shared close-only shell. */
|
||||
export function openBatchCloseOnly({ scope, trigger, grid, note }) {
|
||||
openCloseOnlyReview({ scope, trigger, title: REVIEW_TITLE, heading: BATCH_TITLE, grid, sentence: note, closeLabel: 'Cancel' });
|
||||
}
|
||||
|
||||
/** D134: show the previewed batch; Start tagging runs it as the `autotag` job. */
|
||||
export function showBatchPlan(plan, { values, activity, scope, trigger = null, notify = showWorkspaceToast, navigate = navigateTo,
|
||||
backup = () => downloadDatabaseBackup(), openReview = openReviewDialog, openCloseOnly = openBatchCloseOnly }) {
|
||||
const grid = batchGrid(plan, values);
|
||||
const note = batchCloseNote(plan);
|
||||
if (note) {
|
||||
openCloseOnly({ scope, trigger, grid, note });
|
||||
return;
|
||||
}
|
||||
const body = batchBody(values);
|
||||
openReview({ scope, trigger, title: REVIEW_TITLE, heading: BATCH_TITLE, grid, sentence: BATCH_SENTENCE,
|
||||
backup: plan.backup?.policy === 'off' ? null : { copy: BACKUP_COPY, onBackup: backup }, cancelLabel: 'Cancel',
|
||||
primary: { label: START_LABEL },
|
||||
onStart: () => startJob({ activity, scope, kind: BATCH_KIND, body, what: BATCH_TITLE, notify, navigate }) });
|
||||
}
|
||||
|
||||
/** Preview the batch for the drafts (read-only) and show it; the pressed button reads "Reviewing…" until it answered. */
|
||||
export async function reviewBatch({ api, activity, scope, values, trigger = null, notify = showWorkspaceToast, navigate = navigateTo,
|
||||
show = showBatchPlan }) {
|
||||
await holding(trigger, 'Reviewing…', async () => {
|
||||
let plan;
|
||||
try {
|
||||
plan = await api.post(BATCH_PREVIEW_URL, { model_type: values.autotag_model_type, existing: values.autotag_existing },
|
||||
{ signal: scope.signal });
|
||||
} catch (error) {
|
||||
if (scope.signal.aborted) return;
|
||||
notify(error?.code === 'busy' ? busyToast(error.running) : reviewRefusal(error));
|
||||
return;
|
||||
}
|
||||
if (!scope.signal.aborted) show(plan, { values, activity, scope, trigger, notify, navigate });
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark `button` aria-disabled (with its reason as a hover title) or enabled. */
|
||||
function setAvailable(button, reason) {
|
||||
if (reason) {
|
||||
button.setAttribute('aria-disabled', 'true');
|
||||
button.dataset.reason = reason;
|
||||
} else {
|
||||
button.removeAttribute('aria-disabled');
|
||||
delete button.dataset.reason;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount Auto-tagging in `container` (System's `.settings-layout`); returns its surface. `drafts` is the System draft
|
||||
* store, `activity` the page's Activity store. `selected()` is the image selected in Images (null when none) and
|
||||
* `openTagging` opens the Tag image dialog and `show` the previewed batch; all three are injectable for tests.
|
||||
*/
|
||||
export function mountTagging(container, { api, activity, scope, drafts, notify = showWorkspaceToast, navigate = navigateTo,
|
||||
selected = selectedImage, openTagging = openImageTagging, show = showBatchPlan }) {
|
||||
const surface = el('section', undefined, 'surface');
|
||||
surface.append(el('h2', 'Auto-tagging'), el('p', INTRO));
|
||||
const controls = [];
|
||||
const add = (row, id) => {
|
||||
const input = row.children[1];
|
||||
input.dataset.key = SETTING_KEYS[id];
|
||||
controls.push({ id, key: SETTING_KEYS[id], input, text: row.children[0] });
|
||||
surface.append(row);
|
||||
return row;
|
||||
};
|
||||
const modelRow = add(settingRow({ id: 'tag-model', label: 'Tagging model', description: STATUS_COPY.loading,
|
||||
control: { type: 'select', options: modelOptions(null) } }), 'tag-model');
|
||||
add(settingRow({ id: 'tag-existing', label: 'Existing tags', description: 'Skipping remains the default; replace and merge are explicit.',
|
||||
control: { type: 'select', options: EXISTING_OPTIONS } }), 'tag-existing');
|
||||
add(settingRow({ id: 'threshold-general', label: 'General-tag confidence', description: 'Current WD14 default: 0.35.',
|
||||
control: { type: 'number', min: 0, max: 1, step: 0.05 } }), 'threshold-general');
|
||||
add(settingRow({ id: 'threshold-character', label: 'Character-tag confidence', description: 'Current WD14 default: 0.85.',
|
||||
control: { type: 'number', min: 0, max: 1, step: 0.05 } }), 'threshold-character');
|
||||
// D133: the persisted suggestion count, which the concept does not show.
|
||||
add(settingRow({ id: 'tag-suggestions', label: 'Suggestions per image', description: SUGGESTIONS_DESCRIPTION,
|
||||
control: { type: 'number', min: 1, max: 100, step: 1 } }), 'tag-suggestions');
|
||||
|
||||
const field = el('div', undefined, 'field');
|
||||
const templateLabel = el('label', 'LLM tag template');
|
||||
templateLabel.htmlFor = 'tag-template';
|
||||
const template = el('textarea');
|
||||
template.id = 'tag-template';
|
||||
template.dataset.setting = 'tag-template';
|
||||
template.dataset.key = SETTING_KEYS['tag-template'];
|
||||
field.append(templateLabel, template);
|
||||
surface.append(field);
|
||||
controls.push({ id: 'tag-template', key: SETTING_KEYS['tag-template'], input: template, text: null });
|
||||
|
||||
for (const { key, input } of controls) {
|
||||
// Text controls draft while typing (the Save row follows at once); selects and numbers on change.
|
||||
const event = input.localName === 'textarea' ? 'input' : 'change';
|
||||
scope.listen(input, event, () => drafts.set('host', key, draftValue(key, input.value)));
|
||||
}
|
||||
|
||||
const actions = el('div', undefined, 'row-actions');
|
||||
const preview = createButton('Preview tagging review', 'ca-primary');
|
||||
const download = createButton('Download');
|
||||
const unload = createButton('Unload');
|
||||
actions.append(preview, download, unload);
|
||||
surface.append(actions);
|
||||
const batch = operationRow({ scope, title: BATCH_TITLE, description: BATCH_DESCRIPTION, label: 'Review',
|
||||
onClick: trigger => {
|
||||
if (trigger.getAttribute('aria-disabled') === 'true') { notify(trigger.dataset.reason); return; }
|
||||
reviewBatch({ api, activity, scope, values: current(), trigger, notify, navigate, show })
|
||||
.catch(error => { if (!scope.signal.aborted) notify(reviewRefusal(error)); })
|
||||
.finally(() => { if (!scope.signal.aborted) repaint(); });
|
||||
} });
|
||||
surface.append(batch);
|
||||
const review = batch.children[1].children[0];
|
||||
|
||||
let status = { phase: 'loading', models: null, loadedType: null };
|
||||
let starting = false;
|
||||
// The "could not be read" line under each row, while the server settings could not be read.
|
||||
const notes = new Map();
|
||||
const image = selected();
|
||||
const current = () => Object.fromEntries(Object.values(SETTING_KEYS).map(key => [key, drafts.get('host', key)]));
|
||||
const chosen = () => drafts.get('host', 'autotag_model_type');
|
||||
const known = () => MODEL_IDS.includes(chosen());
|
||||
|
||||
const repaint = () => {
|
||||
const values = current();
|
||||
const unread = values.autotag_model_type === undefined;
|
||||
const failed = drafts.hostState?.() === 'failed' && unread;
|
||||
for (const { key, input, text } of controls) {
|
||||
const value = values[key];
|
||||
input.disabled = value === undefined || (key === 'autotag_model_type' && status.phase === 'loading');
|
||||
// A focused field keeps what the user is typing; a select always shows its value.
|
||||
if (value !== undefined && (document.activeElement !== input || input.localName === 'select')) input.value = String(value);
|
||||
if (!text) continue;
|
||||
notes.get(key)?.remove();
|
||||
notes.delete(key);
|
||||
if (failed) {
|
||||
const note = el('p', HOST_UNAVAILABLE, 'small');
|
||||
notes.set(key, note);
|
||||
text.append(note);
|
||||
}
|
||||
}
|
||||
const labels = modelOptions(status.models);
|
||||
for (const [index, option] of [...modelRow.children[1].options].entries()) option.textContent = labels[index][1];
|
||||
modelRow.children[0].children[1].textContent = unread && status.phase !== 'loading' ? STATUS_COPY.failed
|
||||
: modelDescription(status, values.autotag_model_type);
|
||||
|
||||
const unknown = { message: failed ? HOST_UNAVAILABLE : unread ? SETTINGS_UNREAD : UNKNOWN_MODEL };
|
||||
const problem = known() ? draftProblem(values) : unknown;
|
||||
setAvailable(preview, !image ? NO_SELECTION : problem?.message);
|
||||
const entry = status.models?.[values.autotag_model_type];
|
||||
// A start in flight keeps Download as it is (aria-disabled), whatever a draft change repaints.
|
||||
if (!starting) {
|
||||
download.textContent = downloadLabel(values.autotag_model_type, entry);
|
||||
if (status.phase === 'loading') setAvailable(download, STATUS_UNREAD);
|
||||
else if (!known()) setAvailable(download, unknown.message);
|
||||
else if (status.phase !== 'loaded') setAvailable(download, STATUS_COPY.failed);
|
||||
else setAvailable(download, null);
|
||||
}
|
||||
// Unload is offered only while a model is loaded.
|
||||
unload.disabled = !(status.phase === 'loaded' && status.loadedType);
|
||||
if (review.textContent === 'Reviewing…') return;
|
||||
const batchProblem = known() ? draftProblem(values, { limit: false }) : unknown;
|
||||
setAvailable(review, entry?.downloaded === false ? notDownloaded(modelLabel(values.autotag_model_type, entry))
|
||||
: batchProblem?.message);
|
||||
};
|
||||
|
||||
let reads = 0;
|
||||
const readModels = async () => {
|
||||
const read = ++reads;
|
||||
let next;
|
||||
try {
|
||||
next = readStatus(await api.get(MODELS_URL, { signal: scope.signal, cache: 'no-store' }));
|
||||
} catch {
|
||||
next = { phase: 'failed', models: null, loadedType: null };
|
||||
}
|
||||
if (read !== reads || scope.signal.aborted) return;
|
||||
status = next;
|
||||
repaint();
|
||||
};
|
||||
|
||||
scope.listen(preview, 'click', () => {
|
||||
if (preview.getAttribute('aria-disabled') === 'true') { notify(preview.dataset.reason); return; }
|
||||
openTagging({ api, scope, image, source: 'tags', trigger: preview, preset: taggingPreset(current()) });
|
||||
});
|
||||
scope.listen(download, 'click', async () => {
|
||||
if (download.getAttribute('aria-disabled') === 'true') { notify(download.dataset.reason); return; }
|
||||
const model = chosen();
|
||||
starting = true;
|
||||
try {
|
||||
await holding(download, null, () => startJob({ activity, scope, kind: DOWNLOAD_KIND, body: { model_type: model },
|
||||
what: `Tagging model download · ${modelLabel(model, status.models?.[model])}`, notify, navigate }));
|
||||
} finally {
|
||||
starting = false;
|
||||
}
|
||||
if (!scope.signal.aborted) repaint();
|
||||
});
|
||||
scope.listen(unload, 'click', async () => {
|
||||
const model = status.loadedType;
|
||||
unload.disabled = true;
|
||||
try {
|
||||
const result = await api.post(UNLOAD_URL, {}, { signal: scope.signal });
|
||||
if (scope.signal.aborted) return;
|
||||
// Only an answer that unloaded something says so; "No model was loaded" just reads the status again.
|
||||
if (result?.model_loaded === false) notify(`${modelLabel(model, status.models?.[model])} unloaded.`);
|
||||
} catch (error) {
|
||||
if (scope.signal.aborted) return;
|
||||
notify(error?.code === 'model-busy' ? modelBusy(error.operation) : UNLOAD_FAILED);
|
||||
}
|
||||
await readModels();
|
||||
});
|
||||
|
||||
scope.cleanup(drafts.subscribe(repaint));
|
||||
repaint();
|
||||
readModels();
|
||||
return [surface];
|
||||
}
|
||||
@@ -6,20 +6,22 @@
|
||||
* truthful and "Save preferences" is aria-disabled while nothing is unsaved. A section not delivered yet shows the
|
||||
* per-section hand-off to its legacy tool (D94), never mock placeholders. Overview (system-overview.mjs),
|
||||
* Appearance (system-appearance.mjs), Sources & scans (system-sources.mjs), Integrations (system-integrations.mjs),
|
||||
* Maintenance (system-maintenance.mjs) and Backup & export (system-backups.mjs) are native.
|
||||
* Auto-tagging (system-tagging.mjs), Maintenance (system-maintenance.mjs) and Backup & export (system-backups.mjs) are
|
||||
* native.
|
||||
*
|
||||
* Switching sections keeps the shell (and focus on the pressed button) and disposes the old section's scope, so its
|
||||
* listeners and timers never outlive it. The drafts are module-level (Q10): they live for the page session.
|
||||
*/
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { SYSTEM_SECTIONS, navigateTo } from './state.mjs';
|
||||
import { createSystemDrafts, DRAFT_DOMAINS } from './system-state.mjs';
|
||||
import { createSystemDrafts, DRAFT_DOMAINS, INVALID_SETTING_CODE } from './system-state.mjs';
|
||||
import { sectionNav } from './system-controls.mjs';
|
||||
import { mountOverview } from './system-overview.mjs';
|
||||
import { mountAppearance } from './system-appearance.mjs';
|
||||
import { mountSources } from './system-sources.mjs';
|
||||
import { mountMaintenance } from './system-maintenance.mjs';
|
||||
import { mountBackups } from './system-backups.mjs';
|
||||
import { mountTagging } from './system-tagging.mjs';
|
||||
import { LORA_ENABLE_URL, LORA_STATUS_URL, loraValues, mountIntegrations, writeHostInParts } from './system-integrations.mjs';
|
||||
import { busyToast } from './activity-scan.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
@@ -28,12 +30,10 @@ import { displayPreferences, readPreferences, reconcilePreferences, subscribePre
|
||||
const SETTINGS_URL = '/prompt_manager/settings';
|
||||
const ADMIN = Object.freeze({ href: '/prompt_manager/admin', label: 'Open existing administration' });
|
||||
/**
|
||||
* D94: where each section's capabilities live until its PR (the plan's legacy capability coverage): the admin page's
|
||||
* dashboard, Settings, Maintenance, Backup, Restore, Scan, Logs, Diagnostics and Auto-tag tools, and the gallery's
|
||||
* settings, thumbnails, duplicates and cache tools.
|
||||
* D94: where each section's capabilities live until its PR (the plan's legacy capability coverage): only Diagnostics &
|
||||
* logs still hands off, to the admin page's Logs and Diagnostics tools.
|
||||
*/
|
||||
export const SECTION_HANDOFFS = Object.freeze({
|
||||
tagging: [ADMIN],
|
||||
diagnostics: [ADMIN],
|
||||
});
|
||||
export const NOT_DELIVERED = 'This setting is not available here yet.';
|
||||
@@ -56,6 +56,20 @@ function el(tag, text, className) {
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* S11 (CUJ 2a): a `/settings` save that carried Auto-tagging values (`autotag_*` keys in `body`) and was refused with a
|
||||
* plain 400 naming the setting ("General-tag confidence must be between 0 and 1."): mark it `invalid-setting`, so the
|
||||
* Save row shows the server's sentence. Every other section's refusal is left as it was (the generic copy).
|
||||
*/
|
||||
export function settingRefusal(error, body) {
|
||||
const tagging = Object.keys(body ?? {}).some(key => key.startsWith('autotag_'));
|
||||
if (tagging && error?.status === 400 && error.refused === true && !error.code && typeof error.message === 'string'
|
||||
&& error.message) {
|
||||
error.code = INVALID_SETTING_CODE;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
/** A domain whose settings no delivered section edits yet: nothing can be drafted, and a write would say it failed. */
|
||||
const undelivered = async () => { throw new Error(NOT_DELIVERED); };
|
||||
|
||||
@@ -92,7 +106,8 @@ export function systemDrafts(api) {
|
||||
// a save where only one part was written reports which (writeHostInParts, review I3).
|
||||
try {
|
||||
return await writeHostInParts({ changes, saved: drafts.saved('host'), expected_revision,
|
||||
postLora: body => api.post(LORA_ENABLE_URL, body), postSettings: body => api.post(SETTINGS_URL, body) });
|
||||
postLora: body => api.post(LORA_ENABLE_URL, body),
|
||||
postSettings: body => api.post(SETTINGS_URL, body).catch(error => { throw settingRefusal(error, body); }) });
|
||||
} catch (error) {
|
||||
// A sources change waits for a running record-changing job (S4): name it, as a refused start does (D57).
|
||||
if (error?.code === 'busy') showWorkspaceToast(busyToast(await runningJob(error)));
|
||||
@@ -128,13 +143,14 @@ function markFailed(layout, store) {
|
||||
|
||||
/**
|
||||
* The sections delivered so far: Overview (S2 Task 6), Appearance (S3 Task 8), Sources & scans (S4 Task 11),
|
||||
* Integrations (S10 Task 27), Maintenance (S7 Task 19) and Backup & export (S9 Task 24).
|
||||
* Integrations (S10 Task 27), Auto-tagging (S11 Task 6, #328), Maintenance (S7 Task 19) and Backup & export (S9 Task 24).
|
||||
*/
|
||||
export const DELIVERED_SECTIONS = Object.freeze({
|
||||
overview: mountOverview,
|
||||
appearance: mountAppearance,
|
||||
sources: mountSources,
|
||||
integrations: mountIntegrations,
|
||||
tagging: mountTagging,
|
||||
maintenance: mountMaintenance,
|
||||
backups: mountBackups,
|
||||
});
|
||||
|
||||
@@ -18,7 +18,8 @@ import { selectedImage } from './images.mjs';
|
||||
import { onImageTagsSaved, openImageTagging } from './image-tagging.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
const NO_SELECTION = 'Select an image in Images first.';
|
||||
/** D115: why Auto-tag images (and System › Auto-tagging's Preview tagging review) only explains without a selection. */
|
||||
export const NO_SELECTION = 'Select an image in Images first.';
|
||||
|
||||
const COPY = {
|
||||
tags: {empty: 'No tags yet.', error: 'Tags could not be loaded. Nothing was changed.', more: 'Load more tags'},
|
||||
|
||||
@@ -482,6 +482,35 @@ export function openReviewDialog({ scope, title, heading = null, grid, options =
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A review that cannot start: the concept's record-changing review shell (`maintenancePlan`) with its `h3`, the
|
||||
* `dl.meta-grid` rows, any `options` nodes, the reason as its `p.small` and a single dismissal (`closeLabel`). `titles`
|
||||
* maps a grid term to its value's hover title. Used by Integrations (D107, "Close") and Auto-tagging (D134, "Cancel").
|
||||
*/
|
||||
export function openCloseOnlyReview({ scope, title, heading = null, grid, options = [], sentence, closeLabel = 'Close', titles = {},
|
||||
trigger = null }) {
|
||||
requireScope(scope);
|
||||
requireGrid(grid);
|
||||
for (const [name, text] of Object.entries({ title, sentence, closeLabel })) {
|
||||
if (typeof text !== 'string' || !text) throw new TypeError(`The review needs its ${name}`);
|
||||
}
|
||||
const nodes = heading ? [el('h3', heading)] : [];
|
||||
const list = el('dl', undefined, 'meta-grid');
|
||||
for (const [term, value] of grid) {
|
||||
const entry = el('div');
|
||||
const dd = el('dd', value);
|
||||
if (typeof titles[term] === 'string') dd.title = titles[term];
|
||||
entry.append(el('dt', term), dd);
|
||||
list.append(entry);
|
||||
}
|
||||
const close = createButton(closeLabel);
|
||||
close.addEventListener('click', () => closeWorkspaceDialog());
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
actions.append(close);
|
||||
nodes.push(list, ...options, el('p', sentence, 'small'), actions);
|
||||
show(title, nodes, { scope, trigger });
|
||||
}
|
||||
|
||||
/**
|
||||
* The concept's "Review metadata scan" (`scan-review`, D55). `sources` is the number of output folders the scan reads.
|
||||
* The scope is the concept's "New and unresolved files"; the "All files" checkbox (D58, the concept's `.setting-row`
|
||||
|
||||
@@ -432,3 +432,10 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
@media (max-width: 700px) {
|
||||
#workspace-main .setting-row :is(#send-destination, #renderer) { max-width: 160px !important; /* D106 */ }
|
||||
}
|
||||
/* D132: at phone width the concept's 120px cap cuts every engine label ("WD14 SwinV2 · ~400 MB"); the Tagging model
|
||||
select alone keeps its natural width there (its widest engine label) and, with no room beside the row text, wraps
|
||||
under it. */
|
||||
@media (max-width: 700px) {
|
||||
#workspace-main .setting-row:has(> #tag-model) { flex-wrap: wrap; /* D132 */ }
|
||||
#workspace-main .setting-row #tag-model { flex-shrink: 0; max-width: 100% !important; /* D132 */ }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user