Compare commits

...
Author SHA1 Message Date
Vito Sansevero 5a2b926f3c feat(tagging): pick saved instructions in the tagging dialogs (#431)
Squashed from PR #588. Closes #431.
2026-10-31 19:32:39 -07:00
Vito Sansevero 8a2ea9bd97 feat(tagging): saved instructions with a default per engine (#431)
Squashed from PR #587.
2026-10-31 19:32:37 -07:00
Vito Sansevero ede0ab8394 feat(maintenance): repair old Pair prompts' empty category and tags (#550)
Squashed from PR #579. Closes #550.
2026-10-31 19:32:36 -07:00
44 changed files with 3905 additions and 207 deletions
+140 -12
View File
@@ -1886,7 +1886,8 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68
engines in the mock's order with legacy labels and sizes (`WD14 SwinV2 · ~400
MB`, `WD14 ViT · ~400 MB`, `GGUF JoyCaption (Recommended) · ~4.5 GB` in the
mock's Ollama slot, `HuggingFace JoyCaption · ~16 GB`); the instructions start
from the server's `default_prompt`.
from the chosen engine's default saved instructions (#431, D204; before, the
server's `default_prompt`).
- **D91** (plan D68, ruling T3-bulk): a **Suggestions** number field (1–100) for
every model, prefilled from `autotag.defaults.suggestion_limit` (10); a model
change keeps it.
@@ -2978,7 +2979,8 @@ change: ComfyUI needs a restart.
A Pair save, by a run or the node's Save pair button, writes the category and tags to the positive prompt only when that
save creates the prompt row (maintainer ruling). An existing prompt never changes on a run: empty, cleared or set by a person, including one first seen as
another pair's negative. The negative stays empty, the saved pair keeps its own metadata, and `save_prompt_pair` writes
no prompt metadata unless called with `fill_new_positive`. No backfill; optional repair is #550.
no prompt metadata unless called with `fill_new_positive`. No backfill on a run; the opt-in repair for prompts older
Pair runs saved is System › Maintenance › "Pair category and tags" (#550, "Pair category and tags repair" below).
Tests: `tests/test_prompt_pair_node.py`, `tests/test_api_pairs.py`, `tests/comfyui/pair-node.spec.mjs`. A Python change: ComfyUI needs a restart.
### Delete… on the archived shelf (#492)
@@ -4307,6 +4309,12 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
primary stays the concept's plain button; the plan it opens has the primary.
`duplicates dialog matches the concept` and `duplicates comparison detects mutations`
(system-maintenance-concept.spec) pin the mapping.
- **D192** (Maintenance, #550, beyond the concept): Database adds "Pair category and
tags" after "Unused prompt components", a plain Review plan row (no danger mark: it
deletes nothing) with the line "Fill an empty category or tags on prompts older Pair
node runs saved, from their saved pairs. Only empty fields of never-edited prompts."
`system-maintenance-concept.spec.mjs` maps it (`mapPairRow`) as a copy of Optimize
database's row.
- **D102** (Maintenance, S7, Q5, #222): Metadata cache clears only the browser entries
Prompt Manager owns as a cache (`web/js/cache-ownership.js`). The inventory of every
storage writer has none (see "Browser cache ownership" below), so the row's line adds
@@ -4606,9 +4614,9 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
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, Already auto-tagged, Images to tag
("about N"), Time, Settings ("General <g> · Character <c>" or "Template of N
characters", then " · up to L tags per image" with the preview's saved
`suggestion_limit`) and Backup, the estimate sentence ("Nothing is written until you
("about N"), Time, Settings ("General <g> · Character <c>", then " · up to L tags
per image" with the preview's saved `suggestion_limit`; for a language model, since
D205, an "Instructions" row and "Up to L tags per image") and Backup, the estimate sentence ("Nothing is written until you
start. The number to tag is an estimate: images changed since they were tagged are
tagged again, and missing files are skipped. Activity reports what was tagged.") in the
`p.small`, the D55 backup box and Cancel / "Start tagging", which starts the
@@ -4650,6 +4658,51 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
concept row's), `skipped tags add, preview, remove, save and reload (D202)`, `skipped
tags preview checks library tags only …` (workspace-system-tagging.spec) and
`suggestions omit skipped tags and say how many were hidden` (workspace-tagging.spec).
- **D203** (Auto-tagging, #431, beyond the concept): "Saved instructions" replaces the
concept's "LLM tag template" field (the mock's state 2): a `.setting-block`
(`data-setting="tag-instructions"`, `data-key="autotag_instructions"`) with the label,
"Instructions for the language-model engines. Built-in can’t be changed; duplicate it
to make your own. N of 20 saved · Built‑in is not counted.", a `role="listbox"` list
(Built-in first with a "Read-only" chip, then the saved entries; "Default for GGUF" /
"Default for HuggingFace" chips), "+ New" ("New instructions", "(2)" if taken),
"Duplicate" ("<name> copy"), "Delete" (`.danger`, disabled for Built-in), "At most 20
saved instructions." at the cap, and the editor beside the list (under it below 700px):
"Name" ("N of 60") and "Text" ("N of 4,000 characters"), read-only for Built-in with
"Built-in instructions can’t be changed. Duplicate them to make your own.", else the
entry's problem ("The name “X” is already used.", "The text must not be empty."…).
Then two concept select rows, "Default for GGUF JoyCaption" and "Default for
HuggingFace JoyCaption" ("The Tag image dialog and batches start from this.",
`data-key="autotag_default_instructions"`), and the note a deleted default leaves
("GGUF now uses Built-in."; nothing is written until Save preferences). Pinned by the
comparison's mapping (system-tagging-concept.spec: the block in the concept row's
tokens, the two rows on the concept's select row) and `saved instructions: new, edit,
per-engine default, delete, save and reload (D203)`, `a refused instructions save names
the entry and highlights the block (D203)` (workspace-system-tagging.spec).
- **D204** (Tag image and Auto-tag selected dialogs, #431, beyond the concept): for a
language-model engine a "Saved instructions" select (`#image-tag-instructions-pick`,
`#bulk-tag-instructions-pick`) sits above "Tagging instructions" (the mock's state 3),
on the concept's select field: Built-in, then the saved entries (`GET /autotag/models`
`instructions`, full text), the engine's default marked "<name> · default". It starts
at that default; a pick replaces the text. The text stays editable for the run: while
it differs from the picked entry the select shows a disabled "Edited for this run" and
a muted line under the textarea reads "Edits apply to this run only. Manage saved
instructions in System › Auto-tagging." Choosing an entry again replaces the edit.
Switching between GGUF and HF starts from the new engine's default and drops an edit;
WD14 hides both. A preset (Preview tagging review, the bulk review) shows the entry
with its text, else "Edited for this run". When the read fails the select holds only
Built-in with the client's copy (`BUILTIN_INSTRUCTIONS`, pinned equal to
`DEFAULT_TAG_TEMPLATE` by workspace-image-tagging-state.test). Request bodies are
unchanged (`options.instructions` is the textarea's text); there is no "Save as new".
The workspace no longer reads `default_prompt`. Pinned by the tagging comparison's
mapping (tagging-canonical-comparison.mjs: the select before the instructions),
`the saved instructions picker fills, edits one-off and resets per engine (D204)`
(workspace-tagging.spec) and `Auto-tag selected picks saved instructions and sends the
text shown (D204)` (workspace-tagging-bulk.spec).
- **D205** (Auto-tagging, #431; amends D134): for a language-model engine the batch
review adds a read-only "Instructions" row with the drafted engine default's name
before "Settings", which reads "Up to L tags per image"; the batch and Preview tagging
review send that entry's text. WD14 is unchanged. Pinned by `the batch review names the
language model's default instructions (D205)` (workspace-system-tagging.spec).
- **D158** (Sources, #247 D-Q4): the concept has no switch for prompts from workflows
without a PromptManager node; the maintainer chose a visible switch over a config key.
A `.setting-row` in the concept's toggle markup follows "Watch for new outputs":
@@ -4881,6 +4934,47 @@ replaced the database since the review; the plan is dropped). The job:
prompt ids only), logs the backup by its file name, and prunes safety backups to the
newest 10. With no backup the log and the status line say "No safety backup".
#### Pair category and tags repair (#550)
`pair_metadata` ("Pair category and tags", D192; `database/pair_metadata_repair.py`) fills
the empty category and the empty tag set of prompts that Pair node runs before #546
saved, from their saved pairs. It is reviewed and applied like the operations above and
follows the safety backup policy like them (no forced backup). Rules
(`docs/egm/post-3.4.1/550-design.md`):
- Considered: prompts the Pair node created as a positive: their earliest pair (lowest
id, either role, archived included) has them as the positive and its `created_at` is
within 2 s of theirs. A prompt first saved by the PromptManager node, or first as a
negative, is out of scope and not counted.
- Sources: the prompt's non-archived pairs where it is the positive; pairs with a blank
category or no tags are ignored for that field. Category values are trimmed and
case-sensitive, tag sets compared by tag id; a field fills when exactly one value is
left. A whitespace-only category counts as empty.
- Kept, per field, in this order: archived prompt, edited prompt (`updated_at` not within
2 s of `created_at`; NULL or unreadable counts as edited), pairs that disagree, a tag
set over 20 tags. A prompt can fill one field and keep the other.
- The plan lists protected rows first, then fills, then a "Record" row, within the 50
detail rows; at most 20,000 prompts per plan ("review again for the rest").
- The fingerprint covers each considered prompt's computed result (values, tag ids and
names, source pair ids), never a pair's title, notes, rating, run count or use
stamps, so a Pair run between review and apply still applies.
- The apply's writes are guarded (`category` still blank, no `prompt_tags` row) and a
guard that hits nothing rolls the whole transaction back as `plan-changed`. It never
writes `prompt_pairs`, `prompt_pair_tags`, `updated_at`, a tag or Library state.
- The job lists 200 changed rows ("Prompt 12: category “night”; tags a, b"); its
Download report lists every one (`truncated: false`, `Job.record_counted(complete=True)`).
Protected lists one row per kept prompt in the review's wording and order ("Prompt 7:
kept, pairs disagree on category: Adult, asian"), also complete in the report.
- Activity shows a row with no path as a record: its detail as plain text, never an open
or locate action, and the filter reads the detail. A maintenance job of records (every
operation but Missing-file links) says "No records in this group.", "No listed record
matches this filter." and "Filter records…" (`groupCopy`); file jobs keep "files".
Tests: `tests/test_maintenance_pair_metadata.py`, `tests/test_api_maintenance_pair_metadata.py`,
`tests/test_jobs.py`, `tests/js/workspace-system-maintenance.test.js`,
`tests/browser/workspace-system-maintenance.spec.mjs` (hook `/__test__/system/pair-metadata`).
A Python change: ComfyUI needs a restart.
The legacy restore (`POST /prompt_manager/restore`) takes a backup of any size the disks
have room for, so a real library's safety backup restores: the upload, its staged copy
and the swap (the upload twice in the temporary folder, once more plus the current
@@ -5235,16 +5329,16 @@ server-side tagging configuration, `autotag.defaults` in config.json: `model_typ
(`wd14-swinv2`, `wd14-vit`, `gguf`, `hf`), `existing` (`skip`, `merge`,
`replace_after_review`), `general_threshold` and `character_threshold` (0–1 in
steps of 0.05, #369),
`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
`suggestion_limit` (1–100), and since #431 `skipped_tags`, `instructions` and
`default_instructions` (below). They are host drafts (`autotag_model_type`, `autotag_existing`,
`autotag_general_threshold`, `autotag_character_threshold`, `autotag_suggestion_limit`,
`autotag_skipped_tags`, `autotag_instructions`, `autotag_default_instructions`) 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
Instruction text limits count code points, as Python's `len` does. A hand-edited bad value
falls back to its default on load, one key at a time. Every page shows the thresholds in
steps of 0.05 (the System rows, the Tag image dialogs, the legacy sliders), so a saved
threshold off that grid is refused ("General-tag confidence must be a multiple of
@@ -5255,8 +5349,42 @@ stored exactly on the grid (#369). A per-request threshold keeps its own check:
`validate_options` and starts the `autotag` job titled "Tag output images (legacy page)";
the legacy `/autotag/single` only requires a number. `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.
`default_prompt`, `model_type`, `existing`, and since #431 `instructions` and
`default_instructions`), so the Tag image and Auto-tag selected dialogs start from them;
a preset (the bulk review, Preview tagging review) wins.
**Saved instructions** (#431, D203, D205): `autotag.defaults.instructions` holds the
saved entries `[{id, name, text}]` in order (at most 20; `id` 12 lowercase hex made by
the client, unique, `builtin` reserved; `name` trimmed, 1–60 characters, no control
character, unique case-folded, not "Built-in"; `text` as `validate_template`), and
`autotag.defaults.default_instructions` is `{gguf, hf}`, each `builtin` or a saved id.
Built-in (`{id: "builtin", name: "Built-in", text: DEFAULT_TAG_TEMPLATE}`) is never
stored; readers put it first (`AutotagConfig.all_instructions`), so a later release's
built-in text reaches everyone who uses it. Refusals (400, the Save row shows them and
highlights the editor): "Saved instructions: at most 20 entries besides Built-in.",
"Saved instructions: entry N has an invalid id.", "… repeats the id of entry M.", "…
needs a name.", "… name must be at most 60 characters.", "… name must not contain a line
break.", "… name “X” is already used.", "Saved instructions: the name “Built-in” is
reserved.", "Saved instructions: entry N text must be …", "Default for GGUF JoyCaption
must be one of the saved instructions." (and HuggingFace). A save carrying either key
without `expected_revision` is refused ("Reload the page and try again."); a save
carrying one is paired with the other from the running configuration and both are
written, plus the old `template` key as the GGUF default's text (deleted when that
default is Built-in), so an older build reads that default as its one template. `POST`
with `autotag_template` is refused ("LLM tag template was replaced by Saved instructions.
Reload the page."); `GET` keeps returning it (the GGUF default's text, read-only) for one
release and adds `autotag_builtin_instructions` (Built-in's text, read-only). Load: each
key alone (a damaged list → Built-in only, a default naming nothing → Built-in, each with
one warning), then the in-memory migration of the old `template`: with no `instructions`
key, a template other than Built-in becomes "Custom template" (id = first 12 hex of its
SHA-256), the default of both engines unless `default_instructions` is stored; with the
key, a template matching no entry (edited on an older build) is appended as "Custom
template (n)" with one INFO line (a full list: one WARNING, nothing imported). Nothing is
written at load; the next save that carries either key writes the list.
`AutotagConfig.default_instructions_text(engine)` feeds the legacy batch and single
blank-prompt fallbacks (by the request's engine) and `default_prompt`. Tests:
`tests/test_saved_instructions.py`, `tests/test_saved_instructions_routes.py`,
`tests/js/workspace-system-instructions.test.js`.
**Skipped tags** (#431, D202): `autotag.defaults.skipped_tags`, host draft
`autotag_skipped_tags`, a list of at most 200 entries, each trimmed, 1–50 code points,
+15 -2
View File
@@ -2,7 +2,8 @@
Four operations, each the native owner of a legacy ``/maintenance`` one and
built on the same queries, so a preview counts what the legacy cleanup would
change:
change (``workflow_storage`` and #550's ``pair_metadata``, which fills old
Pair prompts' empty category and tags, live in their own modules):
- ``duplicate_prompts``: prompts whose text is equal ignoring case and outer
spaces (``DUPLICATE_PROMPTS_SQL``, legacy ``cleanup_duplicates``); each
@@ -60,6 +61,9 @@ MAINTENANCE_OPERATIONS = (
"missing_links",
# Applied as its own batched job (database/workflow_storage.py).
"workflow_storage",
# #550: fill old Pair prompts' empty category and tags
# (database/pair_metadata_repair.py).
"pair_metadata",
)
MAX_DETAILS = 50 # detail rows a plan lists
MAX_RESULT_ROWS = 200 # changed records an apply describes (the job lists 200)
@@ -533,6 +537,10 @@ class MaintenanceOperations:
lambda conn: (self._link_rows(conn), _mark_rows(conn)),
)
return self._plan_missing_links(rows, roots, _stale_marks(marks, roots))
if operation == "pair_metadata":
from .pair_metadata_repair import plan_pair_metadata
return _read_snapshot(self.model.db_path, plan_pair_metadata)
reader = (
self._plan_duplicate_prompts
if operation == "duplicate_prompts"
@@ -800,7 +808,8 @@ class MaintenanceOperations:
records changed and up to ``MAX_RESULT_ROWS`` descriptions of them
(``{prompt_id[, kept_id]}`` or ``{image_path}``). ``marks`` are a
missing-links plan's stale removal marks; that apply also returns
``marks``, how many it forgot.
``marks``, how many it forgot. ``pair_metadata`` returns every filled
prompt, uncapped (its job keeps the complete record, #550).
"""
if operation not in MAINTENANCE_OPERATIONS or operation == "workflow_storage":
raise ValueError(f"Unknown maintenance operation {operation!r}")
@@ -815,6 +824,10 @@ class MaintenanceOperations:
"unused_components": self._apply_unused_components,
}
with self._pair_transaction() as conn:
if operation == "pair_metadata":
from .pair_metadata_repair import apply_pair_metadata
return apply_pair_metadata(conn, fingerprint, targets)
if operation == "missing_links":
return self._apply_missing_links(
conn, fingerprint, targets, roots, list(marks)
+452
View File
@@ -0,0 +1,452 @@
"""Pair category and tags repair (#550): the ``pair_metadata`` maintenance plan.
Before #546 a Pair node run saved its category and tags on the saved pair
only; the positive prompt it created stayed Uncategorized and untagged. This
plan fills the **empty** category and the **empty** tag set of those prompts
from their saved pairs (``docs/egm/post-3.4.1/550-design.md``):
- Considered: prompts the Pair node created as a positive (their earliest
pair, in either role, has them as the positive and its ``created_at`` is
within ``PAIR_CREATED_TOLERANCE`` of theirs) whose category is blank
and/or that have no tags.
- Sources: the prompt's non-archived pairs where it is the positive. Pairs
with a blank category or no tags are ignored for that field. A field fills
when exactly one value remains (category: trimmed, case-sensitive; tags:
the tag-id set).
- Kept (protected), in this order: archived prompt, edited prompt
(``updated_at`` not within the tolerance of ``created_at``), pairs that
disagree, a tag set over ``MAX_LIBRARY_TAGS``.
The apply runs in the caller's ``BEGIN IMMEDIATE`` transaction, recomputes
the fingerprint, then writes guarded statements (the field must still be
empty) that never touch a pair, a negative or ``updated_at``. A guard that
hits nothing raises ``PlanChanged`` so the whole transaction rolls back.
"""
import datetime
from . import maintenance as _maintenance
from .library_commands import MAX_LIBRARY_TAGS
from .maintenance import (
MAX_DETAILS,
MaintenanceDraft,
PlanChanged,
_excerpt,
_Fingerprint,
_marks,
_plural,
)
OPERATION = "pair_metadata"
# Seconds: the Pair save stamps its new prompt and its pair with separate
# utc_now_iso() calls in one transaction (microseconds apart).
PAIR_CREATED_TOLERANCE = 2.0
# Fill prompts one plan applies; the rest are applied by the next review.
# Bounds the complete record a job keeps in memory (about 100 bytes a row).
MAX_PLAN_FILLS = 20000
DETAIL_VALUES = 3
DETAIL_TAGS = 5
MEDIA_LABEL = "No image file or saved pair is changed."
EMPTY_LABEL = "No Pair prompts are missing a category or tags"
RECORD_NOTE = (
"The job's report lists every filled field. Download it to keep it: "
"Activity forgets jobs after a restart."
)
# Protected classes in precedence order: (key, detail, reason one, many).
EDITED_DETAIL = (
"Kept: edited after it was saved; its empty category or tags may be intentional"
)
PROTECTED = (
(
"archived",
"Kept: archived in the Library",
"archived in the Library",
"archived in the Library",
),
(
"edited",
EDITED_DETAIL,
"edited after it was saved",
"edited after they were saved",
),
("conflict", None, "where pairs disagree", "where pairs disagree"),
(
"too-many-tags",
f"Kept: its pair has more than {MAX_LIBRARY_TAGS} tags",
f"whose pair has more than {MAX_LIBRARY_TAGS} tags",
f"whose pair has more than {MAX_LIBRARY_TAGS} tags",
),
)
_PROTECTED_ORDER = [key for key, *_rest in PROTECTED]
# Detail rows: what the user can act on (a conflict) first.
DETAIL_ORDER = ("conflict", "edited", "archived", "too-many-tags")
_CANDIDATES_SQL = """
SELECT p.id, p.hash, p.text, p.created_at, p.updated_at,
COALESCE(TRIM(p.category), '') = '' AS blank_category,
NOT EXISTS (SELECT 1 FROM prompt_tags pt WHERE pt.prompt_id = p.id)
AS untagged,
EXISTS (SELECT 1 FROM prompt_archive pa WHERE pa.prompt_id = p.id)
AS archived,
(SELECT CASE WHEN pp.positive_prompt_id = p.id
THEN pp.created_at END
FROM prompt_pairs pp
WHERE pp.positive_prompt_id = p.id OR pp.negative_prompt_id = p.id
ORDER BY pp.id LIMIT 1) AS first_pair_at
FROM prompts p
WHERE EXISTS (SELECT 1 FROM prompt_pairs pp WHERE pp.positive_prompt_id = p.id)
AND (COALESCE(TRIM(p.category), '') = ''
OR NOT EXISTS (SELECT 1 FROM prompt_tags pt WHERE pt.prompt_id = p.id))
ORDER BY p.id
"""
_SOURCES_SQL = (
"SELECT id, positive_prompt_id, TRIM(COALESCE(category, '')) FROM prompt_pairs "
"WHERE archived = 0 AND positive_prompt_id IN ({marks}) ORDER BY id"
)
_PAIR_TAGS_SQL = (
"SELECT ppt.pair_id, t.id, t.name FROM prompt_pair_tags ppt "
"JOIN tags t ON t.id = ppt.tag_id WHERE ppt.pair_id IN ({marks}) "
"ORDER BY ppt.pair_id, t.id"
)
def parse_stamp(value):
"""A stored timestamp as an aware UTC datetime, or None when unreadable."""
if not isinstance(value, str) or not value.strip():
return None
try:
stamp = datetime.datetime.fromisoformat(value.strip())
except ValueError:
return None
if stamp.tzinfo is None:
stamp = stamp.replace(tzinfo=datetime.timezone.utc)
return stamp
def _close(first, second):
"""Whether two stored stamps parse and lie within the tolerance."""
a, b = parse_stamp(first), parse_stamp(second)
if a is None or b is None:
return False
return abs((a - b).total_seconds()) <= PAIR_CREATED_TOLERANCE
def _batches(ids):
size = _maintenance.ID_BATCH
for start in range(0, len(ids), size):
yield ids[start : start + size]
def _sources(conn, ids):
"""``{prompt_id: [(pair_id, category, ((tag_id, name), ...)), ...]}``."""
pairs = {}
for batch in _batches(ids):
for pair_id, prompt_id, category in conn.execute(
_SOURCES_SQL.format(marks=_marks(batch)), batch
):
pairs.setdefault(prompt_id, []).append([pair_id, category, []])
by_pair = {pair[0]: pair for group in pairs.values() for pair in group}
pair_ids = sorted(by_pair)
for batch in _batches(pair_ids):
for pair_id, tag_id, name in conn.execute(
_PAIR_TAGS_SQL.format(marks=_marks(batch)), batch
):
by_pair[pair_id][2].append((tag_id, name))
return {
prompt_id: [(pid, category, tuple(tags)) for pid, category, tags in group]
for prompt_id, group in pairs.items()
}
def _field(values, guard):
"""``(class, value)`` of one field from its distinct values, or None."""
if not values:
return None
if guard:
return (guard, None)
if len(values) > 1:
return ("conflict", sorted(values))
return ("fill", next(iter(values)))
def _classify(row, sources):
"""The result of one considered prompt, or None when nothing applies."""
guard = None
if row["archived"]:
guard = "archived"
elif not _close(row["updated_at"], row["created_at"]):
guard = "edited"
category = tags = None
tag_pair = None
if row["blank_category"]:
category = _field({c for _p, c, _t in sources if c}, guard)
if row["untagged"]:
sets = {}
for pair_id, _category, pair_tags in sources:
if pair_tags:
sets.setdefault(frozenset(pair_tags), pair_id)
tags = _field(set(sets), guard)
if tags and tags[0] == "fill":
tag_pair = sets[tags[1]]
if len(tags[1]) > MAX_LIBRARY_TAGS:
tags = ("too-many-tags", None)
else:
tags = ("fill", tuple(sorted(tags[1])))
elif tags and tags[0] == "conflict":
tags = ("conflict", [sorted(s) for s in sorted(sets, key=sorted)])
if category is None and tags is None:
return None
return {
"id": row["id"],
"hash": row["hash"],
"text": row["text"],
"category": category,
"tags": tags,
"tag_pair": tag_pair if tags and tags[0] == "fill" else None,
"pairs": [pair_id for pair_id, _c, _t in sources],
}
def _state(conn):
"""``(results, fingerprint)``: every considered prompt's result, by id."""
considered = []
for row in conn.execute(_CANDIDATES_SQL):
row = dict(
zip(
(
"id",
"hash",
"text",
"created_at",
"updated_at",
"blank_category",
"untagged",
"archived",
"first_pair_at",
),
tuple(row),
)
)
if _close(row["created_at"], row["first_pair_at"]):
considered.append(row)
sources = _sources(conn, [row["id"] for row in considered])
fingerprint = _Fingerprint(OPERATION)
results = []
for row in considered:
result = _classify(row, sources.get(row["id"], []))
if result is None:
continue
results.append(result)
fingerprint.add(
[
result["id"],
result["hash"],
result["category"],
result["tags"],
result["tag_pair"],
result["pairs"],
]
)
return results, fingerprint.hexdigest()
def _fills(result):
return (
bool(result["category"] and result["category"][0] == "fill"),
bool(result["tags"] and result["tags"][0] == "fill"),
)
def _targets(results):
targets = []
for result in results:
category, tags = _fills(result)
if category or tags:
targets.append([result["id"], category, tags])
return targets[:MAX_PLAN_FILLS]
def _protected_classes(result):
"""The protected classes of a result's fields, in precedence order."""
found = {
field[0]
for field in (result["category"], result["tags"])
if field and field[0] != "fill"
}
return [key for key in _PROTECTED_ORDER if key in found]
def _more(values, limit):
shown = ", ".join(values[:limit])
return shown + (", …" if len(values) > limit else "")
def _fill_detail(result):
parts = []
category, tags = _fills(result)
if category:
parts.append(f"category “{result['category'][1]}”")
if tags:
names = sorted(name for _id, name in result["tags"][1])
noun = "tag" if len(names) == 1 else "tags"
parts.append(f"{len(names)} {noun}: {_more(names, DETAIL_TAGS)}")
pairs = [str(pair_id) for pair_id in result["pairs"]]
label = "pair" if len(pairs) == 1 else "pairs"
return f"Fills {' and '.join(parts)} ({label} {_more(pairs, DETAIL_VALUES)})"
def _protected_detail(result, key):
if key != "conflict":
return next(detail for k, detail, *_r in PROTECTED if k == key)
if result["category"] and result["category"][0] == "conflict":
values = result["category"][1]
return f"Kept: pairs disagree on category: {_more(values, DETAIL_VALUES)}"
return "Kept: pairs disagree on tags"
def _kept(results):
"""One row per kept prompt, its review reasons without "Kept: " (#550)."""
rows = []
for result in results:
classes = set(_protected_classes(result))
if classes:
rows.append(
{
"prompt_id": result["id"],
"reasons": [
_protected_detail(result, key).removeprefix("Kept: ")
for key in DETAIL_ORDER
if key in classes
],
}
)
return rows
def _details(results, targets):
rows = []
for key in DETAIL_ORDER:
for result in results:
if key in _protected_classes(result):
rows.append(
{
"label": _excerpt(result["text"]),
"detail": _protected_detail(result, key),
}
)
planned = {target[0] for target in targets}
for result in results:
if result["id"] in planned:
rows.append(
{"label": _excerpt(result["text"]), "detail": _fill_detail(result)}
)
if not targets:
return rows[:MAX_DETAILS]
return rows[: MAX_DETAILS - 1] + [{"label": "Record", "detail": RECORD_NOTE}]
def _scope_label(targets, total):
if not targets:
return EMPTY_LABEL
categories = sum(1 for _id, category, _tags in targets if category)
tag_sets = sum(1 for _id, _category, tags in targets if tags)
label = (
f"{_plural(len(targets), 'prompt')} to fill: "
f"{_plural(categories, 'category', 'categories')}, "
f"{_plural(tag_sets, 'tag set')}"
)
if total > len(targets):
label += (
f" (the first {len(targets):,} of {total:,}; review again for the rest)"
)
return label
def plan_pair_metadata(conn):
"""The reviewed ``pair_metadata`` plan on ``conn``'s snapshot."""
results, fingerprint = _state(conn)
targets = _targets(results)
total = sum(1 for result in results if any(_fills(result)))
counts = {key: 0 for key in _PROTECTED_ORDER}
held = 0
for result in results:
classes = _protected_classes(result)
held += bool(classes)
for key in classes:
counts[key] += 1
reasons = [
f"{counts[key]:,} {one if counts[key] == 1 else many}"
for key, _detail, one, many in PROTECTED
if counts[key]
]
return MaintenanceDraft(
operation=OPERATION,
fingerprint=fingerprint,
targets=targets,
scope={"count": len(targets), "label": _scope_label(targets, total)},
protected={"count": held, "reasons": reasons},
media={"deletes_files": False, "label": MEDIA_LABEL},
details=_details(results, targets),
)
def _fill_categories(conn, fills):
"""Set each blank category; raises PlanChanged when one is no longer blank."""
cursor = conn.executemany(
"UPDATE prompts SET category = ? WHERE id = ? "
"AND COALESCE(TRIM(category), '') = ''",
fills,
)
if cursor.rowcount != len(fills):
raise PlanChanged(OPERATION)
def _fill_tags(conn, fills, expected):
"""Copy each source pair's tags onto its untagged prompt."""
cursor = conn.executemany(
"INSERT INTO prompt_tags(prompt_id, tag_id) "
"SELECT ?, ppt.tag_id FROM prompt_pair_tags ppt "
"JOIN tags t ON t.id = ppt.tag_id WHERE ppt.pair_id = ? "
"AND NOT EXISTS (SELECT 1 FROM prompt_tags WHERE prompt_id = ?)",
fills,
)
if cursor.rowcount != expected:
raise PlanChanged(OPERATION)
def apply_pair_metadata(conn, fingerprint, targets):
"""Apply the reviewed fills on ``conn`` (inside BEGIN IMMEDIATE).
Returns ``{changed, categories, tag_sets, rows, kept}``; ``rows`` lists
every filled prompt (``{prompt_id, category?, tags?}``) and ``kept`` every
prompt the plan keeps (``{prompt_id, reasons}``, in the review's wording
and order), both never capped.
"""
results, current = _state(conn)
if current != fingerprint or _targets(results) != targets:
raise PlanChanged(OPERATION)
by_id = {result["id"]: result for result in results}
categories, tags, rows = [], [], []
tag_count = 0
for prompt_id, fill_category, fill_tags in targets:
result = by_id[prompt_id]
row = {"prompt_id": prompt_id}
if fill_category:
row["category"] = result["category"][1]
categories.append((row["category"], prompt_id))
if fill_tags:
row["tags"] = sorted(name for _id, name in result["tags"][1])
tags.append((prompt_id, result["tag_pair"], prompt_id))
tag_count += len(result["tags"][1])
rows.append(row)
if categories:
_fill_categories(conn, categories)
if tags:
_fill_tags(conn, tags, tag_count)
return {
"changed": len(rows),
"categories": len(categories),
"tag_sets": len(tags),
"rows": rows,
"kept": _kept(results),
}
+23 -3
View File
@@ -78,6 +78,12 @@ MODEL_DOWNLOAD_BODY_MAX = 1024
_download_titles = {}
def _instruction_engine(model_type: str) -> str:
"""The language-model engine whose default instructions ``model_type``
reads: itself, or GGUF for a WD14 engine (which takes none)."""
return model_type if model_type in ("gguf", "hf") else "gguf"
def _holder_label(holder: Optional[str]) -> str:
if not holder:
return ANOTHER_OPERATION
@@ -294,7 +300,15 @@ class AutotagRoutesMixin:
"success": True,
"models": public_models(status, loaded_type),
# The saved tagging defaults (#328, System › Auto-tagging).
"default_prompt": AutotagConfig.TEMPLATE,
# The saved engine's default instructions (GGUF for a
# WD14 engine); kept one release for cached pages (#431).
"default_prompt": AutotagConfig.default_instructions_text(
_instruction_engine(AutotagConfig.MODEL_TYPE)
),
# #431: Built-in first, with every text, and each
# language-model engine's default.
"instructions": AutotagConfig.all_instructions(),
"default_instructions": dict(AutotagConfig.DEFAULT_INSTRUCTIONS),
"model_loaded": loaded,
"loaded_model_type": loaded_type,
"wd14_general_threshold": AutotagConfig.GENERAL_THRESHOLD,
@@ -549,7 +563,10 @@ class AutotagRoutesMixin:
else:
prompt = params.get("prompt")
template = prompt if isinstance(prompt, str) and prompt.strip() else None
options = {"instructions": template or AutotagConfig.TEMPLATE}
options = {
"instructions": template
or AutotagConfig.default_instructions_text(model_type)
}
try:
_model, options = validate_options(
model_type,
@@ -1092,7 +1109,10 @@ class AutotagRoutesMixin:
else:
prompt = data.get("prompt")
template = prompt if isinstance(prompt, str) and prompt.strip() else None
options = {"instructions": template or AutotagConfig.TEMPLATE}
options = {
"instructions": template
or AutotagConfig.default_instructions_text(model_type)
}
use_gpu = data.get("use_gpu", True)
options["use_gpu"] = use_gpu if isinstance(use_gpu, bool) else True
return validate_options(
+30 -17
View File
@@ -204,6 +204,8 @@ class Job:
self.total = 0
self.counters = dict.fromkeys(groups, 0)
self.outcomes = {group: [] for group in groups}
# Groups whose report lists every row, beyond MAX_OUTCOME_ROWS (#550).
self.complete = {}
self.log = []
self._cancel_requested = False
self.last_event = None
@@ -297,31 +299,36 @@ class Job:
)
self.log_line(log_line)
def record_counted(self, group, count, rows=()):
def record_counted(self, group, count, rows=(), *, complete=False):
"""Count ``count`` outcomes in ``group`` at once, listing ``rows``.
For outcomes that need no log line each (a thumbnail job's already
fresh previews): the counter is exact, ``rows`` (``record``'s row
shape) are listed up to MAX_OUTCOME_ROWS, and nothing is logged.
``complete`` also keeps every row for the report (#550: a record of
each change that must not be cut short; the caller bounds it).
"""
if group not in self.counters:
raise ValueError(f"Unknown outcome group {group!r} for {self.kind}")
self.counters[group] += max(0, int(count))
listed = self.outcomes[group]
kept = self.complete.setdefault(group, []) if complete else None
for row in rows:
if len(listed) >= MAX_OUTCOME_ROWS:
if kept is None and len(listed) >= MAX_OUTCOME_ROWS:
break
index = row.get("root_index")
if isinstance(index, bool) or not isinstance(index, int) or index < 0:
index = None
listed.append(
{
"path": _relative_path(row.get("path")),
"root": str(row.get("root") or ""),
"root_index": index,
"detail": str(row.get("detail") or ""),
}
)
shaped = {
"path": _relative_path(row.get("path")),
"root": str(row.get("root") or ""),
"root_index": index,
"detail": str(row.get("detail") or ""),
}
if kept is not None:
kept.append(shaped)
if len(listed) < MAX_OUTCOME_ROWS:
listed.append(dict(shaped))
def finish(self, state="finished"):
"""End the job in ``state`` and release every subscriber.
@@ -406,8 +413,9 @@ class Job:
"""The downloadable report: the summary, every outcome group and the log.
Each group carries its exact count, its listed rows (at most
MAX_OUTCOME_ROWS) and whether the list is cut short. Rows and log lines
already hold root-relative paths only.
MAX_OUTCOME_ROWS, or every row of a ``complete`` group) and whether
the list is cut short. Rows and log lines already hold root-relative
paths only.
"""
summary = self.summary()
summary.pop("listed", None)
@@ -417,16 +425,21 @@ class Job:
"generated_at": self._clock(),
"job": summary,
"outcomes": {
group: {
"count": self.counters.get(group, len(rows)),
"rows": [dict(row) for row in rows],
"truncated": self.counters.get(group, len(rows)) > len(rows),
}
group: self._report_group(group, rows)
for group, rows in self.outcomes.items()
},
"log": list(self.log),
}
def _report_group(self, group, listed):
rows = self.complete.get(group, listed)
count = self.counters.get(group, len(rows))
return {
"count": count,
"rows": [dict(row) for row in rows],
"truncated": count > len(rows),
}
def report_filename(self):
"""The download name: prompt-manager-KIND-YYYYMMDD-HHMM.json (local time)."""
stamp = time.strftime("%Y%m%d-%H%M", time.localtime(self._clock()))
+60 -6
View File
@@ -1,8 +1,9 @@
"""Maintenance routes: reviewed, read-only plans (#249 System S6).
``POST /prompt_manager/maintenance/preview {operation, options:{}}`` reviews
one operation (``duplicate_prompts``, ``optimize``, ``unused_components`` or
``missing_links``; ``database/maintenance.py``; ``duplicate_files``,
one operation (``duplicate_prompts``, ``optimize``, ``unused_components``,
``missing_links`` or ``pair_metadata``, #550; ``database/maintenance.py``;
``duplicate_files``,
``py/api/duplicate_files.py``; ``rotated_logs``, the rotated log copies,
``py/api/log_cleanup.py``, which has its own apply job) off the event loop and
answers a ``Plan``: ``{success, token, operation, expires_at, scope:{count,
@@ -97,7 +98,11 @@ MAINTENANCE_TITLES = {
"unused_components": "Unused prompt components",
"missing_links": "Missing-file links",
"workflow_storage": "Store each workflow once",
# #550, beyond the concept: fills old Pair prompts' empty category and tags.
"pair_metadata": "Pair category and tags",
}
# Operations whose job keeps every changed row for its report (#550).
COMPLETE_RECORD = frozenset({"pair_metadata"})
_OPERATION_ERROR = "operation must be one of: " + ", ".join(PREVIEW_OPERATIONS)
MAINTENANCE_GROUPS = ("changed", "protected", "errors")
MAX_TOKEN_LENGTH = 128
@@ -281,9 +286,27 @@ def short_space_message(short):
)
def _changed_line(operation, count, marks=0):
def _pair_metadata_line(result):
count = result["changed"]
noun = (
"prompt from its saved pairs"
if count == 1
else "prompts from their saved pairs"
)
categories = result.get("categories", 0)
tag_sets = result.get("tag_sets", 0)
return (
f"Filled {count:,} {noun} "
f"({categories:,} {'category' if categories == 1 else 'categories'}, "
f"{tag_sets:,} {'tag set' if tag_sets == 1 else 'tag sets'})"
)
def _changed_line(operation, count, marks=0, result=None):
if operation == "optimize":
return "Optimized the database"
if operation == "pair_metadata":
return _pair_metadata_line(result or {"changed": count})
if operation == "duplicate_prompts":
noun = "duplicate prompt" if count == 1 else "duplicate prompts"
return f"Merged {count:,} {noun}"
@@ -668,9 +691,28 @@ class MaintenanceRoutesMixin:
rows = await self._run_in_executor(
self._maintenance_rows, plan.operation, result["rows"]
)
job.record_counted("changed", result["changed"], rows)
job.record_counted("protected", payload["protected"])
line = _changed_line(plan.operation, result["changed"], result.get("marks", 0))
job.record_counted(
"changed",
result["changed"],
rows,
complete=plan.operation in COMPLETE_RECORD,
)
if "kept" in result:
# #550: each kept record with its reason, like the changed ones.
kept = [
{
"path": "",
"detail": f"Prompt {item['prompt_id']}: kept, "
+ "; ".join(item["reasons"]),
}
for item in result["kept"]
]
job.record_counted("protected", len(kept), kept, complete=True)
else:
job.record_counted("protected", payload["protected"])
line = _changed_line(
plan.operation, result["changed"], result.get("marks", 0), result
)
job.log_line(line)
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
return None
@@ -691,6 +733,8 @@ class MaintenanceRoutesMixin:
}
)
return rows
if operation == "pair_metadata":
return [{"path": "", "detail": _filled_detail(item)} for item in described]
if operation == "duplicate_prompts":
return [
{
@@ -708,6 +752,16 @@ class MaintenanceRoutesMixin:
]
def _filled_detail(item):
"""``Prompt 12: category “portrait”; tags a, b``: one filled prompt (#550)."""
parts = []
if "category" in item:
parts.append(f"category “{item['category']}”")
if item.get("tags"):
parts.append("tags " + ", ".join(item["tags"]))
return f"Prompt {item['prompt_id']}: " + "; ".join(parts)
def backup_note(backup):
"""The status line's backup part: the file name, or that there is none."""
return f"Safety backup {backup}" if backup else NO_BACKUP
+100 -14
View File
@@ -54,13 +54,24 @@ _SETTINGS_FILE_KEYS = {
"AUTOTAG_EXISTING": ("autotag", "defaults", "existing"),
"AUTOTAG_GENERAL_THRESHOLD": ("autotag", "defaults", "general_threshold"),
"AUTOTAG_CHARACTER_THRESHOLD": ("autotag", "defaults", "character_threshold"),
"AUTOTAG_TEMPLATE": ("autotag", "defaults", "template"),
"AUTOTAG_SUGGESTION_LIMIT": ("autotag", "defaults", "suggestion_limit"),
"AUTOTAG_SKIPPED_TAGS": ("autotag", "defaults", "skipped_tags"),
# #431: the saved instructions and each engine's default, always written
# together (server-side pairing).
"AUTOTAG_INSTRUCTIONS": ("autotag", "defaults", "instructions"),
"AUTOTAG_DEFAULT_INSTRUCTIONS": ("autotag", "defaults", "default_instructions"),
}
# The old single template: written only as the GGUF default's mirror for a
# downgrade (#431 B1.1), deleted when that default is Built-in.
_TEMPLATE_MIRROR_KEY = ("autotag", "defaults", "template")
TEMPLATE_REPLACED_ERROR = (
"LLM tag template was replaced by Saved instructions. Reload the page."
)
# Whole-list settings (#431): a save without ``expected_revision`` would merge
# a stale copy over a list changed elsewhere, so it is refused.
_REVISION_REQUIRED = frozenset({"autotag_skipped_tags"})
_REVISION_REQUIRED = frozenset(
{"autotag_skipped_tags", "autotag_instructions", "autotag_default_instructions"}
)
REVISION_REQUIRED_ERROR = "Reload the page and try again."
# The thumbnail settings a running Convert outdated job fixes (#317).
_THUMBNAIL_SETTINGS = ("THUMBNAIL_FORMAT", "THUMBNAIL_QUALITY", "THUMBNAIL_EDGE")
@@ -142,13 +153,20 @@ def _settings_mutation(updates, new_roots):
"""The config commit for a settings save: only the submitted keys change.
Every other section and key (integrations and the CivitAI key, database,
unknown sections) is kept as it is on disk.
unknown sections) is kept as it is on disk. A save of the instructions
also writes the old ``template`` key as the GGUF default's mirror.
"""
def mutate(config):
for name, value in updates.items():
*sections, key = _SETTINGS_FILE_KEYS[name]
_set_nested(config, sections, key, value)
_set_nested(config, sections, key, _jsonable(value))
if "AUTOTAG_INSTRUCTIONS" in updates:
*sections, key = _TEMPLATE_MIRROR_KEY
mirror = _mirror_text(
updates["AUTOTAG_INSTRUCTIONS"], updates["AUTOTAG_DEFAULT_INSTRUCTIONS"]
)
_set_nested(config, sections, key, _DELETE if mirror is None else mirror)
if new_roots is not None:
gallery = _section(config, "gallery")
monitoring = _section(gallery, "monitoring")
@@ -160,10 +178,37 @@ def _settings_mutation(updates, new_roots):
return mutate
# ``_set_nested``'s value that removes the key instead of setting it.
_DELETE = object()
def _jsonable(value):
"""A runtime value as config.json holds it (tuples become lists)."""
if isinstance(value, (list, tuple)):
return [_jsonable(item) for item in value]
if isinstance(value, dict):
return {key: _jsonable(item) for key, item in value.items()}
return value
def _mirror_text(entries, defaults):
"""The GGUF default's text, or None when it is Built-in (#431 B1.1)."""
from ..tagging_defaults import BUILTIN_INSTRUCTIONS_ID
for item in entries:
if item["id"] == defaults["gguf"] != BUILTIN_INSTRUCTIONS_ID:
return item["text"]
return None
def _set_nested(config, sections, key, value):
"""``config[s1][s2]...[key] = value``, each section a fresh dict."""
"""``config[s1][s2]...[key] = value``, each section a fresh dict;
``_DELETE`` removes the key."""
if not sections:
config[key] = value
if value is _DELETE:
config.pop(key, None)
else:
config[key] = value
return
merged = _section(config, sections[0])
_set_nested(merged, sections[1:], key, value)
@@ -182,7 +227,6 @@ def _validate_autotag_settings(data):
MODEL_TYPES,
validate_saved_threshold,
validate_skipped_tags,
validate_template,
)
updates = {}
@@ -211,12 +255,7 @@ def _validate_autotag_settings(data):
raise ValueError(f"{error}.") from None
updates[key.upper()] = value
if "autotag_template" in data:
try:
updates["AUTOTAG_TEMPLATE"] = validate_template(
data["autotag_template"], "LLM tag template"
)
except ValueError as error:
raise ValueError(f"{error}.") from None
raise ValueError(TEMPLATE_REPLACED_ERROR)
if "autotag_suggestion_limit" in data:
value = data["autotag_suggestion_limit"]
if type(value) is not int or not 1 <= value <= MAX_SUGGESTION_LIMIT:
@@ -229,9 +268,37 @@ def _validate_autotag_settings(data):
updates["AUTOTAG_SKIPPED_TAGS"] = tuple(
validate_skipped_tags(data["autotag_skipped_tags"])
)
updates.update(_validate_instruction_settings(data))
return updates
def _validate_instruction_settings(data):
"""Server-side pairing (#431): a save that carries either instruction key
gets the other from the running configuration (which holds the migrated
list), the defaults are checked against the merged list, and both are
written. Empty when the save carries neither."""
from ..config import AutotagConfig
from ..tagging_defaults import (
validate_default_instructions,
validate_instructions,
)
if not {"autotag_instructions", "autotag_default_instructions"} & set(data):
return {}
if "autotag_instructions" in data:
entries = validate_instructions(data["autotag_instructions"])
else:
entries = [dict(item) for item in AutotagConfig.INSTRUCTIONS]
defaults = data.get(
"autotag_default_instructions", dict(AutotagConfig.DEFAULT_INSTRUCTIONS)
)
defaults = validate_default_instructions(defaults, entries)
return {
"AUTOTAG_INSTRUCTIONS": tuple(entries),
"AUTOTAG_DEFAULT_INSTRUCTIONS": defaults,
}
async def _read_json(request):
"""Parse a JSON object body through the package's capped reader."""
from . import _read_json_body
@@ -259,6 +326,7 @@ class SettingsRoutesMixin:
"""Get current settings."""
try:
from ..config import AutotagConfig, PromptManagerConfig, GalleryConfig
from ..tagging_defaults import DEFAULT_TAG_TEMPLATE
root_paths = [
self._public_path(d) for d in GalleryConfig.MONITORING_DIRECTORIES
@@ -291,7 +359,19 @@ class SettingsRoutesMixin:
"autotag_character_threshold": (
AutotagConfig.CHARACTER_THRESHOLD
),
"autotag_template": AutotagConfig.TEMPLATE,
# Read-only for one release (#431): a page cached
# from before the upgrade still renders.
"autotag_template": AutotagConfig.default_instructions_text(
"gguf"
),
"autotag_instructions": [
dict(item) for item in AutotagConfig.INSTRUCTIONS
],
"autotag_default_instructions": dict(
AutotagConfig.DEFAULT_INSTRUCTIONS
),
# Built-in's text, which System shows read-only.
"autotag_builtin_instructions": DEFAULT_TAG_TEMPLATE,
"autotag_suggestion_limit": AutotagConfig.SUGGESTION_LIMIT,
"autotag_skipped_tags": list(AutotagConfig.SKIPPED_TAGS),
"cpu_count": PromptManagerConfig.max_worker_threads(),
@@ -526,6 +606,12 @@ class SettingsRoutesMixin:
if name in _AUTOTAG_SETTINGS:
setattr(AutotagConfig, name[len(_AUTOTAG_PREFIX) :], value)
continue
if "AUTOTAG_INSTRUCTIONS" in updates:
# What config.json now holds as the old key (the mirror).
AutotagConfig._LEGACY_TEMPLATE = AutotagConfig.mirror_template()
for name, value in updates.items():
if name in _AUTOTAG_SETTINGS:
continue
owner = GalleryConfig if name in _GALLERY_SETTINGS else PromptManagerConfig
setattr(owner, name, value)
restart_fields = []
+167 -10
View File
@@ -30,6 +30,7 @@ routes = server_instance.routes
# Extension info
extension_uri = None # Will be set in __init__.py
import hashlib # noqa: E402
import os # noqa: E402
from typing import Any, Dict, List, Optional, Tuple # noqa: E402
@@ -590,6 +591,12 @@ try:
validate_skipped_tags,
validate_template,
validate_threshold,
BUILTIN_INSTRUCTIONS_ID,
INSTRUCTION_ENGINES,
MAX_SAVED_INSTRUCTIONS,
builtin_instructions,
validate_default_instructions,
validate_instructions,
)
except ImportError:
from py.tagging_defaults import ( # noqa: F401 - re-exported
@@ -604,6 +611,12 @@ except ImportError:
validate_skipped_tags,
validate_template,
validate_threshold,
BUILTIN_INSTRUCTIONS_ID,
INSTRUCTION_ENGINES,
MAX_SAVED_INSTRUCTIONS,
builtin_instructions,
validate_default_instructions,
validate_instructions,
)
@@ -623,13 +636,24 @@ def _valid_skipped_tags(value: Any) -> Tuple[str, ...]:
return tuple(validate_skipped_tags(value))
def _reason(error: ValueError) -> str:
"""A settings sentence as a load warning's reason ("entry 2 is empty")."""
text = str(error).rstrip(".")
for prefix in ("Skipped tags", "Saved instructions"):
if text.startswith(prefix):
return text.removeprefix(prefix).lstrip(": ")
return text
def _text_id(text: str) -> str:
"""A migrated entry's id: deterministic, so every load gives the same."""
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
def _fallback_warning(key: str, error: ValueError, default: Any) -> str:
"""The one load warning of a saved tagging default that fails its check."""
if key == "template":
return f"autotag.defaults.template {error}; using the built-in one"
if key == "skipped_tags":
reason = str(error).removeprefix("Skipped tags").lstrip(": ").rstrip(".")
return f"autotag.defaults.skipped_tags {reason}; using no skipped tags"
return f"autotag.defaults.skipped_tags {_reason(error)}; using no skipped tags"
return f"autotag.defaults.{key} {error}; using {default!r}"
@@ -653,9 +677,18 @@ class AutotagConfig:
EXISTING = "skip"
GENERAL_THRESHOLD = 0.35
CHARACTER_THRESHOLD = 0.85
TEMPLATE = DEFAULT_TAG_TEMPLATE
SUGGESTION_LIMIT = DEFAULT_SUGGESTION_LIMIT
SKIPPED_TAGS: Tuple[str, ...] = ()
# #431: the saved instructions (Built-in is never stored) and the default
# of each language-model engine, by id.
INSTRUCTIONS: Tuple[Dict[str, str], ...] = ()
DEFAULT_INSTRUCTIONS: Dict[str, str] = {
"gguf": BUILTIN_INSTRUCTIONS_ID,
"hf": BUILTIN_INSTRUCTIONS_ID,
}
# The old single template as read at load (None when absent or invalid);
# only the migration reads it. Saves keep it as the GGUF default's mirror.
_LEGACY_TEMPLATE: Optional[str] = None
# config.json key -> (attribute, validator, default)
_KEYS = {
@@ -671,7 +704,6 @@ class AutotagConfig:
lambda v: validate_saved_threshold("value", v),
0.85,
),
"template": ("TEMPLATE", validate_template, DEFAULT_TAG_TEMPLATE),
"suggestion_limit": (
"SUGGESTION_LIMIT",
_valid_suggestion_limit,
@@ -691,18 +723,142 @@ class AutotagConfig:
type(value) is int and 1 <= value <= MAX_SUGGESTION_LIMIT
) # bool, float and str are rejected
@classmethod
def all_instructions(cls) -> List[Dict[str, str]]:
"""Built-in first, then the saved entries (copies)."""
return [builtin_instructions()] + [dict(item) for item in cls.INSTRUCTIONS]
@classmethod
def default_instructions_text(cls, engine: str) -> str:
"""The text of ``engine``'s (``gguf`` or ``hf``) default instructions."""
if engine not in INSTRUCTION_ENGINES:
raise ValueError(f"{engine} takes no instructions")
wanted = cls.DEFAULT_INSTRUCTIONS.get(engine, BUILTIN_INSTRUCTIONS_ID)
for item in cls.INSTRUCTIONS:
if item["id"] == wanted:
return item["text"]
return DEFAULT_TAG_TEMPLATE
@classmethod
def mirror_template(cls) -> Optional[str]:
"""What an older build reads as its one template (#431 B1.1): the
GGUF default's text, or None (no key) when that default is Built-in."""
if cls.DEFAULT_INSTRUCTIONS.get("gguf") == BUILTIN_INSTRUCTIONS_ID:
return None
return cls.default_instructions_text("gguf")
@classmethod
def get_config(cls) -> Dict[str, Any]:
"""The saved defaults; the template only when it is not the built-in
one, so a later built-in template still reaches who never set one."""
"""The saved defaults; ``template`` only as the GGUF default's mirror."""
defaults = {
key: getattr(cls, attr) for key, (attr, _v, _d) in cls._KEYS.items()
}
if defaults["template"] == DEFAULT_TAG_TEMPLATE:
del defaults["template"]
defaults["skipped_tags"] = list(defaults["skipped_tags"])
defaults["instructions"] = [dict(item) for item in cls.INSTRUCTIONS]
defaults["default_instructions"] = dict(cls.DEFAULT_INSTRUCTIONS)
mirror = cls.mirror_template()
if mirror is not None:
defaults["template"] = mirror
return {"defaults": defaults}
@classmethod
def _load_instructions(cls, defaults: Dict[str, Any]) -> None:
"""Each instruction key alone, then the cross-key default check, then
the in-memory migration of the old template (#431). Nothing is
written here, so the revision never changes at load."""
cls._LEGACY_TEMPLATE = None
if "template" in defaults:
try:
cls._LEGACY_TEMPLATE = validate_template(defaults["template"])
except ValueError as error:
config_logger.warning(
f"autotag.defaults.template {error}; using the built-in one"
)
entries: List[Dict[str, str]] = []
damaged = False
if "instructions" in defaults:
try:
entries = validate_instructions(defaults["instructions"])
except ValueError as error:
damaged = True
config_logger.warning(
f"autotag.defaults.instructions {_reason(error)}; "
"using Built-in only"
)
chosen = {engine: BUILTIN_INSTRUCTIONS_ID for engine in INSTRUCTION_ENGINES}
stored = defaults.get("default_instructions")
if damaged:
# Both defaults fall back with the list (its one warning says so);
# the old template is migrated as on a first upgrade.
pass
elif "default_instructions" in defaults and (
not isinstance(stored, dict) or set(stored) != set(INSTRUCTION_ENGINES)
):
config_logger.warning(
"autotag.defaults.default_instructions must name gguf and hf; "
"using Built-in"
)
elif isinstance(stored, dict):
ids = {item["id"] for item in entries}
for engine in INSTRUCTION_ENGINES:
value = stored[engine]
if isinstance(value, str) and (
value == BUILTIN_INSTRUCTIONS_ID or value in ids
):
chosen[engine] = value
else:
config_logger.warning(
f"autotag.defaults.default_instructions.{engine} names no "
"saved instructions; using Built-in"
)
cls._migrate_template(
entries,
chosen,
first=damaged or "instructions" not in defaults,
keep_defaults=not damaged and "default_instructions" in defaults,
)
cls.INSTRUCTIONS = tuple(entries)
cls.DEFAULT_INSTRUCTIONS = chosen
@classmethod
def _migrate_template(cls, entries, chosen, *, first, keep_defaults) -> None:
"""Bring a template saved by an older build in as a named entry."""
legacy = cls._LEGACY_TEMPLATE
if legacy is None or legacy == DEFAULT_TAG_TEMPLATE:
return
if first:
# The first upgrade: it becomes "Custom template", the default for
# both engines unless defaults are stored already (20A).
entries.append(
{"id": _text_id(legacy), "name": "Custom template", "text": legacy}
)
if not keep_defaults:
for engine in INSTRUCTION_ENGINES:
chosen[engine] = entries[-1]["id"]
return
if any(item["text"] == legacy for item in entries):
return # the mirror of a saved default
# Edited on an older build after an upgrade (B1.2): import it.
if len(entries) >= MAX_SAVED_INSTRUCTIONS:
config_logger.warning(
"The tagging template edited on an older version was not imported: "
f"the saved instructions are full ({MAX_SAVED_INSTRUCTIONS})"
)
return
names = {item["name"].casefold() for item in entries}
number = 2
while f"custom template ({number})" in names:
number += 1
name = f"Custom template ({number})"
ids = {item["id"] for item in entries}
new_id, n = _text_id(legacy), 2
while new_id in ids:
new_id, n = _text_id(f"{legacy}\n{n}"), n + 1
entries.append({"id": new_id, "name": name, "text": legacy})
config_logger.info(
"Imported the tagging template edited on an older version as " f"'{name}'"
)
@classmethod
def update_config(cls, new_config: Any) -> None:
if not isinstance(new_config, dict):
@@ -720,6 +876,7 @@ class AutotagConfig:
except ValueError as error:
config_logger.warning(_fallback_warning(key, error, default))
setattr(cls, attr, default)
cls._load_instructions(defaults)
class PromptManagerConfig:
+94
View File
@@ -226,3 +226,97 @@ def wd14_label_names(path) -> Tuple[str, ...]:
with _label_cache_lock:
_label_cache[key] = (stamp, names)
return names
# Saved instructions (#431): named language-model instructions with a default
# per engine. Built-in is never stored; readers put it first.
BUILTIN_INSTRUCTIONS_ID = "builtin"
BUILTIN_INSTRUCTIONS_NAME = "Built-in"
MAX_SAVED_INSTRUCTIONS = 20
MAX_INSTRUCTION_NAME_CHARS = 60
INSTRUCTION_ENGINES = ("gguf", "hf")
ENGINE_LABELS = {"gguf": "GGUF JoyCaption", "hf": "HuggingFace JoyCaption"}
_INSTRUCTION_ID = re.compile(r"[0-9a-f]{12}")
_CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f

]")
def builtin_instructions() -> Dict[str, str]:
"""The read-only Built-in entry, always the shipped template."""
return {
"id": BUILTIN_INSTRUCTIONS_ID,
"name": BUILTIN_INSTRUCTIONS_NAME,
"text": DEFAULT_TAG_TEMPLATE,
}
def _instruction_entry(raw: Any, number: int) -> Dict[str, str]:
prefix = f"Saved instructions: entry {number}"
if (
not isinstance(raw, dict)
or set(raw) != {"id", "name", "text"}
or not isinstance(raw["id"], str)
or _INSTRUCTION_ID.fullmatch(raw["id"]) is None
):
raise ValueError(f"{prefix} has an invalid id.")
name = raw["name"].strip() if isinstance(raw["name"], str) else ""
if not name:
raise ValueError(f"{prefix} needs a name.")
if len(name) > MAX_INSTRUCTION_NAME_CHARS:
raise ValueError(
f"{prefix} name must be at most {MAX_INSTRUCTION_NAME_CHARS} characters."
)
if _CONTROL.search(name):
raise ValueError(f"{prefix} name must not contain a line break.")
if name.casefold() == BUILTIN_INSTRUCTIONS_NAME.casefold():
raise ValueError("Saved instructions: the name “Built-in” is reserved.")
try:
text = validate_template(raw["text"], f"{prefix} text")
except ValueError as error:
raise ValueError(f"{error}.") from None
return {"id": raw["id"], "name": name, "text": text}
def validate_instructions(value: Any) -> List[Dict[str, str]]:
"""The saved entries ``[{id, name, text}]`` in order (names trimmed), or
ValueError with the sentence System shows (Built-in is not counted)."""
if not isinstance(value, list):
raise ValueError("Saved instructions must be a list.")
if len(value) > MAX_SAVED_INSTRUCTIONS:
raise ValueError(
f"Saved instructions: at most {MAX_SAVED_INSTRUCTIONS} entries besides "
"Built-in."
)
entries: List[Dict[str, str]] = []
ids: Dict[str, int] = {}
names: Dict[str, int] = {}
for number, raw in enumerate(value, 1):
item = _instruction_entry(raw, number)
if item["id"] in ids:
raise ValueError(
f"Saved instructions: entry {number} repeats the id of entry "
f"{ids[item['id']]}."
)
key = item["name"].casefold()
if key in names:
raise ValueError(
f"Saved instructions: entry {number} name “{item['name']}” is "
"already used."
)
ids[item["id"]] = number
names[key] = number
entries.append(item)
return entries
def validate_default_instructions(value: Any, entries) -> Dict[str, str]:
"""``{gguf: id, hf: id}``, each Built-in or an id of ``entries``."""
if not isinstance(value, dict) or set(value) != set(INSTRUCTION_ENGINES):
raise ValueError("Default instructions must name GGUF and HuggingFace.")
ids = {BUILTIN_INSTRUCTIONS_ID} | {item["id"] for item in entries}
for engine in INSTRUCTION_ENGINES:
if not isinstance(value[engine], str) or value[engine] not in ids:
raise ValueError(
f"Default for {ENGINE_LABELS[engine]} must be one of the saved "
"instructions."
)
return {engine: value[engine] for engine in INSTRUCTION_ENGINES}
@@ -64,11 +64,31 @@ function measureMaintenance(mainSelector) {
};
}
/** D102: the Metadata cache row says why its review is unavailable and its button is aria-disabled (D93's dimmed style). */
function mapMaintenanceConcept(reference) {
/**
* D192 (#550, beyond the concept): Database adds "Pair category and tags" after "Unused prompt components", shaped like
* the concept's plain Review plan row (Optimize database's).
*/
function mapPairRow(reference) {
return {
...reference,
surfaces: reference.surfaces.map(surface => ({ ...surface, rows: surface.rows.map(row => row.title !== 'Metadata cache' ? row : {
surfaces: reference.surfaces.map(surface => {
const plain = surface.rows.find(row => row.title === 'Optimize database');
const at = surface.rows.findIndex(row => row.title === 'Unused prompt components');
if (!plain || at < 0) return surface;
return { ...surface, tree: [...surface.tree, 'div.operation'], rows: [...surface.rows.slice(0, at + 1),
{ ...plain, title: 'Pair category and tags', description: 'Fill an empty category or tags on prompts older ' +
'Pair node runs saved, from their saved pairs. Only empty fields of never-edited prompts.' },
...surface.rows.slice(at + 1)] };
}),
};
}
/** D102: the Metadata cache row says why its review is unavailable and its button is aria-disabled (D93's dimmed style). */
function mapMaintenanceConcept(reference) {
const mapped = mapPairRow(reference);
return {
...mapped,
surfaces: mapped.surfaces.map(surface => ({ ...surface, rows: surface.rows.map(row => row.title !== 'Metadata cache' ? row : {
...row, description: `${row.description} Nothing is cached in this browser.`,
actions: row.actions.map(action => ({ ...action, opacity: '0.55', cursor: 'not-allowed' })) }) })),
};
@@ -209,8 +229,8 @@ test('maintenance comparison detects mutations', async ({ page, request }) => {
const expected = mapMaintenanceConcept(concept);
const production = await productionMaintenance(page, 'dark');
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the section differs only on the D102 row.
expect(diffInventory(concept, production).map(item => item.path).sort()).toEqual([
// Mapped only for the D192 row, the section differs only on the D102 row.
expect(diffInventory(mapPairRow(concept), production).map(item => item.path).sort()).toEqual([
'surfaces.1.rows.3.actions.0.cursor', 'surfaces.1.rows.3.actions.0.opacity', 'surfaces.1.rows.3.description']);
const measure = () => page.evaluate(measureMaintenance, '#workspace-main');
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
+68 -21
View File
@@ -35,6 +35,8 @@ const MODEL_OPTIONS = ['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF Jo
const SUGGESTIONS = 'From 1 to 100: the most suggestions Generate shows for one image.';
const SKIPPED_DESCRIPTION = 'Suggested tags that contain one of these words are hidden. Words are separated by spaces, _ or -; '
+ 'other characters such as ( ) belong to the word.';
const DEFAULT_DESCRIPTION = 'The Tag image dialog and batches start from this.';
const INSTRUCTIONS_DESCRIPTION = 'Instructions for the language-model engines. Built-in can’t be changed; duplicate it to make your own.';
const BATCH = 'Tag each output image from its own pixels with the model above. Prompts are not changed. Review the scope first.';
/** Runs in the page: Auto-tagging's surface under `mainSelector`: rows, field, actions, operation, geometry and tokens. */
@@ -64,7 +66,7 @@ function measureTagging(mainSelector) {
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],
field: 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') },
@@ -81,7 +83,22 @@ function measureTagging(mainSelector) {
label: css(block.querySelector('label'), 'fontSize', 'fontWeight', 'color'),
description: css(block.querySelector('.setting-block-text p'), 'fontSize', 'color', 'marginTop'),
input: css(block.querySelector('input'), 'fontSize', 'backgroundColor', 'color', 'borderTopColor') })(
surface.querySelector(':scope > .setting-block')),
surface.querySelector(':scope > .setting-block[data-setting="tag-skipped"]')),
// D203 (#431): Saved instructions (the concept has a template field instead).
instructions: (block => block && { tree: [...block.children].map(name), key: block.dataset.key,
text: [...block.firstElementChild.children].map(node => [node.localName, text(node)]),
list: [...block.querySelectorAll('[role="option"]')].map(option => [text(option.firstElementChild),
[...option.querySelectorAll('.badges > .chip')].map(chip => [text(chip), chip.className]), option.getAttribute('aria-selected')]),
listRole: block.querySelector('.instruction-list').getAttribute('role'),
actions: [...block.querySelectorAll('.instruction-actions > button')].map(item => [text(item), item.className, item.disabled]),
editor: [...block.querySelectorAll('.instruction-editor .field')].map(item => [text(item.querySelector('label > span')),
item.querySelector('input, textarea').value, item.querySelector('input, textarea').readOnly]),
note: text(block.querySelector('#instruction-note')),
layout: getComputedStyle(block.querySelector('.instructions-layout')).gridTemplateColumns.split(' ').length,
...css(block, 'paddingTop', 'paddingBottom', 'borderTopWidth', 'borderTopColor'),
label: css(block.querySelector('label'), 'fontSize', 'fontWeight', 'color'),
textarea: css(block.querySelector('textarea'), 'fontSize', 'backgroundColor', 'borderTopColor') })(
surface.querySelector(':scope > .setting-block[data-setting="tag-instructions"]')),
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]) })),
@@ -115,14 +132,21 @@ function measureTagging(mainSelector) {
* 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 }) {
function mapTaggingConcept(reference, { builtin, saved, phone }) {
const [model, existing, general, character] = reference.rows;
const [preview] = reference.actions.buttons;
// D203: Built-in first, then the saved entries, in each default select.
const names = ['Built-in', ...saved.map(entry => entry.name)];
const defaultRow = (engine, long) => ({ ...existing,
text: [['label', `Default for ${long}`, `instruction-default-${engine}`], ['p', DEFAULT_DESCRIPTION, null]],
control: { ...existing.control, id: `instruction-default-${engine}`, options: names.map(label => [label, false]),
selected: names[1] } });
return {
...reference,
// D133 adds a row after the thresholds, D202 the Skipped tags block after it; D134 adds the operation after the
// actions.
tree: [...reference.tree.slice(0, 6), 'div.setting-row', 'div.setting-block', ...reference.tree.slice(6), 'div.operation'],
// D133 adds a row after the thresholds, D202 the Skipped tags block after it; D203 replaces the template field
// with Saved instructions, the two default rows and their note; D134 adds the operation after the actions.
tree: [...reference.tree.slice(0, 6), 'div.setting-row', 'div.setting-block', 'div.setting-block', 'div.setting-row',
'div.setting-row', 'p.instruction-reset.small', reference.tree[7], '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]],
@@ -133,7 +157,26 @@ function mapTaggingConcept(reference, { template, phone }) {
// 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' } },
// D203: the default of each language-model engine (the fixture's saved entry is both), on the concept's
// select row.
defaultRow('gguf', 'GGUF JoyCaption'), defaultRow('hf', 'HuggingFace JoyCaption'),
],
// D203: Saved instructions in the concept's setting-row tokens (as D202), Built-in selected and read-only.
instructions: { tree: ['div.setting-block-text', 'div.instructions-layout'], key: 'autotag_instructions',
text: [['label', 'Saved instructions'], ['p', `${INSTRUCTIONS_DESCRIPTION} ${saved.length} of 20 saved · Built\u2011in is not counted.`]],
list: [['Built-in', [['Read-only', 'chip']], 'true'],
...saved.map((entry, index) => [entry.name, index === 0
? [['Default for GGUF', 'chip blue'], ['Default for HuggingFace', 'chip blue']] : [], 'false'])],
listRole: 'listbox',
actions: [['+ New', '', false], ['Duplicate', '', false], ['Delete', 'danger', true]],
editor: [['Name', 'Built-in', true], ['Text', builtin, true]],
note: 'Built-in instructions can’t be changed. Duplicate them to make your own.',
layout: phone ? 1 : 2,
paddingTop: reference.row.paddingTop, paddingBottom: reference.row.paddingBottom,
borderTopWidth: reference.row.borderTopWidth, borderTopColor: reference.row.borderTopColor,
label: reference.label,
textarea: { fontSize: reference.field.textarea.fontSize, backgroundColor: reference.field.textarea.backgroundColor,
borderTopColor: reference.field.textarea.borderTopColor } },
// D202 (#431): the Skipped tags block in the concept's setting-row tokens: its label and description as a row's,
// the row's padding and rule above, the concept's field input; an empty list says so.
skipped: { tree: ['div.setting-block-text', 'div.skip-add', 'p.skip-problem.small', 'div.skip-chips.tags', 'p.skip-preview.small'],
@@ -148,15 +191,15 @@ function mapTaggingConcept(reference, { template, phone }) {
marginTop: reference.description.marginTop },
input: { fontSize: '12px', backgroundColor: reference.field.textarea.backgroundColor, color: reference.field.textarea.color,
borderTopColor: reference.field.textarea.borderTopColor } },
// 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 } },
// D203: no template field.
field: null,
// 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 (#398): every select shows its whole chosen option at every width (at 390 the concept's 120px cap cuts
// them; production lets each grow past it).
selectsFit: reference.selectsFit.map(() => true),
selectsFit: [...reference.selectsFit, true, true].map(() => true),
};
}
@@ -180,7 +223,9 @@ async function productionTagging(page, theme) {
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 };
const { settings } = await response.json();
return { builtin: settings.autotag_builtin_instructions, saved: settings.autotag_instructions,
phone: page.viewportSize().width <= 700 };
}
for (const theme of THEMES) for (const [width, height] of SIZES) {
@@ -204,14 +249,15 @@ for (const theme of THEMES) for (const width of [390, 320]) {
await page.locator('#workspace-main #tag-existing').selectOption('replace_after_review');
await page.locator('#workspace-main section.surface').screenshot({ path: testInfo.outputPath(`production-tagging-selects-${theme}-${width}.png`) });
const selects = page.locator('#workspace-main section.surface > .setting-row > select');
await expect(selects).toHaveCount(2);
for (let index = 0; index < 2; index += 1) {
// D203 adds the two per-engine default selects.
await expect(selects).toHaveCount(4);
for (let index = 0; index < 4; index += 1) {
const select = selects.nth(index);
const values = await select.evaluate(node => [...node.options].map(option => option.value));
for (const value of values) {
await select.selectOption(value);
const measured = await page.evaluate(measureTagging, '#workspace-main');
expect(measured.selectsFit, `${await select.getAttribute('id')}=${value}`).toEqual([true, true]);
expect(measured.selectsFit, `${await select.getAttribute('id')}=${value}`).toEqual([true, true, true, true]);
expect(measured.pageScroll, value).toBe(false);
}
}
@@ -225,15 +271,16 @@ test('auto-tagging comparison detects mutations', async ({ page, request }) => {
const expected = mapTaggingConcept(concept, saved);
const production = await productionTagging(page, 'dark');
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the section differs only where D132, D133, D134 and D202 are recorded (and the saved template's text).
// Unmapped, the section differs only where D132, D133, D134, D202 and D203 are recorded (and the saved template's text).
// (The concept has no Skipped tags block, so every path under it is one D202 difference.)
expect(diffInventory(concept, production).map(item => (item.path.startsWith('skipped') ? 'skipped'
expect(diffInventory(concept, production).map(item => (/^(skipped|instructions|field)\b/.test(item.path) ? item.path.split('.')[0]
: 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', 'skipped', 'tree.6', 'tree.7', 'tree.8', 'tree.9', 'tree.10',
'tree.length'].sort());
'actions.buttons.1', 'actions.buttons.2', 'field', 'instructions', 'operations.0.actions', 'operations.0.description',
'operations.0.title', 'operations.0.tree', 'operations.length', 'rows.0.control', 'rows.0.text',
...[4, 5, 6].flatMap(row => ['control', 'controlBelow', 'flexWrap', 'text', 'tree'].map(key => `rows.${row}.${key}`)),
'rows.length', 'selectsFit.2', 'selectsFit.3', 'selectsFit.length', 'skipped',
...Array.from({ length: 8 }, (_, index) => `tree.${index + 6}`), 'tree.length'].sort());
const measure = () => page.evaluate(measureTagging, '#workspace-main');
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
// A removed row.
@@ -284,8 +331,8 @@ test('auto-tagging at 390 wraps only the engine row', async ({ page, request })
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).toEqual([true, true]);
expect(production.rows.map(row => row.flexWrap)).toEqual(['wrap', 'nowrap', 'nowrap', 'nowrap', 'nowrap', 'nowrap', 'nowrap']);
expect(production.selectsFit).toEqual([true, true, true, true]);
});
/** Runs in the page: the open review dialog: its title, body and the concept's tokens. */
+11 -2
View File
@@ -92,6 +92,8 @@ export function cardActionsInventory(card) {
};
}
// #431: the fixture's saved "Describe the image" entry, the default of both engines (workspace_tagging_fixture.py).
export const FIXTURE_INSTRUCTIONS_ID='d35c71be0a1e';
// D84 (plan D61): the legacy engines' values, labels and sizes, GGUF JoyCaption in the concept's Ollama slot.
const MODEL_LABELS=Object.freeze({'wd14-swinv2':['wd14-swinv2','WD14 SwinV2 · ~400 MB'],'wd14-vit':['wd14-vit','WD14 ViT · ~400 MB'],
ollama:['gguf','GGUF JoyCaption (Recommended) · ~4.5 GB'],huggingface:['hf','HuggingFace JoyCaption · ~16 GB']});
@@ -106,17 +108,24 @@ const CONCEPT_LABELS=Object.freeze({'WD14 SwinV2 · fast classifier':'WD14 SwinV
* (`chip`: a concept `.chip` inventory, the heading's size chip) around the row's own text (12px) in the checked
* row's colours (accent border, `--soft-blue`);
* - D84 (plan D61): the legacy engine labels and values, and the server's default instructions (`instructions`);
* - D204 (#431): a "Saved instructions" select (`picker`: its value and options) before the instructions, on the
* concept's select field tokens;
* - D91 (plan D68): a Suggestions count field after the model's own fields, on the concept's number field tokens
* (`numberField`: the concept's General confidence field) with the configured count (`limit`);
* - D29: the prototype's "Sample suggestions from …" status reads "Suggestions from …".
* The opened state is compared unmapped: both show the concept's on-screen "Generate suggestions with the selected
* model before approving." (its renderTagModelOptions, a1062–1076, replaces the markup's sentence at once).
*/
export function mapConceptTagging(reference,{instructions,limit=10,chip,numberField}) {
export function mapConceptTagging(reference,{instructions,limit=10,chip,numberField,
picker={value:FIXTURE_INSTRUCTIONS_ID,options:[['builtin','Built-in'],[FIXTURE_INSTRUCTIONS_ID,`${instructions} · default`]]}}) {
const [select,...own]=reference.modelPanel.fields;
// D204 (#431): a "Saved instructions" select on the concept's select field, before the instructions.
const pickField={...select,label:'Saved instructions',control:{...select.control,id:'image-tag-instructions-pick',
value:picker.value,options:picker.options}};
const suggestionsField={...numberField,label:'Suggestions',control:{...numberField.control,id:'image-suggestion-limit',
value:String(limit),min:'1',max:'100',step:'1'}};
const mappedOwn=own.map(field=>field.control.id==='image-tag-instructions'?{...field,control:{...field.control,value:instructions}}:field);
const mappedOwn=own.flatMap(field=>field.control.id==='image-tag-instructions'
?[pickField,{...field,control:{...field.control,value:instructions}}]:[field]);
const rows=reference.suggestions.rows.map(row=>({...row,
control:row.control?.kind==='checkbox'&&row.control.checked?{kind:'remove',label:`Remove ${row.name}`}:row.control,
borderTopLeftRadius:chip.borderTopLeftRadius}));
@@ -64,7 +64,9 @@ test('the section shows the concept rows; Metadata cache follows Q5 (D102)', asy
item.querySelector('.row-actions > button').textContent, item.querySelector('.row-actions > button').className]),
]))).toEqual([
['Database', [['Integrity & relationship checks', 'Check', ''], ['Duplicate prompt text', 'Review plan', ''],
['Optimize database', 'Review plan', ''], ['Unused prompt components', 'Review plan', 'danger']]],
['Optimize database', 'Review plan', ''], ['Unused prompt components', 'Review plan', 'danger'],
// D192 (#550): beyond the concept.
['Pair category and tags', 'Review plan', '']]],
['Images & cache', [['Missing-file links', 'Review plan', 'danger'], ['Duplicate image files', 'Find duplicates', ''],
['Thumbnail cache', 'Review options', ''], ['Metadata cache', 'Review plan', '']]],
]);
@@ -177,6 +179,70 @@ test("plan dialog shows the server's scope, protected, backup policy and media l
expect(await serverJson(request, '/__test__/system/digest')).toEqual(before);
});
test('D192: Pair category and tags reviews old Pair prompts, keeps an edited one and fills the other (#550)',
async ({ page, request }) => {
await expect(await request.post('/__test__/system/pair-metadata')).toBeOK();
const answers = [];
page.on('response', response => { if (response.url().endsWith(PREVIEW_URL)) answers.push(response.json()); });
await openMaintenance(page);
const pairRow = row(page, 'Pair category and tags');
await expect(pairRow.locator('p')).toHaveText('Fill an empty category or tags on prompts older Pair node runs saved, ' +
'from their saved pairs. Only empty fields of never-edited prompts.');
await pairRow.getByRole('button', { name: 'Review plan', exact: true }).click();
const plan = dialog(page);
await expect(plan.locator('#modal-body > h3')).toHaveText('Pair category and tags');
const answer = await answers[0];
expect(answer.operation).toBe('pair_metadata');
expect(await gridOf(page)).toEqual([
['Scope', '1 prompt to fill: 1 category, 1 tag set'],
['Protected', '1 edited after it was saved'],
['Backup', `Create and verify before changes (${answer.backup.destination_label})`],
['Media on disk', 'No image file or saved pair is changed.'],
]);
await expect(plan.locator('details > summary')).toHaveText('Listed records (3)');
await plan.locator('details > summary').click();
// Protected rows come first, the Record row last.
const lines = (await plan.locator('details > pre').textContent()).split('\n');
expect(lines).toHaveLength(3);
expect(lines[0]).toBe('S550 harbor · Kept: edited after it was saved; its empty category or tags may be intentional');
expect(lines[1]).toMatch(/^S550 lantern · Fills category “night” and 2 tags: s550-glow, s550-warm \(pair \d+\)$/);
expect(lines[2]).toMatch(/^Record · The job's report lists every filled field\. Download it to keep it/);
await plan.getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
await expect(dialog(page)).toHaveCount(0);
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
const job = await ended(request, page.url().split('/').pop());
expect([job.title, job.state, job.counters.changed, job.counters.protected])
.toEqual(['Pair category and tags', 'finished', 1, 1]);
const main = page.locator('#workspace-main');
await expect(main.locator('h1')).toHaveText('Pair category and tags');
await expect(main.getByRole('button', { name: 'Download report', exact: true })).toBeVisible();
// #550 live test: the Changed group lists the path-less record rows as plain text, never an open action.
const results = main.getByRole('region', { name: 'Results', exact: true });
await results.locator('.outcome-list > button').filter({ hasText: 'Changed' }).click();
const records = results.locator('.outcome-files > .file-row');
await expect(records).toHaveCount(1);
await expect(records.first()).toHaveText(/^Prompt \d+: category “night”; tags s550-glow, s550-warm$/);
expect(await records.first().evaluate(node => node.localName)).toBe('div');
await expect(results.locator('.outcome-files button')).toHaveCount(0);
await expect(results.getByRole('searchbox', { name: 'Filter results' })).toHaveAttribute('placeholder', 'Filter records…');
// Protected lists each kept prompt with the review's reason (#550 live re-check).
await results.locator('.outcome-list > button').filter({ hasText: 'Protected' }).click();
await expect(records).toHaveCount(1);
await expect(records.first()).toHaveText(
/^Prompt \d+: kept, edited after it was saved; its empty category or tags may be intentional$/);
// An empty group says records, not files.
await results.locator('.outcome-list > button').filter({ hasText: 'Errors' }).click();
await expect(results.locator('.outcome-files > p.empty-state')).toHaveText('No records in this group.');
const library = await serverJson(request, '/prompt_manager/library');
const lantern = library.items.find(item => item.text === 'S550 lantern');
const harbor = library.items.find(item => item.text === 'S550 harbor');
const filled = (await serverJson(request, `/prompt_manager/library/${lantern.id}?archive=all`)).positive;
expect([filled.category, [...filled.tags].sort()]).toEqual(['night', ['s550-glow', 's550-warm']]);
expect((await serverJson(request, `/prompt_manager/library/${harbor.id}?archive=all`)).positive.category).toBeFalsy();
await page.goto(`${workspace}#/library`);
await expect(page.locator(`article[data-prompt-id="${lantern.id}"] .card-title > .chip`)).toHaveText('night');
});
test('a plan with nothing to do shows only Close, never Apply', async ({ page, request }) => {
const applies = [];
page.on('request', item => { if (item.url().endsWith(APPLY_URL)) applies.push(item.url()); });
@@ -112,13 +112,14 @@ test('model options are the four real engines with size and the description show
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']);
'General-tag confidence', 'Character-tag confidence', 'Suggestions per image', 'Default for GGUF JoyCaption',
'Default for HuggingFace JoyCaption']);
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);
// D203 (#431): Saved instructions replace the template field.
await expect(page.locator('#tag-template')).toHaveCount(0);
// Unload is offered only while a model is loaded (none is).
await expect(actionOf(page, 'Unload')).toBeDisabled();
});
@@ -500,3 +501,74 @@ test('skipped tags preview checks library tags only without a label list, and a
await expect(skippedBlock(page)).toHaveAttribute('aria-invalid', 'true');
await page.unrouteAll({ behavior: 'ignoreErrors' });
});
// D203 (#431): Saved instructions: Built-in read-only, New, edit, per-engine defaults, Delete resets a default, Save
// preferences and a reload; the server pairs the two keys. D205: the batch review names the engine's default.
const instructionOption = (page, name) => page.locator('#instruction-list [role="option"]').filter({ has: page.locator(`span:text-is("${name}")`) });
test('saved instructions: new, edit, per-engine default, delete, save and reload (D203)', async ({ page, request }) => {
await openTagging(page);
const settings = (await serverJson(request, SETTINGS_URL)).settings;
await expect(page.locator('#instructions-count')).toHaveText('1 of 20 saved · Built\u2011in is not counted.');
await expect(page.locator('#instruction-list [role="option"] > span:first-child')).toHaveText(['Built-in', 'Describe the image']);
await expect(page.locator('#instruction-name')).toHaveValue('Built-in');
await expect(page.locator('#instruction-text')).toHaveValue(settings.autotag_builtin_instructions);
await expect(page.locator('#instruction-text')).toHaveAttribute('readonly', '');
await expect(page.locator('#instruction-delete')).toBeDisabled();
await expect(page.locator('#instruction-default-gguf')).toHaveValue(settings.autotag_instructions[0].id);
// + New, then name and text it, and make it HuggingFace's default.
await page.locator('#instruction-new').click();
await expect(page.locator('#instruction-name')).toHaveValue('New instructions');
await expect(page.locator('#instruction-note')).toHaveText('The text must not be empty.');
await page.locator('#instruction-name').fill('Photo realism');
await page.locator('#instruction-text').fill('Tag camera angle and lighting.');
await expect(page.locator('#instruction-text').locator('xpath=..').locator('label .muted')).toHaveText('30 of 4,000 characters');
await page.locator('#instruction-default-hf').selectOption({ label: 'Photo realism' });
await expect(instructionOption(page, 'Photo realism').locator('.chip')).toHaveText(['Default for HuggingFace']);
await expect(status(page)).toHaveText('Unsaved changes.');
await save(page).click();
await expect(status(page)).toHaveText('Saved to the server configuration.');
let saved = (await serverJson(request, SETTINGS_URL)).settings;
expect(saved.autotag_instructions.map(entry => [entry.name, entry.text])).toEqual([['Describe the image', 'Describe the image'],
['Photo realism', 'Tag camera angle and lighting.']]);
expect(saved.autotag_default_instructions.hf).toBe(saved.autotag_instructions[1].id);
// A reload shows the saved list; deleting GGUF's default resets it to Built-in and says so.
await openTagging(page);
await instructionOption(page, 'Describe the image').click();
await page.locator('#instruction-delete').click();
await expect(page.locator('#instruction-reset')).toHaveText('GGUF now uses Built-in.');
await expect(page.locator('#instruction-default-gguf')).toHaveValue('builtin');
await save(page).click();
await expect(status(page)).toHaveText('Saved to the server configuration.');
saved = (await serverJson(request, SETTINGS_URL)).settings;
expect(saved.autotag_instructions.map(entry => entry.name)).toEqual(['Photo realism']);
expect(saved.autotag_default_instructions.gguf).toBe('builtin');
expect(saved.autotag_template).toBe(saved.autotag_builtin_instructions);
});
test('a refused instructions save names the entry and highlights the block (D203)', async ({ page }) => {
await openTagging(page);
await page.locator('#instruction-duplicate').click();
await page.locator('#instruction-name').fill('describe the IMAGE');
await expect(page.locator('#instruction-note')).toHaveText('The name “describe the IMAGE” is already used.');
await save(page).click();
await expect(status(page)).toHaveText('Saved instructions: entry 2 name “describe the IMAGE” is already used.');
await expect(surface(page).locator('.setting-block[data-setting="tag-instructions"]')).toHaveAttribute('aria-invalid', 'true');
});
test('the batch review names the language model\'s default instructions (D205)', async ({ page }) => {
await openTagging(page);
await page.locator('#tag-model').selectOption('gguf');
await page.locator('#tag-existing').selectOption('merge');
const answer = page.waitForResponse(item => item.url().endsWith(PREVIEW_URL));
await reviewButton(page).click();
await answer;
await expect(dialog(page).locator('#modal-title')).toHaveText('Review tag output images');
const grid = await gridOf(page);
expect(grid.slice(-3)).toEqual([['Instructions', 'Describe the image'], ['Settings', 'Up to 10 tags per image'],
grid.at(-1)]);
// The batch sends the default's text.
const start = page.waitForRequest(item => item.url().endsWith('/prompt_manager/jobs/autotag'));
await dialog(page).getByRole('button', { name: 'Start tagging', exact: true }).click();
expect((await start).postDataJSON().options).toEqual({ instructions: 'Describe the image' });
});
@@ -341,3 +341,21 @@ test('a running scan makes the start report busy', async ({ page, request }) =>
await request.post(`/__test__/jobs/${scan.job.id}/finish`, { data: { state: 'finished' } });
}
});
// D204 (#431): Auto-tag selected has the same picker; the job sends the text the dialog shows.
test('Auto-tag selected picks saved instructions and sends the text shown (D204)', async ({ page }) => {
await select(page, FIRST);
await barButton(page, 'Auto-tag selected').click();
await expect(title(page)).toHaveText('Auto-tag selected images');
const body = modal(page).locator('#modal-body');
await body.getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('gguf');
const pick = body.getByRole('combobox', { name: 'Saved instructions', exact: true });
await expect(pick).toHaveAttribute('id', 'bulk-tag-instructions-pick');
await expect(pick.locator('option')).toHaveText(['Built-in', 'Describe the image · default']);
const text = body.getByRole('textbox', { name: 'Tagging instructions', exact: true });
await text.fill('Bulk one-off.');
await expect(pick.locator('option:checked')).toHaveText('Edited for this run');
const started = jobStart(page);
await actionButtons(page).filter({ hasText: 'Start auto-tagging' }).click();
expect((await started).postDataJSON().options.instructions).toBe('Bulk one-off.');
});
+47 -1
View File
@@ -4,7 +4,7 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { saveImageTags } from './concept-inventory.mjs';
import { taggingInventory } from './tagging-canonical-comparison.mjs';
import { FIXTURE_INSTRUCTIONS_ID, taggingInventory } from './tagging-canonical-comparison.mjs';
const images = '/prompt_manager/workspace#/images';
const MODULE = '/prompt_manager/js/workspace/image-tagging.mjs';
@@ -861,3 +861,49 @@ test('suggestions omit skipped tags and say how many were hidden', async ({ page
await expect(chips(page).locator(':scope > span:first-child')).toHaveText(['sandstone', 'arch', 'sky', 'dune']);
await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(`${CHIP_HINT} 2 skipped tags were not suggested.`);
});
// D204 (#431): the Saved instructions select above Tagging instructions: the engine default, a pick, a one-off edit
// ("Edited for this run" and the muted note), an engine switch resets, WD14 hides both; Generate sends the text shown.
test('the saved instructions picker fills, edits one-off and resets per engine (D204)', async ({ page, request }) => {
const settings = await (await request.get('/prompt_manager/settings')).json();
const builtin = settings.settings.autotag_builtin_instructions;
await openTag(page);
const options = dialog(page).locator('#image-tag-model-options');
const pick = options.getByRole('combobox', { name: 'Saved instructions', exact: true });
const text = options.getByRole('textbox', { name: 'Tagging instructions', exact: true });
const note = options.locator('.instructions-edited');
await expect(pick).toHaveCount(0);
await model(page).selectOption('gguf');
await expect(pick.locator('option')).toHaveText(['Built-in', 'Describe the image · default']);
await expect(pick).toHaveAttribute('id', 'image-tag-instructions-pick');
await expect(text).toHaveValue('Describe the image');
await expect(note).toBeHidden();
// A keyboard pick (the select focused) keeps focus on the select the fields are re-rendered with.
await pick.focus();
await pick.selectOption('builtin');
await expect(text).toHaveValue(builtin);
await expect(pick).toBeFocused();
await text.fill('Only the sky.');
await expect(text).toHaveAttribute('aria-describedby', 'image-tag-instructions-pick-note');
await expect(pick.locator('option:checked')).toHaveText('Edited for this run');
await expect(pick.locator('option[value="edited"]')).toBeDisabled();
await expect(note).toHaveText('Edits apply to this run only. Manage saved instructions in System › Auto-tagging.');
await expect(text).toBeFocused();
// Generate sends the edited text; the saved entries are unchanged.
const sent = page.waitForRequest(item => item.url().endsWith('/image-annotations/suggest'));
await generate(page).click();
expect((await sent).postDataJSON().options.instructions).toBe('Only the sky.');
// Choosing an entry replaces the edit; switching engines starts from the new engine's default.
await pick.selectOption('builtin');
await expect(text).toHaveValue(builtin);
await expect(note).toBeHidden();
await text.fill('Edited again.');
await model(page).selectOption('hf');
await expect(text).toHaveValue('Describe the image');
await expect(pick).toHaveValue(FIXTURE_INSTRUCTIONS_ID);
await model(page).selectOption('wd14-vit');
await expect(pick).toHaveCount(0);
await expect(text).toHaveCount(0);
expect((await (await request.get('/prompt_manager/settings')).json()).settings.autotag_instructions)
.toEqual(settings.settings.autotag_instructions);
});
+24
View File
@@ -41,6 +41,11 @@ read. These per-server hooks only change what a System test needs on top:
- ``POST /__test__/system/duplicate-prompts`` (S7) adds two prompts whose
text differs only in case and spacing (a duplicate prompt text group);
``{"again": true}`` adds a third, so a reviewed plan no longer matches;
- ``POST /__test__/system/pair-metadata`` (#550) saves two saved pairs the
way the Pair node did before #546 (the pair holds the category and tags,
its new positive prompt none): ``S550 lantern`` (category ``night``, tags
``s550-glow``, ``s550-warm``) fills; ``S550 harbor`` was edited after it was
saved, so the plan keeps it;
- ``POST /__test__/system/duplicate-files`` (S7) writes one PNG's bytes three
times: ``output/s7-dupes/same.png``, ``input/s7-dupes/same.png`` (the same
relative path in the other source) and ``output/s7-dupes/copy.png``;
@@ -344,6 +349,25 @@ def register_system_commands(app, routes, api, root: Path):
conn.commit()
return web.json_response({"success": True})
@routes.post("/__test__/system/pair-metadata")
async def pair_metadata(request):
def seed():
api.db.save_prompt_pair(
"S550 lantern",
"S550 blurry",
category="night",
tags=["s550-glow", "s550-warm"],
)
api.db.save_prompt_pair("S550 harbor", category="night")
with api.db.model.get_connection() as conn:
conn.execute(
"UPDATE prompts SET updated_at='2099-01-01T00:00:00+00:00' "
"WHERE text='S550 harbor'"
)
await api._run_in_executor(seed)
return web.json_response({"success": True})
@routes.post("/__test__/system/duplicate-files")
async def duplicate_files(request):
body = await request.json() if request.can_read_body else {}
+22 -3
View File
@@ -391,10 +391,15 @@ DOWNLOAD_HOLD_LIMIT_S = 60
DEFAULTS = (0.35, 0.85, 10)
# The saved "Describe the image" entry's id (#431).
FIXTURE_INSTRUCTIONS_ID = "d35c71be0a1e"
def _set_defaults(model, values) -> None:
"""Set the saved tagging defaults (``AutotagConfig``, #328); a reset also
puts back the saved model, batch policy and template (the fake model's
short instructions, which the concept comparisons show)."""
puts back the saved model, batch policy, skipped tags and instructions
(the fake model's short instructions, which the concept comparisons
show)."""
from py.config import AutotagConfig
general, character, limit = values
@@ -404,7 +409,21 @@ def _set_defaults(model, values) -> None:
if values == DEFAULTS:
AutotagConfig.MODEL_TYPE = "wd14-swinv2"
AutotagConfig.EXISTING = "skip"
AutotagConfig.TEMPLATE = model.default_prompt
# #431: Built-in is always the shipped text, so the fake model's short
# instructions are a saved entry, the default of both engines (the
# concept comparisons show it).
AutotagConfig.INSTRUCTIONS = (
{
"id": FIXTURE_INSTRUCTIONS_ID,
"name": model.default_prompt,
"text": model.default_prompt,
},
)
AutotagConfig.DEFAULT_INSTRUCTIONS = {
"gguf": FIXTURE_INSTRUCTIONS_ID,
"hf": FIXTURE_INSTRUCTIONS_ID,
}
AutotagConfig._LEGACY_TEMPLATE = None
AutotagConfig.SKIPPED_TAGS = ()
+3
View File
@@ -185,9 +185,12 @@
"monitor.scheduled",
"revision",
"settings",
"settings.autotag_builtin_instructions",
"settings.autotag_character_threshold",
"settings.autotag_default_instructions",
"settings.autotag_existing",
"settings.autotag_general_threshold",
"settings.autotag_instructions",
"settings.autotag_model_type",
"settings.autotag_skipped_tags",
"settings.autotag_suggestion_limit",
+92 -3
View File
@@ -53,8 +53,12 @@ function observed(overrides = {}, capture = {}) {
...overrides,
};
}
// #431: the saved instructions; the fixture's entry is the default of both engines.
const SAVED_ENTRY = { id: '0123456789ab', name: 'Saved', text: DEFAULT_PROMPT };
const models = (overrides = {}) => ({
success: true, models: { 'wd14-swinv2': { downloaded: true } }, default_prompt: DEFAULT_PROMPT,
success: true, models: { 'wd14-swinv2': { downloaded: true } },
instructions: [{ id: 'builtin', name: 'Built-in', text: 'Built-in text.' }, SAVED_ENTRY],
default_instructions: { gguf: SAVED_ENTRY.id, hf: SAVED_ENTRY.id },
wd14_general_threshold: 0.35, wd14_character_threshold: 0.85, suggestion_limit: 10, ...overrides,
});
const SANDSTONE = [
@@ -244,10 +248,13 @@ test('dialog starts on the saved model unless a preset or the user chose one', a
});
test('an unreadable or malformed models read falls back to the documented defaults', async () => {
for (const route of [() => fail(500, null), () => response(200, models({ default_prompt: 4, suggestion_limit: 0, wd14_general_threshold: 2, wd14_character_threshold: 'x' }))]) {
for (const route of [() => fail(500, null), () => response(200, models({ instructions: 4, suggestion_limit: 0, wd14_general_threshold: 2, wd14_character_threshold: 'x' }))]) {
const { c } = await opened({ [`GET ${MODELS}`]: route });
assert.equal(c.state.phase, 'ready');
assert.deepEqual({ ...c.state.options }, { general_threshold: 0.35, character_threshold: 0.85, instructions: '', suggestion_limit: 10 });
// #431: the client's Built-in copy (D204's read failure).
const { BUILTIN_INSTRUCTIONS } = await load();
assert.deepEqual({ ...c.state.options }, { general_threshold: 0.35, character_threshold: 0.85, instructions: BUILTIN_INSTRUCTIONS,
suggestion_limit: 10 });
}
});
@@ -950,3 +957,85 @@ test('the chip hint says how many skipped tags were not suggested (#431)', async
assert.equal(c.state.chipHint, hint, String(skipped));
}
});
// D204 (#431): the Saved instructions picker of both dialogs.
const PHOTO_ENTRY = { id: '0123456789ac', name: 'Photo', text: 'Photo tags.' };
const picker = (overrides = {}) => ({ [`GET ${MODELS}`]: () => response(200, models({ model_type: 'gguf',
instructions: [{ id: 'builtin', name: 'Built-in', text: 'Built-in text.' }, SAVED_ENTRY, PHOTO_ENTRY],
default_instructions: { gguf: SAVED_ENTRY.id, hf: PHOTO_ENTRY.id }, ...overrides })) });
test('the picker starts at the engine default; a pick replaces the text and an edit is one-off (D204)', async () => {
const { c } = await opened(picker());
assert.deepEqual([c.state.instructionId, c.state.options.instructions, c.state.instructionsEdited], [SAVED_ENTRY.id, DEFAULT_PROMPT, false]);
const revision = c.state.optionsRevision;
c.pickInstructions(PHOTO_ENTRY.id);
assert.deepEqual([c.state.instructionId, c.state.options.instructions, c.state.instructionsEdited], [PHOTO_ENTRY.id, 'Photo tags.', false]);
assert.equal(c.state.optionsRevision, revision + 1, 'a pick re-renders the textarea');
c.setOption('instructions', 'Photo tags, please.');
assert.equal(c.state.instructionsEdited, true);
assert.equal(c.state.optionsRevision, revision + 1, 'typing never re-renders');
// Choosing an entry again replaces the edit without asking; an unknown id changes nothing.
c.pickInstructions(PHOTO_ENTRY.id);
assert.deepEqual([c.state.options.instructions, c.state.instructionsEdited], ['Photo tags.', false]);
c.pickInstructions('nope');
assert.equal(c.state.instructionId, PHOTO_ENTRY.id);
// Switching engines resets to the new engine's default and drops a one-off edit; WD14 is never "edited".
c.setOption('instructions', 'edited');
c.setModel('hf');
assert.deepEqual([c.state.instructionId, c.state.options.instructions, c.state.instructionsEdited], [PHOTO_ENTRY.id, 'Photo tags.', false]);
c.setModel('wd14-vit');
assert.equal(c.state.instructionsEdited, false);
c.setModel('gguf');
assert.deepEqual([c.state.instructionId, c.state.options.instructions], [SAVED_ENTRY.id, DEFAULT_PROMPT]);
c.dispose();
});
test('a preset shows the entry with its text, else "Edited for this run" (D204)', async () => {
const { c } = await opened(picker(), { preset: { model: 'hf', options: { instructions: DEFAULT_PROMPT } } });
assert.deepEqual([c.state.instructionId, c.state.instructionsEdited], [SAVED_ENTRY.id, false]);
const { c: other } = await opened(picker(), { preset: { model: 'hf', options: { instructions: 'Something else' } } });
assert.deepEqual([other.state.instructionId, other.state.options.instructions, other.state.instructionsEdited],
[PHOTO_ENTRY.id, 'Something else', true]);
for (const controller of [c, other]) controller.dispose();
});
test('a models read without instructions offers Built-in only with the client copy (D204)', async () => {
const { BUILTIN_INSTRUCTIONS, readChoices } = await load();
assert.equal(BUILTIN_INSTRUCTIONS.startsWith('Generate only comma-separated Danbooru tags'), true);
for (const value of [null, {}, { instructions: [] }, { instructions: [{ id: 'x', name: 'X', text: 'y' }] }]) {
assert.deepEqual(readChoices(value).entries.map(entry => entry.id), ['builtin'], JSON.stringify(value));
}
// A default naming no entry is Built-in.
assert.deepEqual({ ...readChoices({ instructions: [{ id: 'builtin', name: 'Built-in', text: 't' }], default_instructions: { gguf: 'gone' } }).defaults },
{ gguf: 'builtin', hf: 'builtin' });
const { c } = await opened(picker({ instructions: 'broken' }));
assert.deepEqual([c.state.instructionId, c.state.options.instructions], ['builtin', BUILTIN_INSTRUCTIONS]);
c.dispose();
});
test('the bulk options pick and reset like the Tag image dialog (D204)', async () => {
const { createTaggingOptionsController } = await load();
const options = createTaggingOptionsController({ api: harness(defaultRoutes(picker())).api });
await options.open();
assert.deepEqual([options.state.model, options.state.instructionId, options.state.options.instructions], ['gguf', SAVED_ENTRY.id, DEFAULT_PROMPT]);
options.pickInstructions(PHOTO_ENTRY.id);
assert.equal(options.request().options.instructions, 'Photo tags.');
options.setOption('instructions', 'One-off.');
assert.equal(options.state.instructionsEdited, true);
assert.equal(options.request().options.instructions, 'One-off.');
options.setModel('hf');
assert.deepEqual([options.state.instructionId, options.state.instructionsEdited], [PHOTO_ENTRY.id, false]);
options.pickInstructions('nope');
options.dispose();
options.pickInstructions(SAVED_ENTRY.id);
assert.equal(options.state.instructionId, PHOTO_ENTRY.id);
});
test('the client Built-in copy equals the server DEFAULT_TAG_TEMPLATE (#431)', async () => {
const { BUILTIN_INSTRUCTIONS } = await load();
const source = require('node:fs').readFileSync(require('node:path').join(__dirname, '../../py/tagging_defaults.py'), 'utf8');
const block = source.slice(source.indexOf('DEFAULT_TAG_TEMPLATE = ('), source.indexOf('\n)\n', source.indexOf('DEFAULT_TAG_TEMPLATE = (')));
const text = [...block.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map(([, part]) => JSON.parse(`"${part}"`)).join('');
assert.ok(text.length > 100);
assert.equal(BUILTIN_INSTRUCTIONS, text);
});
@@ -180,7 +180,9 @@ test('the bulk options start from the server defaults and wait for them before S
assert.equal(options.state.canStart, false);
assert.equal(options.state.canGenerate, false);
const opening = options.open();
answer({ wd14_general_threshold: 0.5, wd14_character_threshold: 0.9, default_prompt: 'Say it', suggestion_limit: 7,
answer({ wd14_general_threshold: 0.5, wd14_character_threshold: 0.9, suggestion_limit: 7,
instructions: [{ id: 'builtin', name: 'Built-in', text: 'Built-in text.' }, { id: '0123456789ab', name: 'Say', text: 'Say it' }],
default_instructions: { gguf: '0123456789ab', hf: 'builtin' },
models: { 'wd14-swinv2': { downloaded: true }, 'wd14-vit': { downloaded: false }, gguf: { downloaded: true } } });
await opening;
assert.equal(options.state.canStart, true);
@@ -0,0 +1,257 @@
// System › Auto-tagging "Saved instructions" (#431, D203): Built-in read-only, New, Duplicate, Delete (a deleted default
// resets to Built-in), the caps, the editor drafts and the per-engine default selects.
const test = require('node:test');
const assert = require('node:assert/strict');
const instructionsModule = import('../../web/js/workspace/system-instructions.mjs');
const stateModule = import('../../web/js/workspace/system-state.mjs');
const coreModule = import('../../web/js/shared/toolkit/core.js');
/** A minimal DOM: elements with children, attributes, listeners (aborted by their signal) and focus. */
function fakeDOM() {
const previous = global.document;
class Element {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.attributes = {};
this.listeners = [];
this.dataset = {};
this.className = '';
this.id = '';
this.value = '';
this.title = '';
this.disabled = false;
this._text = '';
}
get localName() { return this.tagName.toLowerCase(); }
get textContent() { return this._text + this.children.map(child => (typeof child === 'string' ? child : child.textContent)).join(''); }
set textContent(text) { this.children = []; this._text = String(text); }
append(...nodes) { for (const node of nodes) { if (typeof node !== 'string') node.parentNode = this; this.children.push(node); } }
replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); }
querySelectorAll(selector) {
assert.equal(selector, 'button');
const found = [];
const walk = node => { for (const child of node.children) if (typeof child !== 'string') { if (child.tagName === 'BUTTON') found.push(child); walk(child); } };
walk(this);
return found;
}
focus() { global.document.activeElement = this; }
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); });
}
dispatch(type, event = {}) {
return Promise.all(this.listeners.filter(item => item.type === type).map(({ callback }) => callback({ type, target: this, ...event })));
}
}
global.document = { createElement: tag => new Element(tag), activeElement: null };
const walk = (node, found = []) => { found.push(node); node.children.forEach(child => typeof child !== 'string' && walk(child, found)); return found; };
return { find: (root, predicate) => walk(root).find(predicate), restore() { global.document = previous; } };
}
const PHOTO = { id: '0123456789ab', name: 'Custom template', text: 'Photo tags.' };
const ANIME = { id: '0123456789ac', name: 'Anime', text: 'Anime tags.' };
const HOST = { autotag_builtin_instructions: 'Built-in text.', autotag_instructions: [PHOTO, ANIME],
autotag_default_instructions: { gguf: PHOTO.id, hf: PHOTO.id } };
async function mounted({ host = HOST } = {}) {
const { savedInstructionsBlock } = await instructionsModule;
const { createSystemDrafts } = await stateModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const drafts = createSystemDrafts({
readBrowser: () => ({}), readRuntime: () => ({}), readHost: async () => ({ values: host, revision: 'sha256:one' }),
writeBrowser: async () => ({}), writeHost: async () => ({ success: true, persisted: true }), writeRuntime: async () => ({}),
});
const scope = createScope();
let counter = 0;
const random = bytes => bytes.map(() => (counter += 1));
const block = savedInstructionsBlock({ scope, drafts, random });
const root = global.document.createElement('div');
root.append(...block.nodes);
scope.cleanup(drafts.subscribe(block.repaint));
block.repaint();
await drafts.reloadHost();
const byId = id => dom.find(root, node => node.id === id);
const options = () => byId('instruction-list').children.map(option => [option.children[0].textContent,
option.children[1]?.children.map(badge => badge.textContent) ?? [], option.getAttribute('aria-selected')]);
const click = id => byId(id).dispatch('click');
// A click on an option reaches the list's one listener (as it bubbles in a browser).
const pick = name => byId('instruction-list').dispatch('click', { target: byId('instruction-list').children
.find(option => option.children[0].textContent === name).children[0] });
return { dom, drafts, scope, block, byId, options, click, pick, root };
}
test('helpers: Built-in first, defaults, free names, ids and the server sentences', async () => {
const { allEntries, defaultEntry, freeName, newId, listProblem, entryNote } = await instructionsModule;
assert.deepEqual(allEntries(HOST).map(entry => entry.name), ['Built-in', 'Custom template', 'Anime']);
assert.equal(allEntries(HOST)[0].text, 'Built-in text.');
assert.equal(defaultEntry(HOST, 'hf').text, 'Photo tags.');
assert.equal(defaultEntry({ ...HOST, autotag_default_instructions: { gguf: 'gone', hf: 'builtin' } }, 'gguf').name, 'Built-in');
assert.equal(freeName('New instructions', allEntries(HOST)), 'New instructions');
assert.equal(freeName('anime', allEntries(HOST)), 'anime (2)');
assert.equal(freeName('Anime', [...allEntries(HOST), { name: 'Anime (2)' }]), 'Anime (3)');
let calls = 0;
const id = newId([{ id: '000000000000' }], bytes => { calls += 1; return bytes.fill(calls === 1 ? 0 : 171); });
assert.equal(id, 'abababababab');
assert.match(newId([]), /^[0-9a-f]{12}$/);
const list = entries => listProblem({ autotag_instructions: entries });
assert.equal(list([PHOTO]), null);
assert.equal(list([{ ...PHOTO, name: ' ' }]), 'Saved instructions: entry 1 needs a name.');
assert.equal(list([PHOTO, { ...ANIME, name: 'x'.repeat(61) }]), 'Saved instructions: entry 2 name must be at most 60 characters.');
assert.equal(list([{ ...PHOTO, name: 'a\nb' }]), 'Saved instructions: entry 1 name must not contain a line break.');
assert.equal(list([{ ...PHOTO, name: 'built-in' }]), 'Saved instructions: the name “Built-in” is reserved.');
assert.equal(list([PHOTO, { ...ANIME, name: ' custom TEMPLATE ' }]), 'Saved instructions: entry 2 name “custom TEMPLATE” is already used.');
assert.equal(list([{ ...PHOTO, text: ' ' }]), 'Saved instructions: entry 1 text must be non-empty text.');
assert.equal(list([{ ...PHOTO, text: 'x'.repeat(4001) }]), 'Saved instructions: entry 1 text must be at most 4000 characters.');
const long = { ...PHOTO, text: 'x'.repeat(4001) };
assert.equal(entryNote(long, [long]), 'The text must be at most 4,000 characters.');
});
test('the list shows Built-in read-only first, the saved entries and their default chips', async () => {
const view = await mounted();
try {
assert.deepEqual(view.options(), [['Built-in', ['Read-only'], 'true'],
['Custom template', ['Default for GGUF', 'Default for HuggingFace'], 'false'], ['Anime', [], 'false']]);
assert.equal(view.byId('instructions-count').textContent, '2 of 20 saved · Built‑in is not counted.');
assert.equal(view.byId('instruction-name').value, 'Built-in');
assert.equal(view.byId('instruction-name').readOnly, true);
assert.equal(view.byId('instruction-text').value, 'Built-in text.');
assert.equal(view.byId('instruction-text').readOnly, true);
assert.equal(view.byId('instruction-note').textContent, 'Built-in instructions can’t be changed. Duplicate them to make your own.');
assert.equal(view.byId('instruction-delete').disabled, true);
assert.equal(view.byId('instruction-list').getAttribute('role'), 'listbox');
assert.equal(view.block.nodes[0].dataset.key, 'autotag_instructions');
assert.deepEqual(view.byId('instruction-default-gguf').children.map(option => option.textContent), ['Built-in', 'Custom template', 'Anime']);
assert.equal(view.byId('instruction-default-gguf').value, PHOTO.id);
assert.equal(view.byId('instruction-default-hf').dataset.key, 'autotag_default_instructions');
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('editing a saved entry drafts the list; its counts and note follow', async () => {
const view = await mounted();
try {
await view.pick('Anime');
assert.equal(view.byId('instruction-name').readOnly, false);
assert.equal(view.byId('instruction-delete').disabled, false);
view.byId('instruction-name').value = 'Anime tags';
await view.byId('instruction-name').dispatch('input');
view.byId('instruction-text').value = 'Tags, anime.';
await view.byId('instruction-text').dispatch('input');
assert.deepEqual(view.drafts.get('host', 'autotag_instructions'), [PHOTO, { ...ANIME, name: 'Anime tags', text: 'Tags, anime.' }]);
assert.equal(view.byId('instruction-name').parentNode.children[0].children[1].textContent, '10 of 60');
assert.equal(view.byId('instruction-text').parentNode.children[0].children[1].textContent, '12 of 4,000 characters');
view.byId('instruction-name').value = 'custom template';
await view.byId('instruction-name').dispatch('input');
assert.equal(view.byId('instruction-note').textContent, 'The name “custom template” is already used.');
// Built-in is never edited.
await view.pick('Built-in');
view.byId('instruction-text').value = 'changed';
await view.byId('instruction-text').dispatch('input');
assert.equal(view.drafts.get('host', 'autotag_instructions')[0], PHOTO);
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('New and Duplicate append an entry with a fresh id and a free name; the caps disable them', async () => {
const view = await mounted();
try {
await view.click('instruction-new');
const added = view.drafts.get('host', 'autotag_instructions').at(-1);
assert.match(added.id, /^[0-9a-f]{12}$/);
assert.deepEqual([added.name, added.text], ['New instructions', '']);
assert.equal(view.options().at(-1)[2], 'true');
assert.equal(view.byId('instruction-note').textContent, 'The text must not be empty.');
await view.pick('Built-in');
await view.click('instruction-duplicate');
assert.deepEqual(view.drafts.get('host', 'autotag_instructions').at(-1).name, 'Built-in copy');
assert.equal(view.drafts.get('host', 'autotag_instructions').at(-1).text, 'Built-in text.');
const full = await mounted({ host: { ...HOST, autotag_instructions: Array.from({ length: 20 },
(_, i) => ({ id: i.toString(16).padStart(12, '0'), name: `n${i}`, text: 't' })) } });
try {
assert.equal(full.byId('instruction-new').disabled, true);
assert.equal(full.byId('instruction-duplicate').disabled, true);
assert.equal(full.byId('instruction-cap').hidden, false);
await full.click('instruction-new');
assert.equal(full.drafts.get('host', 'autotag_instructions').length, 20);
} finally { full.scope.dispose(); }
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('deleting a default resets that engine to Built-in and says so; a select change clears the note', async () => {
const view = await mounted();
try {
await view.pick('Custom template');
await view.click('instruction-delete');
assert.deepEqual(view.drafts.get('host', 'autotag_instructions'), [ANIME]);
assert.deepEqual(view.drafts.get('host', 'autotag_default_instructions'), { gguf: 'builtin', hf: 'builtin' });
assert.equal(view.byId('instruction-reset').textContent, 'GGUF and HuggingFace now use Built-in.');
assert.equal(view.options()[1][2], 'true', 'the next entry is selected');
view.byId('instruction-default-hf').value = ANIME.id;
await view.byId('instruction-default-hf').dispatch('change');
assert.deepEqual(view.drafts.get('host', 'autotag_default_instructions'), { gguf: 'builtin', hf: ANIME.id });
assert.equal(view.byId('instruction-reset').textContent, '');
await view.click('instruction-delete');
assert.equal(view.byId('instruction-reset').textContent, 'HuggingFace now uses Built-in.');
assert.deepEqual(view.options().map(([name]) => name), ['Built-in']);
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('arrow keys move the selection; before the read everything is disabled', async () => {
const view = await mounted();
try {
const list = view.byId('instruction-list');
await list.dispatch('keydown', { key: 'ArrowDown' });
assert.equal(view.byId('instruction-name').value, 'Custom template');
await list.dispatch('keydown', { key: 'End' });
assert.equal(view.byId('instruction-name').value, 'Anime');
await list.dispatch('keydown', { key: 'ArrowDown' });
assert.equal(view.byId('instruction-name').value, 'Anime');
await list.dispatch('keydown', { key: 'Home' });
assert.equal(view.byId('instruction-name').value, 'Built-in');
await list.dispatch('keydown', { key: 'a' });
assert.equal(list.getAttribute('aria-activedescendant'), 'instruction-option-builtin');
} finally { view.scope.dispose(); view.dom.restore(); }
const unread = await mounted({ host: {} });
try {
assert.equal(unread.byId('instruction-new').disabled, true);
assert.equal(unread.byId('instruction-default-gguf').disabled, true);
} finally { unread.scope.dispose(); unread.dom.restore(); }
});
test('the reset note goes once the defaults are saved; a long name is cut to fit its copy suffix', async () => {
const view = await mounted();
try {
await view.pick('Custom template');
await view.click('instruction-delete');
assert.equal(view.byId('instruction-reset').textContent, 'GGUF and HuggingFace now use Built-in.');
await view.drafts.save();
assert.equal(view.byId('instruction-reset').textContent, '');
await view.pick('Anime');
view.byId('instruction-name').value = 'x'.repeat(60);
await view.byId('instruction-name').dispatch('input');
await view.click('instruction-duplicate');
const copy = view.drafts.get('host', 'autotag_instructions').at(-1).name;
assert.equal(copy, `${'x'.repeat(55)} copy`);
await view.click('instruction-duplicate');
assert.equal([...view.drafts.get('host', 'autotag_instructions').at(-1).name].length <= 60, true);
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('option clicks are one listener on the list, not one per repaint', async () => {
const view = await mounted();
try {
const list = view.byId('instruction-list');
const before = list.listeners.length;
for (let i = 0; i < 5; i += 1) await view.pick('Anime');
assert.equal(list.listeners.length, before);
assert.equal(view.byId('instruction-list').children.every(option => option.listeners.length === 0), true);
assert.equal(view.byId('instruction-name').value, 'Anime');
} finally { view.scope.dispose(); view.dom.restore(); }
});
+54 -2
View File
@@ -21,8 +21,23 @@ test('the section lists the concept rows in its order, labels and danger marks',
assert.deepEqual(MAINTENANCE_SURFACES.map(([heading]) => heading), surfaces.map(text => text.replace('&amp;', '&')));
const production = MAINTENANCE_SURFACES.flatMap(([, rows]) => rows.map(([title, description, label, , danger = false]) =>
[title, description, label, danger]));
assert.deepEqual(production, concept.map(([title, ...rest]) => [title.replace('&amp;', '&'), ...rest]));
assert.equal(production.length, 8);
// D192 (#550, beyond the concept): "Pair category and tags" follows "Unused prompt components" in Database.
const expected = concept.map(([title, ...rest]) => [title.replace('&amp;', '&'), ...rest]);
expected.splice(expected.findIndex(([title]) => title === 'Unused prompt components') + 1, 0, PAIR_ROW);
assert.deepEqual(production, expected);
assert.equal(production.length, 9);
});
const PAIR_ROW = Object.freeze(['Pair category and tags', 'Fill an empty category or tags on prompts older Pair node runs ' +
'saved, from their saved pairs. Only empty fields of never-edited prompts.', 'Review plan', false]);
test('D192: the Pair category and tags row reviews the pair_metadata plan under its title (#550)', async () => {
const { MAINTENANCE_SURFACES } = await maintenance;
const { OPERATION_TITLES } = await review;
const [, database] = MAINTENANCE_SURFACES[0];
const row = database.find(([title]) => title === PAIR_ROW[0]);
assert.deepEqual(row, [PAIR_ROW[0], PAIR_ROW[1], PAIR_ROW[2], 'pair_metadata']);
assert.equal(OPERATION_TITLES.pair_metadata, 'Pair category and tags');
});
const PLAN = Object.freeze({
@@ -210,3 +225,40 @@ test('a rotated-log cleanup the log overtook offers Review again (S12)', async (
assert.deepEqual(reviewAgainOf({ kind: 'maintenance', state: 'failed', scope: { operation: 'rotated_logs', failure: 'plan-changed' } }),
{ operation: 'rotated_logs' });
});
// #550 live test: a records operation's rows have no path, only a detail ("Prompt 12: category …").
const RECORD_JOB = Object.freeze({
id: 'a'.repeat(32), kind: 'maintenance', title: 'Pair category and tags', state: 'finished',
admission: 'records', cancellable: false, progress: 100, processed: 0, total: 0, status_line: 'Filled 2 prompts',
scope: { operation: 'pair_metadata' }, counters: { changed: 2, protected: 0, errors: 0 },
started_at: 1, finished_at: 2,
});
test('#550: path-less record rows are kept with their detail; file rows are unchanged', async () => {
const { parseDetail, filterRows } = await jobView;
const parsed = parseDetail({ ...RECORD_JOB, outcomes: { changed: [
{ path: '', root: '', root_index: null, detail: 'Prompt 2325: category “asian”; tags asian' },
{ path: '', root: '', root_index: null, detail: '' },
{ path: 'a/b.png', root: 'output', root_index: 0, detail: 'Link removed' },
] }, log: [] });
assert.deepEqual(parsed.outcomes.changed, [
{ path: '', root: '', root_index: null, detail: 'Prompt 2325: category “asian”; tags asian', record: true },
{ path: 'a/b.png', root: 'output', root_index: 0, detail: 'Link removed', record: false },
]);
// The filter reads a record's detail and a file's path.
assert.deepEqual(filterRows(parsed.outcomes.changed, 'asian').map(row => row.detail),
['Prompt 2325: category “asian”; tags asian']);
assert.deepEqual(filterRows(parsed.outcomes.changed, 'a/b').map(row => row.path), ['a/b.png']);
});
test('#550: a records operation says records, not files, for an empty group and a filter', async () => {
const { groupCopy, EMPTY_GROUP, NO_MATCH } = await jobView;
assert.deepEqual(groupCopy(RECORD_JOB), { empty: 'No records in this group.', noMatch: 'No listed record matches this filter.',
placeholder: 'Filter records…' });
for (const operation of ['duplicate_prompts', 'unused_components']) {
assert.equal(groupCopy({ ...RECORD_JOB, scope: { operation } }).empty, 'No records in this group.');
}
const files = { empty: EMPTY_GROUP, noMatch: NO_MATCH, placeholder: 'Filter by file path…' };
assert.deepEqual(groupCopy({ ...RECORD_JOB, scope: { operation: 'missing_links' } }), files);
assert.deepEqual(groupCopy({ ...RECORD_JOB, kind: 'scan', scope: {} }), files);
});
+32 -21
View File
@@ -42,6 +42,8 @@ function fakeDOM() {
this.children.push(node);
}
}
replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); }
focus() { global.document.activeElement = this; }
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; }
@@ -65,7 +67,9 @@ function fakeDOM() {
}
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.' };
autotag_character_threshold: 0.85, autotag_suggestion_limit: 10, autotag_builtin_instructions: 'Built-in tags.',
autotag_instructions: [{ id: '0123456789ab', name: 'Describe', text: 'Describe the image as tags.' }],
autotag_default_instructions: { gguf: '0123456789ab', hf: 'builtin' } };
const entry = (label, size, extra = {}) => ({ name: label, label, size_label: size, downloaded: true, loaded: false, ...extra });
const MODELS = {
success: true,
@@ -124,7 +128,8 @@ test('the section keeps the concept surface, rows, labels and actions in order',
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'"));
// D203 (#431): Saved instructions replace the concept's template field.
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);
@@ -167,27 +172,25 @@ test('controls draft their host keys; numbers as numbers, other text as typed',
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' });
'tag-suggestions': 'autotag_suggestion_limit' });
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', ' 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.' });
autotag_suggestion_limit: 15 });
// 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');
assert.equal(byId('tag-template'), undefined);
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
});
@@ -201,7 +204,8 @@ test('opening the section reads the model status once; the select waits for it',
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', 'setting-block', 'field', 'row-actions', 'operation']);
['', '', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-block', 'setting-block', 'setting-row',
'setting-row', 'small instruction-reset', 'row-actions', 'operation']);
} finally { scope.dispose(); dom.restore(); }
const failed = await mounted({ modelsFail: true });
try {
@@ -302,7 +306,9 @@ test('Preview tagging review opens the Tag image dialog with the section’s val
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 } });
options: { instructions: 'Describe the image as tags.', suggestion_limit: 10 } });
// D205 (#431): each language-model engine reads its own drafted default; Built-in's text comes from the server.
assert.deepEqual(taggingPreset({ ...HOST, autotag_model_type: 'hf' }).options.instructions, 'Built-in tags.');
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.' });
@@ -319,15 +325,17 @@ test('Preview tagging review opens the Tag image dialog with the section’s val
assert.equal(taggingPreset({ ...HOST, autotag_general_threshold: 0.1 + 0.2 }).options.general_threshold, 0.3);
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.' });
// #431: a saved entry the server would refuse holds back a language-model run, with the server's sentence.
const entries = list => ({ ...HOST, autotag_model_type: 'hf', autotag_instructions: list });
assert.deepEqual(draftProblem(entries([{ id: '0123456789ab', name: 'A', text: ' ' }])),
{ key: 'autotag_instructions', message: 'Saved instructions: entry 1 text must be non-empty text.' });
assert.deepEqual(draftProblem(entries([{ id: '0123456789ab', name: 'A', text: 'x'.repeat(4001) }])),
{ key: 'autotag_instructions', message: 'Saved instructions: entry 1 text 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(entries([{ id: '0123456789ab', name: 'A', text: '🏜'.repeat(4000) }])), null);
assert.equal(draftProblem(entries([{ id: '0123456789ab', name: 'A', text: '🏜'.repeat(4001) }])).key, 'autotag_instructions');
// A WD14 run never reads the instructions, nor a language model the thresholds.
assert.equal(draftProblem({ ...HOST, autotag_instructions: [{ id: '0123456789ab', name: '', text: '' }] }), null);
assert.equal(draftProblem({ ...HOST, autotag_model_type: 'gguf', autotag_general_threshold: 7 }), null);
const none = await mounted({ image: null });
@@ -372,9 +380,12 @@ test('the batch review grid, body and Cancel-only reasons (D134, #379, CUJ 4–5
['Settings', 'General 0.35 · Character 0.85 · up to 10 tags per image'], ['Backup', 'Create and verify before changes']]);
const llm = { ...HOST, autotag_model_type: 'gguf' };
assert.deepEqual(batchGrid({ ...PLAN, model: { type: 'gguf' }, backup: { policy: 'off' }, estimate_seconds: null,
estimate_device: null, suggestion_limit: 4 }, llm).slice(-3),
estimate_device: null, suggestion_limit: 4 }, llm).slice(-4),
[['Time', 'Language-model engines take longer per image. Activity shows the time left once tagging starts.'],
['Settings', 'Template of 27 characters · up to 4 tags per image'], ['Backup', 'No safety backup (the policy is off)']]);
['Instructions', 'Describe'], ['Settings', 'Up to 4 tags per image'], ['Backup', 'No safety backup (the policy is off)']]);
// D205 (#431): HF shows its own default by name.
assert.deepEqual(batchGrid({ ...PLAN, model: { type: 'hf' } }, { ...llm, autotag_model_type: 'hf' }).find(([term]) => term === 'Instructions'),
['Instructions', 'Built-in']);
assert.equal(batchGrid({}, HOST)[2][1], '0');
assert.equal(batchGrid({}, HOST)[6][1], 'General 0.35 · Character 0.85');
assert.equal(existingLabel('odd'), 'odd');
@@ -382,7 +393,7 @@ test('the batch review grid, body and Cancel-only reasons (D134, #379, CUJ 4–5
options: { general_threshold: 0.35, character_threshold: 0.85 } });
// #369: a threshold a hair off the grid is sent snapped to it.
assert.equal(batchBody({ ...HOST, autotag_character_threshold: 0.1 + 0.2 }).options.character_threshold, 0.3);
assert.deepEqual(batchBody(llm), { model_type: 'gguf', existing: 'skip', options: { instructions: HOST.autotag_template } });
assert.deepEqual(batchBody(llm), { model_type: 'gguf', existing: 'skip', options: { instructions: 'Describe the image as tags.' } });
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.');
+2
View File
@@ -1144,8 +1144,10 @@ class TestEnvelopes(AnnotationAPITestCase):
self.assertEqual(
sorted(await self.json_of(resp)),
[
"default_instructions", # #431 adds the saved instructions
"default_prompt",
"existing", # #328 adds the saved batch policy and model
"instructions",
"loaded_model_type",
"model_loaded",
"model_type",
+28 -2
View File
@@ -1536,7 +1536,8 @@ class TestSavedTaggingDefaults(AutotagAPITestCase):
"EXISTING",
"GENERAL_THRESHOLD",
"CHARACTER_THRESHOLD",
"TEMPLATE",
"INSTRUCTIONS",
"DEFAULT_INSTRUCTIONS",
"SUGGESTION_LIMIT",
):
self.addCleanup(setattr, AutotagConfig, attr, getattr(AutotagConfig, attr))
@@ -1557,7 +1558,11 @@ class TestSavedTaggingDefaults(AutotagAPITestCase):
self.config.EXISTING = "replace_after_review"
self.config.GENERAL_THRESHOLD = 0.4
self.config.CHARACTER_THRESHOLD = 0.7
self.config.TEMPLATE = "Saved template"
# #431: the saved engine's default instructions.
self.config.INSTRUCTIONS = (
{"id": "0123456789ab", "name": "Saved", "text": "Saved template"},
)
self.config.DEFAULT_INSTRUCTIONS = {"gguf": "0123456789ab", "hf": "builtin"}
self.config.SUGGESTION_LIMIT = 15
data = await (await self.client.get("/prompt_manager/autotag/models")).json()
self.assertEqual(
@@ -1581,6 +1586,27 @@ class TestSavedTaggingDefaults(AutotagAPITestCase):
"suggestion_limit": 15,
},
)
# Built-in first with every text, and each engine's default.
self.assertEqual(
data["instructions"],
[
{"id": "builtin", "name": "Built-in", "text": DEFAULT_TAG_TEMPLATE},
{"id": "0123456789ab", "name": "Saved", "text": "Saved template"},
],
)
self.assertEqual(
data["default_instructions"], {"gguf": "0123456789ab", "hf": "builtin"}
)
# HF reads its own default; a WD14 engine reads GGUF's.
for model_type, prompt in (
("hf", DEFAULT_TAG_TEMPLATE),
("wd14-vit", "Saved template"),
):
self.config.MODEL_TYPE = model_type
data = await (
await self.client.get("/prompt_manager/autotag/models")
).json()
self.assertEqual(data["default_prompt"], prompt, model_type)
async def test_single_without_thresholds_uses_saved_ones(self):
image = self._make_image(self.output_dir)
+181
View File
@@ -0,0 +1,181 @@
"""Pair category and tags repair through the maintenance routes (#550).
``pair_metadata`` is reviewed and applied like the other maintenance plans:
``options`` must be ``{}``, the job is titled "Pair category and tags", the
safety backup follows the ``maintenance.safety_backup`` policy, and the job's
downloadable report lists every filled prompt, never cut at 200 rows.
"""
import asyncio
import os
import sys
from unittest.mock import MagicMock
_mock_server = MagicMock()
_mock_server.PromptServer.instance.routes = MagicMock()
sys.modules.setdefault("server", _mock_server)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from py.api.jobs import MAX_OUTCOME_ROWS # noqa: E402
from py.api.maintenance import MAINTENANCE_TITLES # noqa: E402
from py.config import PromptManagerConfig # noqa: E402
try:
from tests.test_api_maintenance import WAIT, ApplyTestCase # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from test_api_maintenance import WAIT, ApplyTestCase # noqa: E402
OPERATION = "pair_metadata"
TITLE = "Pair category and tags"
class PairMetadataAPITestCase(ApplyTestCase):
def pair(self, positive, negative="", **metadata):
pair_id = self.api.db.save_prompt_pair(positive, negative, **metadata)
with self.api.db.model.get_connection() as conn:
prompt = conn.execute(
"SELECT positive_prompt_id FROM prompt_pairs WHERE id=?", (pair_id,)
).fetchone()[0]
return pair_id, prompt
def category(self, prompt_id):
with self.api.db.model.get_connection() as conn:
return conn.execute(
"SELECT category FROM prompts WHERE id=?", (prompt_id,)
).fetchone()[0]
class TestPairMetadataRoutes(PairMetadataAPITestCase):
async def test_preview_accepts_the_operation_and_no_options(self):
self.assertEqual(MAINTENANCE_TITLES[OPERATION], TITLE)
self.pair("a cat", category="portrait", tags=["a"])
plan = await self.preview(OPERATION)
self.assertEqual(plan["operation"], OPERATION)
self.assertEqual(plan["scope"]["count"], 1)
self.assertEqual(plan["backup"]["policy"], "on")
refused = await self.preview(OPERATION, 400, options={"all": True})
self.assertEqual(refused["code"], "invalid-request")
async def test_apply_fills_with_a_backup_and_names_it(self):
_pair, prompt = self.pair("a cat", "neg", category="portrait", tags=["a", "b"])
plan, summary, job = await self.run_apply(OPERATION)
self.assertEqual(summary["title"], TITLE)
self.assertEqual(summary["admission"], "records")
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(self.category(prompt), "portrait")
[backup] = self.backups()
self.assertEqual(
job.status_line,
"Filled 1 prompt from its saved pairs (1 category, 1 tag set) · "
f"Safety backup {backup}",
)
self.assertEqual(
job.outcomes["changed"][0]["detail"],
f"Prompt {prompt}: category “portrait”; tags a, b",
)
self.assertEqual(job.counters["changed"], 1)
async def test_protected_prompts_are_counted(self):
_a, prompt = self.pair("conflict", "n1", category="x")
self.pair("conflict", "n2", category="y")
self.pair("fill me", category="portrait")
plan, _summary, job = await self.run_apply(OPERATION)
self.assertEqual(plan["protected"]["reasons"], ["1 where pairs disagree"])
self.assertEqual(job.counters["protected"], 1)
self.assertIsNone(self.category(prompt))
# Live test (#550): the count comes with its rows, in the review's wording.
self.assertEqual(
job.outcomes["protected"],
[
{
"path": "",
"root": "",
"root_index": None,
"detail": (
f"Prompt {prompt}: kept, pairs disagree on category: x, y"
),
}
],
)
report = job.report()["outcomes"]["protected"]
self.assertEqual((report["count"], report["truncated"]), (1, False))
async def test_report_lists_every_kept_prompt(self):
PromptManagerConfig.SAFETY_BACKUP = False
count = MAX_OUTCOME_ROWS + 5
for n in range(count):
_pair, prompt = self.pair(f"kept {n:03d}", category="portrait")
with self.api.db.model.get_connection() as conn:
conn.execute(
"UPDATE prompts SET updated_at='2099-01-01T00:00:00+00:00' "
"WHERE id=?",
(prompt,),
)
_plan, _summary, job = await self.run_apply(OPERATION)
self.assertEqual(job.counters["protected"], count)
self.assertEqual(len(job.outcomes["protected"]), MAX_OUTCOME_ROWS)
protected = job.report()["outcomes"]["protected"]
self.assertEqual(
(len(protected["rows"]), protected["truncated"]), (count, False)
)
self.assertTrue(
protected["rows"][0]["detail"].endswith(
": kept, edited after it was saved; its empty category or tags "
"may be intentional"
)
)
async def test_policy_off_writes_without_a_backup(self):
PromptManagerConfig.SAFETY_BACKUP = False
_pair, prompt = self.pair("no backup", category="portrait")
plan, _summary, job = await self.run_apply(OPERATION)
self.assertEqual(plan["backup"]["policy"], "off")
self.assertEqual(job.state, "finished", job.log)
self.assertIn("No safety backup (the policy is off)", "\n".join(job.log))
self.assertTrue(job.status_line.endswith("· No safety backup"))
self.assertEqual(self.backups(), [])
self.assertEqual(self.category(prompt), "portrait")
async def test_reviewed_with_the_policy_on_keeps_the_backup(self):
self.pair("promise", category="portrait")
plan = await self.preview(OPERATION)
PromptManagerConfig.SAFETY_BACKUP = False
body = await self.apply(plan["token"])
job = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertEqual(len(self.backups()), 1)
async def test_plan_changed_writes_nothing(self):
pair_id, prompt = self.pair("drift", category="portrait")
plan = await self.preview(OPERATION)
self.api.db.update_prompt_pair_metadata(pair_id, category="landscape")
body = await self.apply(plan["token"])
job = self.api._jobs.get(body["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertEqual(job.state, "failed")
self.assertEqual(job.last_event["code"], "plan-changed")
self.assertIsNone(self.category(prompt))
async def test_report_lists_every_filled_prompt(self):
PromptManagerConfig.SAFETY_BACKUP = False
count = MAX_OUTCOME_ROWS + 5
for n in range(count):
self.pair(f"bulk {n:03d}", category="portrait")
_plan, _summary, job = await self.run_apply(OPERATION)
self.assertEqual(job.state, "finished", job.log)
self.assertEqual(len(job.outcomes["changed"]), MAX_OUTCOME_ROWS)
response = await self.client.request(
"GET", f"/prompt_manager/jobs/{job.id}/report"
)
self.assertEqual(response.status, 200)
report = await response.json()
changed = report["outcomes"]["changed"]
self.assertEqual(changed["count"], count)
self.assertEqual(len(changed["rows"]), count)
self.assertFalse(changed["truncated"])
self.assertTrue(
all(
row["detail"].endswith("category “portrait”") for row in changed["rows"]
)
)
+24 -1
View File
@@ -158,7 +158,30 @@ class TestLegacyBatch(LegacyTestCase):
self.image("b.png", (2, 2, 2))
await self.relist()
await self.legacy("model_type=gguf&prompt=%20")
self.assertEqual(self.tagger.calls[-1]["prompt"], AutotagConfig.TEMPLATE)
self.assertEqual(
self.tagger.calls[-1]["prompt"],
AutotagConfig.default_instructions_text("gguf"),
)
# #431: a blank prompt falls back to the request engine's own default.
saved = (AutotagConfig.INSTRUCTIONS, AutotagConfig.DEFAULT_INSTRUCTIONS)
self.addCleanup(
lambda: (
setattr(AutotagConfig, "INSTRUCTIONS", saved[0]),
setattr(AutotagConfig, "DEFAULT_INSTRUCTIONS", saved[1]),
)
)
AutotagConfig.INSTRUCTIONS = (
{"id": "0123456789ab", "name": "Photo", "text": "Photo tags."},
)
AutotagConfig.DEFAULT_INSTRUCTIONS = {"gguf": "builtin", "hf": "0123456789ab"}
for model_type, expected in (
("hf", "Photo tags."),
("gguf", AutotagConfig.default_instructions_text("gguf")),
):
self.image(f"{model_type}.png", (3, 3, len(model_type)))
await self.relist()
await self.legacy(f"model_type={model_type}&prompt=%20&skip_tagged=false")
self.assertEqual(self.tagger.calls[-1]["prompt"], expected, model_type)
self.assertEqual(self.tagger.custom_prompt, "legacy prompt")
async def test_a_missing_backend_package_is_one_error_frame(self):
+13 -11
View File
@@ -442,9 +442,11 @@ class TestAutotagConfig(unittest.TestCase):
"EXISTING",
"GENERAL_THRESHOLD",
"CHARACTER_THRESHOLD",
"TEMPLATE",
"SUGGESTION_LIMIT",
"SKIPPED_TAGS",
"INSTRUCTIONS",
"DEFAULT_INSTRUCTIONS",
"_LEGACY_TEMPLATE",
)
}
@@ -489,16 +491,13 @@ class TestAutotagConfig(unittest.TestCase):
"character_threshold": 0.85,
"suggestion_limit": 10,
"skipped_tags": [],
"instructions": [],
"default_instructions": {"gguf": "builtin", "hf": "builtin"},
}
},
)
# The built-in template is not written; a saved one is.
self.config_class.TEMPLATE = "Saved."
self.assertEqual(
PromptManagerConfig.get_config()["autotag"]["defaults"]["template"],
"Saved.",
)
self.config_class.TEMPLATE = config_module.DEFAULT_TAG_TEMPLATE
# #431: the old template key is written only as the GGUF default's
# mirror (test_saved_instructions covers it).
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "config.json")
PromptManagerConfig.update_config(
@@ -526,7 +525,6 @@ class TestAutotagConfig(unittest.TestCase):
"existing": "merge",
"general_threshold": 0.4,
"character_threshold": 0.7,
"template": "Tags only.",
"suggestion_limit": 15,
"skipped_tags": ("blurry", "closed eyes"),
}
@@ -535,7 +533,6 @@ class TestAutotagConfig(unittest.TestCase):
"existing": "skip",
"general_threshold": 0.35,
"character_threshold": 0.85,
"template": config_module.DEFAULT_TAG_TEMPLATE,
"suggestion_limit": 10,
"skipped_tags": (),
}
@@ -569,7 +566,12 @@ class TestAutotagConfig(unittest.TestCase):
k: getattr(self.config_class, attr)
for k, (attr, _v, _d) in self.config_class._KEYS.items()
}
self.assertEqual(loaded, dict(good, **{key: fallback[key]}))
# The old template is no attribute any more (#431): a bad
# one is only warned about and imports nothing.
expected = (
dict(good, **{key: fallback[key]}) if key in fallback else good
)
self.assertEqual(loaded, expected)
def test_skipped_tags_load_trimmed_and_warn_once_when_damaged(self):
"""#431: the list is kept as typed (trimmed); a damaged list is
+16
View File
@@ -490,6 +490,22 @@ class TestJobOutcomesAndLog(unittest.TestCase):
with self.assertRaises(ValueError):
job.record_counted("bogus", 1)
def test_record_counted_complete_keeps_every_row_for_the_report(self):
# #550: a repair's record of each change is never cut at the cap.
job = Job("maintenance", "Pair category and tags", groups=("changed", "errors"))
rows = [
{"path": "", "detail": f"Prompt {i}"} for i in range(MAX_OUTCOME_ROWS + 5)
]
job.record_counted("changed", len(rows), rows, complete=True)
self.assertEqual(len(job.outcomes["changed"]), MAX_OUTCOME_ROWS)
report = job.report()["outcomes"]
self.assertEqual(len(report["changed"]["rows"]), MAX_OUTCOME_ROWS + 5)
self.assertEqual(
report["changed"]["rows"][-1]["detail"], f"Prompt {MAX_OUTCOME_ROWS + 4}"
)
self.assertFalse(report["changed"]["truncated"])
self.assertEqual(report["errors"], {"count": 0, "rows": [], "truncated": False})
def test_thumbnail_job_groups(self):
self.assertEqual(
jobs_module.THUMBNAIL_GROUP_NAMES, ("generated", "skipped", "errors")
+539
View File
@@ -0,0 +1,539 @@
"""Pair category and tags repair (#550): the ``pair_metadata`` maintenance plan.
Before #546 a Pair node run saved its category and tags on the saved pair
only, so the positive prompt it created stayed Uncategorized and untagged.
``pair_metadata`` reviews those prompts and fills only their empty fields
from the prompt's non-archived pairs when every pair with a value agrees.
It never touches a negative, a saved pair or ``updated_at``, and a prompt
the Pair node did not create, that was edited, archived, or whose pairs
disagree, is kept (``550-design.md``).
"""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database import maintenance # noqa: E402
from database import pair_metadata_repair as repair # noqa: E402
try:
from tests.test_maintenance_plans import MaintenanceDatabaseCase # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from test_maintenance_plans import MaintenanceDatabaseCase # noqa: E402
OPERATION = "pair_metadata"
class PairMetadataCase(MaintenanceDatabaseCase):
def pair(self, positive, negative="", **metadata):
"""A pre-#546 Pair save: the pair gets the metadata, the prompt none."""
pair_id = self.db.save_prompt_pair(positive, negative, **metadata)
return pair_id, self.positive_of(pair_id)
def positive_of(self, pair_id):
return self.sql(
"SELECT positive_prompt_id FROM prompt_pairs WHERE id=?", pair_id
)[0][0]
def sql(self, statement, *args):
with self.db.model.get_connection() as conn:
return [tuple(row) for row in conn.execute(statement, args)]
def run_sql(self, statement, *args):
with self.db.model.get_connection() as conn:
conn.execute(statement, args)
def category(self, prompt_id):
return self.sql("SELECT category FROM prompts WHERE id=?", prompt_id)[0][0]
def tags(self, prompt_id):
return sorted(
row[0]
for row in self.sql(
"SELECT t.name FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
"WHERE pt.prompt_id=?",
prompt_id,
)
)
def preview(self, operation=OPERATION, roots=None):
return self.db.preview_maintenance(operation, [])
def apply(self, draft):
return self.db.apply_maintenance(
draft.operation, draft.fingerprint, draft.targets, []
)
def edit_stamp(self, prompt_id, updated_at="2099-01-01T00:00:00+00:00"):
self.run_sql(
"UPDATE prompts SET updated_at=? WHERE id=?", updated_at, prompt_id
)
def details(self, draft):
return [(row["label"], row["detail"]) for row in draft.details]
class TestClassification(PairMetadataCase):
def test_fills_empty_category_and_tags_from_the_pair(self):
pair_id, prompt = self.pair(
"a cat on a sofa", "blurry", category="portrait", tags=["b", "a"]
)
draft = self.preview()
self.assertEqual(draft.operation, OPERATION)
self.assertEqual(draft.targets, [[prompt, True, True]])
self.assertEqual(draft.scope["count"], 1)
self.assertEqual(
draft.scope["label"], "1 prompt to fill: 1 category, 1 tag set"
)
self.assertEqual(draft.protected, {"count": 0, "reasons": []})
self.assertEqual(
draft.media,
{
"deletes_files": False,
"label": "No image file or saved pair is changed.",
},
)
self.assertIn(
(
"a cat on a sofa",
f"Fills category “portrait” and 2 tags: a, b (pair {pair_id})",
),
self.details(draft),
)
self.assertEqual(draft.details[-1]["label"], "Record")
self.assertEqual(draft.details[-1]["detail"], repair.RECORD_NOTE)
def test_empty_plan(self):
self.save("plain prompt")
draft = self.preview()
self.assertEqual(draft.targets, [])
self.assertEqual(draft.scope["count"], 0)
self.assertEqual(
draft.scope["label"], "No Pair prompts are missing a category or tags"
)
self.assertEqual(draft.details, [])
def test_prompt_already_complete_is_not_counted(self):
_pair, prompt = self.pair("done", category="portrait", tags=["a"])
self.run_sql("UPDATE prompts SET category='portrait' WHERE id=?", prompt)
self.db.set_prompt_tags(prompt, ["a"])
self.edit_stamp(
prompt, self.sql("SELECT created_at FROM prompts WHERE id=?", prompt)[0][0]
)
self.assertEqual(self.preview().scope["count"], 0)
def test_whitespace_category_is_blank_on_prompt_and_pair(self):
_p1, blank_prompt = self.pair("ws prompt", category=" portrait ")
self.run_sql("UPDATE prompts SET category=' ' WHERE id=?", blank_prompt)
_p2, blank_pair = self.pair("ws pair", category=" ")
draft = self.preview()
self.assertEqual(draft.targets, [[blank_prompt, True, False]])
self.assertIn("“portrait”", self.details(draft)[0][1])
self.assertNotIn(blank_pair, [t[0] for t in draft.targets])
def test_mixed_fields_category_fills_while_tags_conflict(self):
_a, prompt = self.pair("mixed", "n1", category="portrait", tags=["a"])
self.pair("mixed", "n2", category="portrait", tags=["b"])
draft = self.preview()
self.assertEqual(draft.targets, [[prompt, True, False]])
self.assertEqual(draft.scope["count"], 1)
self.assertEqual(draft.protected["count"], 1)
self.assertEqual(draft.protected["reasons"], ["1 where pairs disagree"])
self.assertIn(("mixed", "Kept: pairs disagree on tags"), self.details(draft))
def test_mixed_fields_tags_fill_while_category_conflicts(self):
_a, prompt = self.pair("mixed2", "n1", category="portrait", tags=["a"])
self.pair("mixed2", "n2", category="landscape", tags=["a"])
draft = self.preview()
self.assertEqual(draft.targets, [[prompt, False, True]])
self.assertIn(
("mixed2", "Kept: pairs disagree on category: landscape, portrait"),
self.details(draft),
)
def test_category_set_and_tags_empty_fills_tags_only(self):
_a, prompt = self.pair("has cat", category="portrait", tags=["a"])
self.run_sql("UPDATE prompts SET category='mine' WHERE id=?", prompt)
draft = self.preview()
self.assertEqual(draft.targets, [[prompt, False, True]])
def test_pairs_without_a_value_are_ignored(self):
_a, prompt = self.pair("partial", "n1", category="portrait", tags=["a"])
self.pair("partial", "n2") # no category, no tags
self.assertEqual(self.preview().targets, [[prompt, True, True]])
def test_equal_values_across_pairs_fill(self):
_a, prompt = self.pair("same", "n1", category="portrait", tags=["a", "b"])
self.pair("same", "n2", category="portrait", tags=["b", "a"])
self.assertEqual(self.preview().targets, [[prompt, True, True]])
def test_category_is_case_sensitive(self):
_a, prompt = self.pair("case", "n1", category="Portrait")
self.pair("case", "n2", category="portrait")
draft = self.preview()
self.assertEqual(draft.targets, [])
self.assertEqual(draft.protected["count"], 1)
def test_edited_prompt_is_protected(self):
cases = {
"later": "2099-01-01T00:00:00+00:00",
"null": None,
"legacy": "2020-01-01 10:00:00",
"garbage": "yesterday",
}
for name, stamp in cases.items():
with self.subTest(name=name):
_a, prompt = self.pair(f"edited {name}", category="portrait")
self.edit_stamp(prompt, stamp)
draft = self.preview()
self.assertNotIn(prompt, [t[0] for t in draft.targets])
self.assertIn(
(
f"edited {name}",
"Kept: edited after it was saved; its empty category or "
"tags may be intentional",
),
self.details(draft),
)
self.run_sql(
"DELETE FROM prompt_pairs WHERE positive_prompt_id=?", prompt
)
def test_first_release_stamps_microseconds_apart_are_never_edited(self):
_a, prompt = self.pair("caa9649f", category="portrait")
self.run_sql(
"UPDATE prompts SET created_at=?, updated_at=? WHERE id=?",
"2026-10-18T10:00:00.000100+00:00",
"2026-10-18T10:00:00.000180+00:00",
prompt,
)
self.run_sql(
"UPDATE prompt_pairs SET created_at=? WHERE positive_prompt_id=?",
"2026-10-18T10:00:00.000400+00:00",
prompt,
)
self.assertEqual(self.preview().targets, [[prompt, True, False]])
def test_edited_beats_conflict(self):
_a, prompt = self.pair("both", "n1", category="portrait")
self.pair("both", "n2", category="landscape")
self.edit_stamp(prompt)
draft = self.preview()
self.assertEqual(draft.protected["reasons"], ["1 edited after it was saved"])
def test_archived_prompt_is_protected(self):
_a, prompt = self.pair("archived", category="portrait")
self.run_sql(
"INSERT INTO prompt_archive(prompt_id, archived_at) VALUES(?, ?)",
prompt,
"2026-10-31T00:00:00+00:00",
)
draft = self.preview()
self.assertEqual(draft.targets, [])
self.assertEqual(draft.protected["reasons"], ["1 archived in the Library"])
self.assertIn(
("archived", "Kept: archived in the Library"), self.details(draft)
)
def test_archived_pairs_are_no_source(self):
pair_id, prompt = self.pair("old pair", "n1", category="portrait")
self.db.archive_prompt_pair(pair_id)
draft = self.preview()
self.assertEqual(draft.targets, [])
self.assertEqual(draft.protected["count"], 0)
# An archived pair also never disagrees with a live one.
self.pair("old pair", "n2", category="landscape")
self.assertEqual(self.preview().targets, [[prompt, True, False]])
self.assertIn("landscape", self.details(self.preview())[0][1])
def test_prompt_not_created_by_its_pair_is_out_of_scope(self):
prompt = self.save("saved by the node first")
self.run_sql(
"UPDATE prompts SET created_at=?, updated_at=? WHERE id=?",
"2026-01-01T00:00:00+00:00",
"2026-01-01T00:00:00+00:00",
prompt,
)
self.db.save_prompt_pair("saved by the node first", category="portrait")
draft = self.preview()
self.assertEqual(draft.targets, [])
self.assertEqual(draft.protected["count"], 0)
def test_negative_is_never_a_target_and_positive_role_sources_only(self):
_a, both = self.pair("dual", "shared negative", category="portrait")
neg = self.sql("SELECT id FROM prompts WHERE text='shared negative'")[0][0]
# The negative of pair A is the positive of pair B, made later: not
# Pair-created as a positive, so out of scope.
self.db.save_prompt_pair("shared negative", category="landscape")
draft = self.preview()
self.assertEqual(draft.targets, [[both, True, False]])
self.assertNotIn(neg, [t[0] for t in draft.targets])
def test_positive_first_then_negative_is_filled_from_positive_pairs_only(self):
_a, prompt = self.pair("pos first", category="portrait")
self.db.save_prompt_pair("another", "pos first", category="landscape")
draft = self.preview()
self.assertIn([prompt, True, False], draft.targets)
self.assertIn("“portrait”", dict(self.details(draft))["pos first"])
def test_more_than_twenty_tags_is_protected(self):
tags = [f"t{n:02d}" for n in range(21)]
_a, prompt = self.pair("many tags", category="portrait", tags=tags)
draft = self.preview()
self.assertEqual(draft.targets, [[prompt, True, False]])
self.assertEqual(
draft.protected["reasons"], ["1 whose pair has more than 20 tags"]
)
self.assertIn(
("many tags", "Kept: its pair has more than 20 tags"), self.details(draft)
)
def test_protected_rows_come_first_within_fifty(self):
_c, conflict = self.pair("zz conflict", "n1", category="x")
self.pair("zz conflict", "n2", category="y")
for n in range(60):
self.pair(f"fill {n:02d}", category="portrait")
draft = self.preview()
self.assertEqual(len(draft.details), maintenance.MAX_DETAILS)
self.assertEqual(draft.details[0]["label"], "zz conflict")
self.assertEqual(draft.details[-1]["label"], "Record")
self.assertEqual(draft.scope["count"], 60)
def test_detail_rows_list_conflicts_before_other_kept_prompts(self):
_a, archived = self.pair("aa archived", category="portrait")
self.run_sql(
"INSERT INTO prompt_archive(prompt_id, archived_at) VALUES(?, ?)",
archived,
"2026-10-31T00:00:00+00:00",
)
_b, edited = self.pair("bb edited", category="portrait")
self.edit_stamp(edited)
self.pair("zz conflict", "n1", category="x")
self.pair("zz conflict", "n2", category="y")
draft = self.preview()
self.assertEqual(
[label for label, _detail in self.details(draft)],
["zz conflict", "bb edited", "aa archived"],
)
# The reasons keep the precedence order.
self.assertEqual(
draft.protected["reasons"],
[
"1 archived in the Library",
"1 edited after it was saved",
"1 where pairs disagree",
],
)
def test_same_library_same_fingerprint_and_pair_use_does_not_change_it(self):
pair_id, _prompt = self.pair("fp", category="portrait", tags=["a"])
first = self.preview().fingerprint
self.db.record_prompt_pair_use(pair_id)
self.db.update_prompt_pair_metadata(pair_id, notes="a note", title="t")
self.assertEqual(self.preview().fingerprint, first)
class TestPreviewWritesNothing(PairMetadataCase):
def test_preview_reads_one_read_only_snapshot(self):
self.pair("cat", category="portrait", tags=["a"])
before = self.sql("SELECT * FROM prompts ORDER BY id")
tags = self.sql("SELECT * FROM prompt_tags ORDER BY prompt_id, tag_id")
connect = maintenance.sqlite3.connect
opened = []
def spy(*args, **kwargs):
opened.append(args[0])
return connect(*args, **kwargs)
with patch.object(maintenance.sqlite3, "connect", side_effect=spy):
self.preview()
self.assertEqual(len(opened), 1)
self.assertTrue(str(opened[0]).endswith("?mode=ro"))
self.assertEqual(self.sql("SELECT * FROM prompts ORDER BY id"), before)
self.assertEqual(
self.sql("SELECT * FROM prompt_tags ORDER BY prompt_id, tag_id"), tags
)
class TestApply(PairMetadataCase):
def snapshot_pairs(self):
return (
self.sql("SELECT * FROM prompt_pairs ORDER BY id"),
self.sql("SELECT * FROM prompt_pair_tags ORDER BY pair_id, tag_id"),
)
def test_apply_fills_only_empty_fields_and_leaves_pairs_alone(self):
_a, prompt = self.pair("apply me", "neg", category="portrait", tags=["a", "b"])
_b, partial = self.pair("partial", "n1", category="portrait", tags=["a"])
self.pair("partial", "n2", category="portrait", tags=["c"])
neg = self.sql("SELECT id FROM prompts WHERE text='neg'")[0][0]
pairs = self.snapshot_pairs()
stamps = self.sql("SELECT id, created_at, updated_at FROM prompts ORDER BY id")
state = self.sql("SELECT * FROM prompt_library_state ORDER BY prompt_id")
tag_rows = self.sql("SELECT id, name FROM tags ORDER BY id")
draft = self.preview()
result = self.apply(draft)
self.assertEqual(result["changed"], 2)
self.assertEqual(
result["rows"],
[
{"prompt_id": prompt, "category": "portrait", "tags": ["a", "b"]},
{"prompt_id": partial, "category": "portrait"},
],
)
self.assertEqual(result["categories"], 2)
self.assertEqual(result["tag_sets"], 1)
self.assertEqual(
(self.category(prompt), self.tags(prompt)), ("portrait", ["a", "b"])
)
self.assertEqual((self.category(partial), self.tags(partial)), ("portrait", []))
self.assertEqual((self.category(neg), self.tags(neg)), (None, []))
self.assertEqual(self.snapshot_pairs(), pairs)
self.assertEqual(
self.sql("SELECT id, created_at, updated_at FROM prompts ORDER BY id"),
stamps,
)
self.assertEqual(
self.sql("SELECT * FROM prompt_library_state ORDER BY prompt_id"), state
)
self.assertEqual(self.sql("SELECT id, name FROM tags ORDER BY id"), tag_rows)
def test_apply_returns_one_kept_row_per_protected_prompt(self):
_a, conflict = self.pair("conflict", "n1", category="asian")
self.pair("conflict", "n2", category="Adult")
_b, edited = self.pair("edited", category="portrait")
self.edit_stamp(edited)
_c, both = self.pair(
"both", "n1", category="x", tags=[f"t{n}" for n in range(21)]
)
self.pair("both", "n2", category="y", tags=[f"t{n}" for n in range(21)])
_d, filled = self.pair("fill", category="portrait")
draft = self.preview()
result = self.apply(draft)
self.assertEqual(
result["kept"],
[
{
"prompt_id": conflict,
"reasons": ["pairs disagree on category: Adult, asian"],
},
{
"prompt_id": edited,
"reasons": [
"edited after it was saved; its empty category or tags "
"may be intentional"
],
},
{
"prompt_id": both,
"reasons": [
"pairs disagree on category: x, y",
"its pair has more than 20 tags",
],
},
],
)
self.assertEqual(len(result["kept"]), draft.protected["count"])
self.assertEqual(self.category(filled), "portrait")
def test_kept_rows_are_never_capped(self):
for n in range(205):
_p, prompt = self.pair(f"kept {n:03d}", category="portrait")
self.edit_stamp(prompt)
result = self.apply(self.preview())
self.assertEqual(result["changed"], 0)
self.assertEqual(len(result["kept"]), 205)
def test_second_run_finds_nothing_and_a_partial_fill_completes_later(self):
_a, prompt = self.pair("partial", "n1", category="portrait", tags=["a"])
p2, _same = self.pair("partial", "n2", category="portrait", tags=["c"])
self.apply(self.preview())
again = self.preview()
self.assertEqual(again.scope["count"], 0)
self.assertEqual(again.protected["count"], 1) # tags still disagree
self.db.update_prompt_pair_metadata(p2, tags=["a"])
self.apply(self.preview())
self.assertEqual(self.tags(prompt), ["a"])
self.assertEqual(self.preview().protected["count"], 0)
def test_drift_raises_plan_changed_and_writes_nothing(self):
def pair_category(ids):
self.db.update_prompt_pair_metadata(ids[0], category="landscape")
def prompt_category(ids):
self.run_sql("UPDATE prompts SET category='mine' WHERE id=?", ids[1])
def new_pair(ids):
self.db.save_prompt_pair("drift", "other neg", category="landscape")
def tag_rename(ids):
tag = self.sql("SELECT id FROM tags WHERE name='a'")[0][0]
self.run_sql("UPDATE tags SET name='renamed' WHERE id=?", tag)
def edited(ids):
self.edit_stamp(ids[1])
for change in (pair_category, prompt_category, new_pair, tag_rename, edited):
with self.subTest(change=change.__name__):
ids = self.pair("drift", "n", category="portrait", tags=["a"])
draft = self.preview()
change(ids)
before = self.sql("SELECT id, category FROM prompts ORDER BY id")
tags = self.sql("SELECT * FROM prompt_tags ORDER BY prompt_id")
with self.assertRaises(maintenance.PlanChanged):
self.apply(draft)
self.assertEqual(
self.sql("SELECT id, category FROM prompts ORDER BY id"), before
)
self.assertEqual(
self.sql("SELECT * FROM prompt_tags ORDER BY prompt_id"), tags
)
self.run_sql("DELETE FROM prompt_pairs")
self.run_sql("DELETE FROM prompts")
self.run_sql("DELETE FROM tags")
def test_pair_run_between_preview_and_apply_still_applies(self):
pair_id, prompt = self.pair("busy", category="portrait")
draft = self.preview()
self.db.record_prompt_pair_use(pair_id)
self.apply(draft)
self.assertEqual(self.category(prompt), "portrait")
def test_guard_failure_rolls_back_everything(self):
_a, first = self.pair("first", category="portrait")
_b, second = self.pair("second", category="portrait")
draft = self.preview()
real = repair._fill_categories
def racing(conn, fills):
# Another writer's value appears inside the transaction.
conn.execute("UPDATE prompts SET category='x' WHERE id=?", (second,))
return real(conn, fills)
with patch.object(repair, "_fill_categories", side_effect=racing):
with self.assertRaises(maintenance.PlanChanged):
self.apply(draft)
self.assertIsNone(self.category(first))
self.assertIsNone(self.category(second))
def test_more_than_two_hundred_fills_are_all_returned(self):
for n in range(205):
self.pair(f"bulk {n:03d}", category="portrait")
result = self.apply(self.preview())
self.assertEqual(result["changed"], 205)
self.assertEqual(len(result["rows"]), 205)
def test_batches_bind_few_ids(self):
for n in range(5):
self.pair(f"batch {n}", category="portrait", tags=["a"])
with patch.object(maintenance, "ID_BATCH", 2):
draft = self.preview()
result = self.apply(draft)
self.assertEqual(result["changed"], 5)
if __name__ == "__main__":
unittest.main()
+379
View File
@@ -0,0 +1,379 @@
"""Saved instructions with a default per language-model engine (#431, PR 2).
The entry and default validation, the in-memory migration of the old single
``autotag.defaults.template`` at load (first upgrade and re-upgrade), the
load fallbacks, and ``AutotagConfig.default_instructions_text``.
"""
import hashlib
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import MagicMock
# config.py imports ComfyUI's server module; stand it in (as test_config does).
_mock_server = MagicMock()
_mock_server.PromptServer.instance.routes = MagicMock()
sys.modules.setdefault("server", _mock_server)
from py.config import AutotagConfig, PromptManagerConfig # noqa: E402
from py.tagging_defaults import ( # noqa: E402
BUILTIN_INSTRUCTIONS_ID,
DEFAULT_TAG_TEMPLATE,
MAX_SAVED_INSTRUCTIONS,
validate_default_instructions,
validate_instructions,
)
ATTRS = ("MODEL_TYPE", "INSTRUCTIONS", "DEFAULT_INSTRUCTIONS", "_LEGACY_TEMPLATE")
def entry(id_, name, text="Tags only."):
return {"id": id_, "name": name, "text": text}
def sha12(text):
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
class TestValidation(unittest.TestCase):
def test_entries_are_kept_with_trimmed_names(self):
self.assertEqual(
validate_instructions([entry("0123456789ab", " Photo ", "Tags.")]),
[entry("0123456789ab", "Photo", "Tags.")],
)
self.assertEqual(validate_instructions([]), [])
def test_every_refusal_sentence(self):
ok = entry("0123456789ab", "One")
cases = (
("x", "Saved instructions must be a list."),
(
[
entry(f"{i:012x}", f"n{i}")
for i in range(MAX_SAVED_INSTRUCTIONS + 1)
],
"Saved instructions: at most 20 entries besides Built-in.",
),
([ok, "x"], "Saved instructions: entry 2 has an invalid id."),
(
[entry("ABCDEF012345", "A")],
"Saved instructions: entry 1 has an invalid id.",
),
([entry("builtin", "A")], "Saved instructions: entry 1 has an invalid id."),
(
[{"id": "0123456789ab", "name": "A"}],
"Saved instructions: entry 1 has an invalid id.",
),
(
[ok, entry("0123456789ab", "Two")],
"Saved instructions: entry 2 repeats the id of entry 1.",
),
(
[entry("0123456789ab", " ")],
"Saved instructions: entry 1 needs a name.",
),
(
[entry("0123456789ab", "x" * 61)],
"Saved instructions: entry 1 name must be at most 60 characters.",
),
(
[entry("0123456789ab", "a\nb")],
"Saved instructions: entry 1 name must not contain a line break.",
),
(
[ok, entry("0123456789ac", "one")],
"Saved instructions: entry 2 name “one” is already used.",
),
(
[entry("0123456789ab", "BUILT-IN")],
"Saved instructions: the name “Built-in” is reserved.",
),
(
[entry("0123456789ab", "A", " ")],
"Saved instructions: entry 1 text must be non-empty text.",
),
(
[entry("0123456789ab", "A", "x" * 4001)],
"Saved instructions: entry 1 text must be at most 4000 characters.",
),
)
for value, message in cases:
with self.subTest(message=message):
with self.assertRaises(ValueError) as caught:
validate_instructions(value)
self.assertEqual(str(caught.exception), message)
def test_defaults_name_built_in_or_a_saved_entry(self):
saved = [entry("0123456789ab", "One")]
self.assertEqual(
validate_default_instructions(
{"gguf": "builtin", "hf": "0123456789ab"}, saved
),
{"gguf": "builtin", "hf": "0123456789ab"},
)
for value, message in (
(
{"gguf": "ffffffffffff", "hf": "builtin"},
"Default for GGUF JoyCaption must be one of the saved instructions.",
),
(
{"gguf": "builtin", "hf": "nope"},
"Default for HuggingFace JoyCaption must be one of the saved "
"instructions.",
),
(
{"gguf": "builtin"},
"Default instructions must name GGUF and HuggingFace.",
),
(
{"gguf": [], "hf": "builtin"},
"Default for GGUF JoyCaption must be one of the saved instructions.",
),
("builtin", "Default instructions must name GGUF and HuggingFace."),
):
with self.subTest(value=value):
with self.assertRaises(ValueError) as caught:
validate_default_instructions(value, saved)
self.assertEqual(str(caught.exception), message)
class ConfigTestCase(unittest.TestCase):
def setUp(self):
saved = {attr: getattr(AutotagConfig, attr) for attr in ATTRS}
self.addCleanup(
lambda: [setattr(AutotagConfig, a, v) for a, v in saved.items()]
)
AutotagConfig.update_config({"defaults": {}})
def load(self, defaults, warnings=0):
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "config.json")
with open(path, "w", encoding="utf-8") as handle:
json.dump({"autotag": {"defaults": defaults}}, handle)
before = os.stat(path).st_mtime_ns, open(path, "rb").read()
if warnings:
with self.assertLogs("prompt_manager.config", level="WARNING") as logs:
PromptManagerConfig.load_from_file(path)
self.assertEqual(len(logs.records), warnings, logs.output)
self.logs = [record.getMessage() for record in logs.records]
else:
PromptManagerConfig.load_from_file(path)
# Nothing is written at load.
self.assertEqual(
(os.stat(path).st_mtime_ns, open(path, "rb").read()), before
)
def state(self):
return [dict(e) for e in AutotagConfig.INSTRUCTIONS], dict(
AutotagConfig.DEFAULT_INSTRUCTIONS
)
class TestLoad(ConfigTestCase):
def test_a_new_install_has_built_in_only_for_both_engines(self):
self.load({})
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
self.assertEqual(
AutotagConfig.default_instructions_text("gguf"), DEFAULT_TAG_TEMPLATE
)
self.assertEqual(
AutotagConfig.all_instructions()[0],
{
"id": BUILTIN_INSTRUCTIONS_ID,
"name": "Built-in",
"text": DEFAULT_TAG_TEMPLATE,
},
)
def test_saved_entries_and_per_engine_defaults_load(self):
saved = [
entry("0123456789ab", "Photo", "Photo tags."),
entry("0123456789ac", "Anime", "Anime tags."),
]
self.load(
{
"instructions": saved,
"default_instructions": {"gguf": "0123456789ac", "hf": "0123456789ab"},
}
)
self.assertEqual(
self.state(), (saved, {"gguf": "0123456789ac", "hf": "0123456789ab"})
)
self.assertEqual(AutotagConfig.default_instructions_text("gguf"), "Anime tags.")
self.assertEqual(AutotagConfig.default_instructions_text("hf"), "Photo tags.")
with self.assertRaises(ValueError):
AutotagConfig.default_instructions_text("wd14-vit")
def test_a_dangling_default_falls_back_to_built_in_with_a_warning(self):
saved = [entry("0123456789ab", "Photo")]
self.load(
{
"instructions": saved,
"default_instructions": {"gguf": "ffffffffffff", "hf": "0123456789ab"},
},
warnings=1,
)
self.assertEqual(
self.state(), (saved, {"gguf": "builtin", "hf": "0123456789ab"})
)
self.assertEqual(
self.logs,
[
"autotag.defaults.default_instructions.gguf names no saved "
"instructions; using Built-in"
],
)
def test_a_damaged_list_falls_back_to_built_in_only_and_the_defaults_follow(self):
self.load(
{
"instructions": [
entry("0123456789ab", "A"),
entry("0123456789ab", "B"),
],
"default_instructions": {"gguf": "0123456789ab", "hf": "builtin"},
},
warnings=1, # one warning; the defaults follow the list silently
)
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
self.assertEqual(
self.logs[0],
"autotag.defaults.instructions entry 2 repeats the id of entry 1; "
"using Built-in only",
)
def test_damaged_defaults_fall_back_with_a_warning(self):
self.load({"default_instructions": "builtin"}, warnings=1)
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
class TestMigration(ConfigTestCase):
CUSTOM = "My own tags, please."
def test_first_upgrade_makes_custom_template_the_default_for_both(self):
self.load({"template": self.CUSTOM})
self.assertEqual(
self.state(),
(
[entry(sha12(self.CUSTOM), "Custom template", self.CUSTOM)],
{"gguf": sha12(self.CUSTOM), "hf": sha12(self.CUSTOM)},
),
)
# Deterministic: a second load gives the same ids.
first = self.state()
self.load({"template": self.CUSTOM})
self.assertEqual(self.state(), first)
def test_a_template_equal_to_built_in_creates_no_entry(self):
self.load({"template": DEFAULT_TAG_TEMPLATE})
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
def test_an_invalid_template_is_ignored_with_todays_warning(self):
self.load({"template": " "}, warnings=1)
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
self.assertIn("autotag.defaults.template", self.logs[0])
def test_a_stored_default_is_never_overwritten_by_the_first_upgrade(self):
self.load(
{
"template": self.CUSTOM,
"default_instructions": {"gguf": "builtin", "hf": "builtin"},
},
warnings=0,
)
self.assertEqual(
self.state(),
(
[entry(sha12(self.CUSTOM), "Custom template", self.CUSTOM)],
{"gguf": "builtin", "hf": "builtin"},
),
)
def test_the_mirror_of_a_saved_default_imports_nothing(self):
saved = [entry(sha12(self.CUSTOM), "Custom template", self.CUSTOM)]
both = {"gguf": sha12(self.CUSTOM), "hf": "builtin"}
self.load(
{
"template": self.CUSTOM,
"instructions": saved,
"default_instructions": both,
}
)
self.assertEqual(self.state(), (saved, both))
def test_re_upgrade_imports_an_edit_made_on_an_older_build(self):
saved = [entry(sha12(self.CUSTOM), "Custom template", "Edited since.")]
both = {"gguf": sha12(self.CUSTOM), "hf": "builtin"}
with self.assertLogs("prompt_manager.config", level="INFO") as logs:
self.load(
{
"template": self.CUSTOM,
"instructions": saved,
"default_instructions": both,
}
)
instructions, defaults = self.state()
# The text's own hash is taken by the edited entry: the next free one.
imported = hashlib.sha256((self.CUSTOM + "\n2").encode()).hexdigest()[:12]
self.assertEqual(
instructions, saved + [entry(imported, "Custom template (2)", self.CUSTOM)]
)
self.assertEqual(defaults, both)
self.assertIn(
"Imported the tagging template edited on an older version as "
"'Custom template (2)'",
"\n".join(logs.output),
)
def test_re_upgrade_into_a_full_list_imports_nothing_and_warns(self):
saved = [entry(f"{i:012x}", f"n{i}") for i in range(MAX_SAVED_INSTRUCTIONS)]
self.load({"template": self.CUSTOM, "instructions": saved}, warnings=1)
self.assertEqual(self.state()[0], saved)
self.assertIn("full", self.logs[0])
class TestGetConfig(ConfigTestCase):
def test_get_config_writes_the_lists_and_the_gguf_mirror(self):
AutotagConfig.INSTRUCTIONS = (entry("0123456789ab", "Photo", "Photo tags."),)
AutotagConfig.DEFAULT_INSTRUCTIONS = {"gguf": "0123456789ab", "hf": "builtin"}
defaults = AutotagConfig.get_config()["defaults"]
self.assertEqual(
defaults["instructions"], [entry("0123456789ab", "Photo", "Photo tags.")]
)
self.assertEqual(
defaults["default_instructions"], {"gguf": "0123456789ab", "hf": "builtin"}
)
self.assertEqual(defaults["template"], "Photo tags.")
AutotagConfig.DEFAULT_INSTRUCTIONS = {"gguf": "builtin", "hf": "0123456789ab"}
self.assertNotIn("template", AutotagConfig.get_config()["defaults"])
class TestDamagedValues(ConfigTestCase):
def test_a_non_text_default_falls_back_with_a_warning(self):
self.load({"default_instructions": {"gguf": [], "hf": "builtin"}}, warnings=1)
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
def test_a_damaged_list_warns_once_and_migrates_the_template_afresh(self):
custom = "My own tags, please."
self.load(
{
"template": custom,
"instructions": "broken",
"default_instructions": {"gguf": "0123456789ab", "hf": "builtin"},
},
warnings=1,
)
self.assertEqual(
self.state(),
(
[entry(sha12(custom), "Custom template", custom)],
{"gguf": sha12(custom), "hf": sha12(custom)},
),
)
if __name__ == "__main__":
unittest.main()
+224
View File
@@ -0,0 +1,224 @@
"""Saved instructions through POST /settings and the legacy fallbacks (#431).
Server-side pairing (a save carrying either key writes both, the missing one
from the running, migrated configuration), the downgrade mirror in the old
``template`` key, the refusals, GET /settings, and the legacy single route's
blank-prompt fallback per request engine. The "restart" is a reload of the
isolated config.json (``PROMPT_MANAGER_CONFIG_PATH`` in the temp tree).
"""
import hashlib
try:
from test_settings_sources import SourcesTestCase
from test_autotag_legacy import LegacyTestCase
except ImportError:
from tests.test_settings_sources import SourcesTestCase
from tests.test_autotag_legacy import LegacyTestCase
from py.config import AutotagConfig, PromptManagerConfig
from py.tagging_defaults import DEFAULT_TAG_TEMPLATE
ATTRS = ("MODEL_TYPE", "INSTRUCTIONS", "DEFAULT_INSTRUCTIONS", "_LEGACY_TEMPLATE")
SETTINGS = "/prompt_manager/settings"
CUSTOM = "My own tags, please."
CUSTOM_ID = hashlib.sha256(CUSTOM.encode()).hexdigest()[:12]
def entry(id_, name, text):
return {"id": id_, "name": name, "text": text}
class InstructionsTestCase(SourcesTestCase):
async def asyncSetUp(self):
await super().asyncSetUp()
saved = {attr: getattr(AutotagConfig, attr) for attr in ATTRS}
self.addCleanup(
lambda: [setattr(AutotagConfig, a, v) for a, v in saved.items()]
)
def restart(self):
PromptManagerConfig.load_from_file(self.config_path)
async def save(self, payload, revision=True):
if revision:
payload = {"expected_revision": (await self._get())["revision"], **payload}
return await self._post(SETTINGS, payload)
def disk(self):
return self._read_config()["autotag"]["defaults"]
class TestMigrationPersists(InstructionsTestCase):
async def asyncSetUp(self):
await super().asyncSetUp()
self._write_config(
{"custom": {"keep": True}, "autotag": {"defaults": {"template": CUSTOM}}}
)
self.restart()
async def test_get_shows_the_migrated_entry_and_writes_nothing(self):
before = self._config_bytes()
settings = (await self._get())["settings"]
self.assertEqual(
settings["autotag_instructions"],
[entry(CUSTOM_ID, "Custom template", CUSTOM)],
)
self.assertEqual(
settings["autotag_default_instructions"],
{"gguf": CUSTOM_ID, "hf": CUSTOM_ID},
)
self.assertEqual(settings["autotag_template"], CUSTOM)
self.assertEqual(settings["autotag_builtin_instructions"], DEFAULT_TAG_TEMPLATE)
self.assertEqual(self._config_bytes(), before)
async def test_a_save_of_only_the_hf_default_writes_the_migrated_list_too(self):
status, data = await self.save(
{"autotag_default_instructions": {"gguf": CUSTOM_ID, "hf": "builtin"}}
)
self.assertEqual(status, 200, data)
disk = self.disk()
self.assertEqual(
disk["instructions"], [entry(CUSTOM_ID, "Custom template", CUSTOM)]
)
self.assertEqual(
disk["default_instructions"], {"gguf": CUSTOM_ID, "hf": "builtin"}
)
self.assertEqual(disk["template"], CUSTOM)
self.assertEqual(self._read_config()["custom"], {"keep": True})
self.restart()
self.assertEqual(
(list(AutotagConfig.INSTRUCTIONS), AutotagConfig.DEFAULT_INSTRUCTIONS),
(
[entry(CUSTOM_ID, "Custom template", CUSTOM)],
{"gguf": CUSTOM_ID, "hf": "builtin"},
),
)
async def test_gguf_built_in_deletes_the_mirror_and_keeps_custom_template(self):
status, data = await self.save(
{"autotag_default_instructions": {"gguf": "builtin", "hf": CUSTOM_ID}}
)
self.assertEqual(status, 200, data)
self.assertNotIn("template", self.disk())
self.restart()
self.assertEqual(
list(AutotagConfig.INSTRUCTIONS),
[entry(CUSTOM_ID, "Custom template", CUSTOM)],
)
self.assertEqual(
AutotagConfig.DEFAULT_INSTRUCTIONS, {"gguf": "builtin", "hf": CUSTOM_ID}
)
self.assertEqual(
AutotagConfig.default_instructions_text("gguf"), DEFAULT_TAG_TEMPLATE
)
async def test_a_save_of_only_the_list_keeps_the_defaults_that_still_exist(self):
photo = entry("0123456789ab", "Photo", "Photo tags.")
status, data = await self.save(
{
"autotag_instructions": [
entry(CUSTOM_ID, "Custom template", CUSTOM),
photo,
]
}
)
self.assertEqual(status, 200, data)
self.assertEqual(
self.disk()["default_instructions"], {"gguf": CUSTOM_ID, "hf": CUSTOM_ID}
)
self.assertEqual(AutotagConfig.INSTRUCTIONS[1], photo)
# A list that drops a default without moving it is refused, nothing written.
before = self._config_bytes()
status, data = await self.save({"autotag_instructions": [photo]})
self.assertEqual(status, 400, data)
self.assertEqual(
data["error"],
"Default for GGUF JoyCaption must be one of the saved instructions.",
)
self.assertEqual(self._config_bytes(), before)
class TestRefusals(InstructionsTestCase):
async def test_the_old_template_key_is_refused(self):
before = self._config_bytes()
status, data = await self.save({"autotag_template": "Tags only."})
self.assertEqual(
(status, data["error"]),
(
400,
"LLM tag template was replaced by Saved instructions. Reload the page.",
),
)
self.assertEqual(self._config_bytes(), before)
async def test_instruction_keys_without_a_revision_are_refused(self):
for payload in (
{"autotag_instructions": []},
{"autotag_default_instructions": {"gguf": "builtin", "hf": "builtin"}},
):
with self.subTest(payload=payload):
status, data = await self.save(payload, revision=False)
self.assertEqual(
(status, data["error"]), (400, "Reload the page and try again.")
)
async def test_entry_refusals_name_the_entry(self):
status, data = await self.save(
{
"autotag_instructions": [
entry("0123456789ab", "Photo", "Photo tags."),
entry("0123456789ac", "photo", "x"),
]
}
)
self.assertEqual(
(status, data["error"]),
(400, "Saved instructions: entry 2 name “photo” is already used."),
)
async def test_a_new_entry_can_become_a_default_in_one_save(self):
photo = entry("0123456789ab", "Photo", "Photo tags.")
status, data = await self.save(
{
"autotag_instructions": [photo],
"autotag_default_instructions": {
"gguf": "0123456789ab",
"hf": "builtin",
},
}
)
self.assertEqual(status, 200, data)
self.assertEqual(self.disk()["template"], "Photo tags.")
settings = (await self._get())["settings"]
self.assertEqual(settings["autotag_template"], "Photo tags.")
self.assertEqual(AutotagConfig.default_instructions_text("gguf"), "Photo tags.")
self.assertEqual(AutotagConfig._LEGACY_TEMPLATE, "Photo tags.")
class TestLegacySingleFallback(LegacyTestCase):
async def test_a_blank_prompt_uses_the_request_engines_default(self):
saved = {attr: getattr(AutotagConfig, attr) for attr in ATTRS}
self.addCleanup(
lambda: [setattr(AutotagConfig, a, v) for a, v in saved.items()]
)
AutotagConfig.INSTRUCTIONS = (entry("0123456789ab", "Photo", "Photo tags."),)
AutotagConfig.DEFAULT_INSTRUCTIONS = {"gguf": "builtin", "hf": "0123456789ab"}
prompt = self.prompt("a prompt")
image = self.link(prompt, self.image("a.png", (1, 2, 3), sees=["bird"]))
for model_type, expected in (
("hf", "Photo tags."),
("gguf", DEFAULT_TAG_TEMPLATE),
):
with self.subTest(model_type=model_type):
status, data = await self.single(
{"image_id": image, "model_type": model_type, "prompt": " "}
)
self.assertEqual(status, 200, data)
self.assertEqual(self.tagger.calls[-1]["prompt"], expected)
if __name__ == "__main__":
import unittest
unittest.main()
+9 -6
View File
@@ -1023,16 +1023,17 @@ class TestAutotagDefaultsSettings(SourcesTestCase):
"EXISTING",
"GENERAL_THRESHOLD",
"CHARACTER_THRESHOLD",
"TEMPLATE",
"SUGGESTION_LIMIT",
"SKIPPED_TAGS",
"INSTRUCTIONS",
"DEFAULT_INSTRUCTIONS",
"_LEGACY_TEMPLATE",
)
SAVED = {
"autotag_model_type": "gguf",
"autotag_existing": "merge",
"autotag_general_threshold": 0.4,
"autotag_character_threshold": 0.8,
"autotag_template": "Tags only.",
"autotag_suggestion_limit": 15,
}
@@ -1068,9 +1069,11 @@ class TestAutotagDefaultsSettings(SourcesTestCase):
"EXISTING": "merge",
"GENERAL_THRESHOLD": 0.4,
"CHARACTER_THRESHOLD": 0.8,
"TEMPLATE": "Tags only.",
"SUGGESTION_LIMIT": 15,
"SKIPPED_TAGS": (),
"INSTRUCTIONS": (),
"DEFAULT_INSTRUCTIONS": {"gguf": "builtin", "hf": "builtin"},
"_LEGACY_TEMPLATE": None,
},
)
settings = (await self._get())["settings"]
@@ -1141,11 +1144,11 @@ class TestAutotagDefaultsSettings(SourcesTestCase):
"replace",
"Existing tags must be skip, merge or replace_after_review.",
),
("autotag_template", " ", "LLM tag template must be non-empty text."),
# #431: the old template is refused whatever it holds.
(
"autotag_template",
"x" * 4001,
"LLM tag template must be at most 4000 characters.",
"Tags only.",
"LLM tag template was replaced by Saved instructions. Reload the page.",
),
(
"autotag_suggestion_limit",
+6
View File
@@ -629,6 +629,12 @@
"functions": 95,
"statements": 98
},
"workspace/system-instructions.mjs": {
"lines": 80,
"branches": 80,
"functions": 80,
"statements": 80
},
"workspace/system-skipped-tags.mjs": {
"lines": 80,
"branches": 80,
+36 -9
View File
@@ -11,6 +11,9 @@
* - Results (`section.surface`): the outcome list (`button[aria-pressed]` with counts, in the mockup's order), a path
* filter, and the selected outcome's listed rows (at most 200 per outcome, so "Showing N of M"). Rows under Added and
* Already known open the file in the existing viewer through its listing identity (D57); the others are plain text.
* - #550: a row with no path is a record (a maintenance apply's "Prompt 12: category …"): its detail is shown as plain
* text, never opened; the filter reads it. A maintenance plan of records (every operation but Missing-file links)
* says "records" where a file job says "files" (`groupCopy`).
* - Log: `details.term` "Scan log", collapsed, holding the raw timestamped lines in `pre[tabindex=0]`.
* - D89: the tiles, the outcome list, the openable rows and the log and cancel labels are the job kind's (`jobView`):
* an image tagging job shows Tagged, Unchanged, Skipped and Errors, opens Tagged and Unchanged rows, and reads
@@ -44,6 +47,10 @@ export const DETAIL_INTERVAL_MS = 1000;
export const NOT_FOUND = 'This job is not in this session’s Activity. Jobs are kept until ComfyUI restarts.';
export const EMPTY_GROUP = 'No files in this group.';
export const NO_MATCH = 'No listed file matches this filter.';
export const EMPTY_RECORDS = 'No records in this group.';
export const NO_RECORD_MATCH = 'No listed record matches this filter.';
const FILE_FILTER = 'Filter by file path…';
const RECORD_FILTER = 'Filter records…';
export const VIEWER_HINT = 'each row opens the image in the viewer';
export const FILE_GONE = 'This file is no longer in Images. Rescan from Import and try again.';
export const OPEN_FAILED = 'The file could not be opened. Try again.';
@@ -68,10 +75,11 @@ export function parseDetail(raw) {
const groups = raw.outcomes !== null && typeof raw.outcomes === 'object' ? raw.outcomes : {};
for (const [group, rows] of Object.entries(groups)) {
if (!Array.isArray(rows)) continue;
outcomes[group] = rows.filter(row => row && isText(row.path) && row.path)
// A file row has a path; a record row (#550) has none, only its detail.
outcomes[group] = rows.filter(row => row && isText(row.path) && (row.path || (isText(row.detail) && row.detail)))
.map(row => ({ path: row.path, root: isText(row.root) ? row.root : '',
root_index: Number.isSafeInteger(row.root_index) && row.root_index >= 0 ? row.root_index : null,
detail: isText(row.detail) ? row.detail : '' }));
detail: isText(row.detail) ? row.detail : '', record: !row.path }));
}
const log = Array.isArray(raw.log) ? raw.log.filter(isText) : [];
return { job, outcomes, log };
@@ -129,11 +137,23 @@ export function resultsNote(shown, total, openable) {
return parts.length === 1 && openable && shown >= total ? `${text[0].toUpperCase()}${text.slice(1)}.` : text;
}
/** Rows whose path (or root label and path) holds the case-folded filter. */
/** Rows whose path (or root label and path), or a record's detail (#550), holds the case-folded filter. */
export function filterRows(rows, filter) {
const wanted = filter.trim().toLocaleLowerCase();
if (!wanted) return rows;
return rows.filter(row => `${row.root}/${row.path}`.toLocaleLowerCase().includes(wanted));
return rows.filter(row => (row.record ? row.detail : `${row.root}/${row.path}`).toLocaleLowerCase().includes(wanted));
}
/** #550: whether a job lists records (a maintenance plan of records), not files. */
export function listsRecords(job) {
return job?.kind === 'maintenance' && job.scope?.operation !== 'missing_links';
}
/** The empty-group, no-match and filter copy: "records" for a records job, "files" otherwise (#550). */
export function groupCopy(job) {
return listsRecords(job)
? { empty: EMPTY_RECORDS, noMatch: NO_RECORD_MATCH, placeholder: RECORD_FILTER }
: { empty: EMPTY_GROUP, noMatch: NO_MATCH, placeholder: FILE_FILTER };
}
/** The first outcome with files, so a view opens on what happened; the first outcome when nothing has yet. */
@@ -176,7 +196,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
const search = element('label', undefined, 'search-field');
const input = element('input');
input.type = 'search';
input.placeholder = 'Filter by file path…';
input.placeholder = FILE_FILTER;
input.setAttribute('aria-label', 'Filter results');
search.append(input);
resultsPart.heading.append(search);
@@ -280,9 +300,13 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
// Listed rows only ever grow, so a row keeps its node across reads: a focused row (or the viewer's trigger) stays.
let rowNodes = new Map();
function rowNode(row, index, openable) {
const key = `${selected}\n${index}\n${row.root}\n${row.path}`;
const key = `${selected}\n${index}\n${row.root}\n${row.path}\n${row.record ? row.detail : ''}`;
let node = rowNodes.get(key);
if (!node) {
if (!node && row.record) {
// #550: a record is its detail, plain text, with no open or locate action.
node = element('div', undefined, 'file-row record-row');
node.append(element('span', row.detail, 'why'));
} else if (!node) {
node = openable ? createButton('', 'file-row') : element('div', undefined, 'file-row');
node.append(element('span', row.path, 'path'), element('span', row.detail, 'why'));
if (openable) node.row = row;
@@ -300,8 +324,11 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
const nodes = [...kept.values()];
// The counter is exact; at most 200 of its files are listed, and the filter narrows those.
const total = Math.max(job.counters[selected] ?? 0, rows.length);
if (!nodes.length) nodes.push(element('p', rows.length ? NO_MATCH : EMPTY_GROUP, 'empty-state'));
const note = wanted.length ? resultsNote(wanted.length, total, openable) : '';
const copy = groupCopy(job);
input.placeholder = copy.placeholder;
if (!nodes.length) nodes.push(element('p', rows.length ? copy.noMatch : copy.empty, 'empty-state'));
const opens = openable && wanted.some(row => !row.record);
const note = wanted.length ? resultsNote(wanted.length, total, opens) : '';
if (note) nodes.push(element('p', note, 'small'));
for (const child of [...files.children]) if (!nodes.includes(child)) child.remove();
nodes.forEach((node, index) => {
+100 -15
View File
@@ -31,6 +31,26 @@ const SOURCES = new Set(['images', 'library', 'review', 'tags', 'bulk']);
const OPTION_KEYS = new Set(['general_threshold', 'character_threshold', 'instructions', 'suggestion_limit']);
const DEFAULTS = Object.freeze({general_threshold: 0.35, character_threshold: 0.85, instructions: '', suggestion_limit: 10});
const MAX_SUGGESTIONS = 100;
/**
* #431: Built-in's text (`DEFAULT_TAG_TEMPLATE` in py/tagging_defaults.py, pinned equal by
* tests/test_saved_instructions.py), for when `/autotag/models` cannot be read.
*/
export const BUILTIN_INSTRUCTIONS = 'Generate only comma-separated Danbooru tags (lowercase_underscores, no spaces).\n'
+ 'Include: subject_count (1girl/1boy/etc), gender, ethnicity (asian, caucasian, etc), age_approx (teen, young_woman), '
+ 'body_attributes, face_features, hairstyle, appearance, clothing, accessories, pose, expression, action, camera_angle, '
+ 'camera_shot, scene_composition (wide_shot, long_shot, dutch_angle), environment, background_elements, lighting, mood.\n'
+ 'Prioritize visual accuracy.\n'
+ 'No sentences, no commentary, no captions. Only tags. Keep length short but descriptive.';
export const BUILTIN_ID = 'builtin';
/** The saved instructions when the server cannot say (D204): Built-in only, the default of both engines. */
const FALLBACK_CHOICES = Object.freeze({
entries: Object.freeze([Object.freeze({id: BUILTIN_ID, name: 'Built-in', text: BUILTIN_INSTRUCTIONS})]),
defaults: Object.freeze({gguf: BUILTIN_ID, hf: BUILTIN_ID}),
});
/** The value and label of the select's extra option while the text differs from the chosen entry (D204). */
export const EDITED_VALUE = 'edited';
export const EDITED_LABEL = 'Edited for this run';
export const EDITED_NOTE = 'Edits apply to this run only. Manage saved instructions in System › Auto-tagging.';
const MAX_INSTRUCTION_CHARS = 4000;
const EDITABLE = new Set(['ready', 'generate-error', 'write-error', 'conflict']);
// Answers after which only a fresh observation can save (the capture or epoch is gone); Retry observes again (D116).
@@ -182,27 +202,54 @@ function validateReceipt(value) {
return value;
}
/** #431: the saved instructions of `/autotag/models` (Built-in first) and each engine's default, else Built-in only. */
export function readChoices(value) {
const entries = Array.isArray(value?.instructions) ? value.instructions.filter(entry => object(entry)
&& typeof entry.id === 'string' && typeof entry.name === 'string' && typeof entry.text === 'string') : [];
if (entries[0]?.id !== BUILTIN_ID) return FALLBACK_CHOICES;
const ids = new Set(entries.map(entry => entry.id));
const defaults = Object.fromEntries(['gguf', 'hf'].map(engine => {
const id = value?.default_instructions?.[engine];
return [engine, ids.has(id) ? id : BUILTIN_ID];
}));
return Object.freeze({entries: Object.freeze(entries.map(entry => Object.freeze({...entry}))), defaults: Object.freeze(defaults)});
}
/** The language-model engine whose default a model reads (GGUF for a WD14 model, which takes none). */
const engineOf = model => (model === 'hf' ? 'hf' : 'gguf');
/** The default entry of `model`'s engine. */
export const engineDefault = (choices, model) => choices.entries.find(entry => entry.id === choices.defaults[engineOf(model)])
?? choices.entries[0];
/** The entry to show for `text` (a preset): the one with that text, else the engine's default. */
const entryFor = (choices, model, text) => choices.entries.find(entry => entry.text === text) ?? engineDefault(choices, model);
/** What the instruction fields hold for `model` with its engine's default (#431). */
const instructionsFor = (choices, model) => {
const entry = engineDefault(choices, model);
return {instructionId: entry.id, text: entry.text};
};
/**
* Thresholds, instructions and suggestion count from the server, else the documented defaults, the models'
* download status (`models`, null when unknown) and the saved tagging model (`model`, null when unknown; #328).
* download status (`models`, null when unknown), the saved tagging model (`model`, null when unknown; #328) and the
* saved instructions (`choices`, #431).
*/
async function readDefaults(api, signal) {
try {
const value = await api.get(MODELS_URL, {signal, cache: 'no-store'});
if (!object(value)) return {defaults: DEFAULTS, models: null, model: null};
if (!object(value)) return {defaults: DEFAULTS, models: null, model: null, choices: FALLBACK_CHOICES};
return {
choices: readChoices(value),
model: typeof value.model_type === 'string' && modelOf(value.model_type) ? value.model_type : null,
defaults: {
general_threshold: unit(value.wd14_general_threshold) ? value.wd14_general_threshold : DEFAULTS.general_threshold,
character_threshold: unit(value.wd14_character_threshold) ? value.wd14_character_threshold : DEFAULTS.character_threshold,
instructions: typeof value.default_prompt === 'string' ? value.default_prompt : DEFAULTS.instructions,
suggestion_limit: limit(value.suggestion_limit) ? value.suggestion_limit : DEFAULTS.suggestion_limit,
},
models: object(value.models) ? value.models : null,
};
} catch {
// The defaults keep the dialog usable; Generate reports a real failure itself.
return {defaults: DEFAULTS, models: null, model: null};
return {defaults: DEFAULTS, models: null, model: null, choices: FALLBACK_CHOICES};
}
}
@@ -258,6 +305,8 @@ function initialState(source, preset) {
model: preset?.model ?? MODEL_OPTIONS[0].id, options: {...DEFAULTS, ...preset?.options}, generated: false, suggestions: [],
suggestionsText: COPY.modelChanged, statusText: '', generateSupported: false, generateReason: null, toast: null,
skippedCount: 0,
// #431 (D204): the saved instructions, each engine's default and the entry the select shows.
instructionChoices: FALLBACK_CHOICES, instructionId: BUILTIN_ID,
// Bumped whenever the controller itself rewrites the option fields or the manual draft, never while typing.
optionsRevision: 0, draftRevision: 0,
};
@@ -269,6 +318,13 @@ export function skippedNote(count) {
return count === 1 ? ' 1 skipped tag was not suggested.' : ` ${count.toLocaleString('en-US')} skipped tags were not suggested.`;
}
/** D204: whether a language model's instructions differ from the chosen saved entry's text (a one-off edit). */
export function instructionsEdited(state) {
if (modelOf(state.model)?.classifier !== false) return false;
const entry = state.instructionChoices?.entries.find(item => item.id === state.instructionId);
return !entry || entry.text !== state.options.instructions;
}
/** Derived labels and permissions; never stored independently of the fields they follow. */
function derive(state) {
const editable = EDITABLE.has(state.phase);
@@ -277,6 +333,7 @@ function derive(state) {
primaryLabel: state.generated ? 'Approve selected tags' : 'Save manual tags',
countText: state.generated ? `${state.suggestions.length} ${noun(state.suggestions.length, 'suggestion')}` : 'Not generated',
chipHint: state.suggestions.length ? COPY.chipHint + skippedNote(state.skippedCount) : '',
instructionsEdited: instructionsEdited(state),
canSave: editable,
canGenerate: editable && state.generateSupported,
canRetry: RETRYABLE.has(state.phase),
@@ -394,6 +451,10 @@ export function createImageTaggingController({api, target, source = 'images', on
if (closed || serial !== openSerial) return;
// The saved model (System › Auto-tagging) unless a preset or the user already chose one (#328).
const savedModel = !chosen && !modelPicked && read.model ? {model: read.model} : {};
// #431: the instructions start from the model's engine default; a preset's text wins (D204).
const model = savedModel.model ?? state.model;
const presetText = chosen?.options.instructions;
const entry = presetText === undefined ? engineDefault(read.choices, model) : entryFor(read.choices, model, presetText);
observedOnce = true;
const {width, height, media_type: mediaType, preview_url: previewUrl, generate} = value.capture;
// The draft is read now, so text typed while observing is kept (#515). A kept draft stays as typed unless
@@ -410,7 +471,8 @@ export function createImageTaggingController({api, target, source = 'images', on
savedTags: value.tags.slice(), tagRevision: value.tag_revision,
manual: draft === null ? value.tags.join(', ') : changed || unseen ? mergeCurrent(value.tags, draft) : draft,
draftRevision: state.draftRevision + 1,
...savedModel, options: {...defaults, ...chosen?.options}, optionsRevision: state.optionsRevision + 1,
...savedModel, options: {...defaults, instructions: entry.text, ...chosen?.options}, optionsRevision: state.optionsRevision + 1,
instructionChoices: read.choices, instructionId: entry.id,
generateSupported: generate.supported, generateReason: generate.reason,
statusText: changed ? `${COPY.changedElsewhere} ${savedNow(value.tags)}${reason ? ` ${reason}` : ''}` : reason,
});
@@ -428,15 +490,26 @@ export function createImageTaggingController({api, target, source = 'images', on
if (id === state.model) return;
stopGenerating();
const phase = state.phase === 'generating' || state.phase === 'generate-error' ? 'ready' : state.phase;
const {instructionId, text} = instructionsFor(state.instructionChoices, id);
set({
phase, model: id, generated: false, suggestions: [], suggestionsText: COPY.modelChanged, statusText: reasonText(),
optionsRevision: state.optionsRevision + 1,
// Like the mock, a model change re-renders its fields with the defaults; the count is kept (D91).
// Like the mock, a model change re-renders its fields with the defaults; the count is kept (D91). The
// instructions start from the new engine's default, dropping a one-off edit (D204).
options: {...state.options, general_threshold: defaults.general_threshold,
character_threshold: defaults.character_threshold, instructions: defaults.instructions},
character_threshold: defaults.character_threshold, instructions: text},
instructionId,
});
}
/** D204: choose a saved entry; its text replaces the instructions (and a one-off edit). */
function pickInstructions(id) {
if (closed) return;
const entry = state.instructionChoices.entries.find(item => item.id === id);
if (!entry) return;
set({instructionId: id, options: {...state.options, instructions: entry.text}, optionsRevision: state.optionsRevision + 1});
}
function setOption(key, value) {
requireValue(OPTION_KEYS.has(key), 'Unknown tagging option');
set({options: {...state.options, [key]: value}});
@@ -593,7 +666,7 @@ export function createImageTaggingController({api, target, source = 'images', on
return {
get state() {return state;},
get target() {return imageTarget;},
subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, retarget, cancel, dispose,
subscribe, open, setManual, setModel, setOption, pickInstructions, generate, remove, save, retry, retarget, cancel, dispose,
};
}
@@ -612,8 +685,9 @@ export function createTaggingOptionsController({api} = {}) {
let abort = null;
let modelPicked = false;
const deriveOptions = value => ({...value, canGenerate: false, unavailable: modelUnavailable(models, value.model),
canStart: value.phase === 'ready' && !modelUnavailable(models, value.model)});
let state = freeze(deriveOptions({phase: 'loading', model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, optionsRevision: 0}));
canStart: value.phase === 'ready' && !modelUnavailable(models, value.model), instructionsEdited: instructionsEdited(value)});
let state = freeze(deriveOptions({phase: 'loading', model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, optionsRevision: 0,
instructionChoices: FALLBACK_CHOICES, instructionId: BUILTIN_ID}));
function set(patch) {
if (closed) return;
@@ -628,17 +702,28 @@ export function createTaggingOptionsController({api} = {}) {
({defaults, models} = read);
// The saved model (System › Auto-tagging) unless the user already chose one (#328).
const savedModel = !modelPicked && read.model ? {model: read.model} : {};
set({phase: 'ready', ...savedModel, options: {...defaults}, optionsRevision: state.optionsRevision + 1});
const {instructionId, text} = instructionsFor(read.choices, savedModel.model ?? state.model);
set({phase: 'ready', ...savedModel, options: {...defaults, instructions: text}, optionsRevision: state.optionsRevision + 1,
instructionChoices: read.choices, instructionId});
}
function setModel(id) {
requireValue(modelOf(id), 'Unknown tagging model');
if (closed) return;
modelPicked = true;
if (id === state.model) return;
// As in the Tag image dialog: the model's fields start from the defaults again; the count is kept (D91).
set({model: id, optionsRevision: state.optionsRevision + 1,
// As in the Tag image dialog: the model's fields start from the defaults again; the count is kept (D91), and
// the instructions from the new engine's default (D204).
const {instructionId, text} = instructionsFor(state.instructionChoices, id);
set({model: id, optionsRevision: state.optionsRevision + 1, instructionId,
options: {...state.options, general_threshold: defaults.general_threshold,
character_threshold: defaults.character_threshold, instructions: defaults.instructions}});
character_threshold: defaults.character_threshold, instructions: text}});
}
/** D204: choose a saved entry; its text replaces the instructions. */
function pickInstructions(id) {
if (closed) return;
const entry = state.instructionChoices.entries.find(item => item.id === id);
if (!entry) return;
set({instructionId: id, options: {...state.options, instructions: entry.text}, optionsRevision: state.optionsRevision + 1});
}
function setOption(key, value) {
requireValue(OPTION_KEYS.has(key), 'Unknown tagging option');
@@ -661,6 +746,6 @@ export function createTaggingOptionsController({api} = {}) {
}
return {
get state() {return state;},
subscribe, open, setModel, setOption, request, dispose,
subscribe, open, setModel, setOption, pickInstructions, request, dispose,
};
}
+47 -3
View File
@@ -17,7 +17,8 @@
* flight at close keeps its capture until it settles, and its outcome is still toasted and announced.
*/
import { createButton, createScope } from '../shared/toolkit/core.js';
import { createImageTaggingController, MODEL_OPTIONS, optionError } from './image-tagging-state.mjs';
import { createImageTaggingController, EDITED_LABEL, EDITED_NOTE, EDITED_VALUE, MODEL_OPTIONS, engineDefault, optionError }
from './image-tagging-state.mjs';
import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs';
import { createWorkspaceIcon } from './icons.mjs';
import { showWorkspaceToast } from './toast.mjs';
@@ -107,9 +108,13 @@ function numberInput(value, {min, max, step}) {
* It follows `controller` until `scope` is disposed (reused by the bulk Auto-tag dialog, D85, without Generate:
* `generate: false`). Returns the `element`, `validate()` (the fields' native validity, then what the controller would
* refuse; true when valid, else the first invalid field reports) and `requestGenerate()`, Generate's own path (field
* validity first), for a Retry after a failed Generate.
* validity first), for a Retry after a failed Generate. D204 (#431): for a language model, a "Saved instructions" select
* (`pickId`) sits above "Tagging instructions": it starts at the engine's default ("<name> · default"), a pick replaces
* the text, and while the text differs from the picked entry it shows "Edited for this run" (not choosable) with a
* muted note under the textarea.
*/
export function renderModelPanel({scope, controller, heading = 'Auto-tag this image', generate: withGenerate = true}) {
export function renderModelPanel({scope, controller, heading = 'Auto-tag this image', generate: withGenerate = true,
pickId = 'image-tag-instructions-pick'}) {
const panel = el('div', undefined, 'tag-model-panel');
const select = el('select');
for (const option of MODEL_OPTIONS) {
@@ -125,9 +130,35 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im
if (withGenerate) panel.append(generate);
const inputs = new Map();
let shownModel = null, shownOptions = null;
// D204: the Saved instructions select and the one-off note, while a language model is chosen.
let pick = null, editedNote = null, shownEdited = null;
/** The select's options: every entry (the engine default marked), plus "Edited for this run" while edited. */
function fillPick(state) {
shownEdited = state.instructionsEdited;
const fallback = engineDefault(state.instructionChoices, state.model).id;
pick.replaceChildren(...state.instructionChoices.entries.map(entry => {
const option = el('option', entry.id === fallback ? `${entry.name} · default` : entry.name);
option.value = entry.id;
return option;
}));
if (state.instructionsEdited) {
const option = el('option', EDITED_LABEL);
option.value = EDITED_VALUE;
option.disabled = true;
pick.append(option);
}
pick.value = state.instructionsEdited ? EDITED_VALUE : state.instructionId;
editedNote.hidden = !state.instructionsEdited;
// The note describes the textarea while it shows.
const area = inputs.get('instructions');
if (state.instructionsEdited) area?.setAttribute('aria-describedby', editedNote.id);
else area?.removeAttribute('aria-describedby');
}
function renderOptions(state) {
inputs.clear();
pick = editedNote = null;
const classifier = MODEL_OPTIONS.find(option => option.id === state.model).classifier;
const rows = [];
if (classifier) {
@@ -139,10 +170,17 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im
}
rows.push(pair);
} else {
pick = el('select');
pick.addEventListener('change', () => { if (pick.value !== EDITED_VALUE) controller.pickInstructions(pick.value); });
rows.push(field('Saved instructions', pick, pickId));
const area = el('textarea');
area.value = state.options.instructions;
inputs.set('instructions', area);
rows.push(field('Tagging instructions', area, OPTION_IDS.instructions));
editedNote = el('p', EDITED_NOTE, 'small instructions-edited');
editedNote.id = `${pickId}-note`;
rows.push(editedNote);
fillPick(state);
}
// D91: the number of suggestions, for every model.
const count = numberInput(state.options.suggestion_limit, {min: '1', max: '100', step: '1'});
@@ -162,7 +200,13 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im
shownModel = state.model;
shownOptions = state.optionsRevision;
select.value = state.model;
// A pick re-renders the fields; keyboard focus stays on the select it was made with.
const picking = pick !== null && document.activeElement === pick;
renderOptions(state);
if (picking) pick?.focus();
} else if (pick && state.instructionsEdited !== shownEdited) {
// Typing never re-renders the fields (I1): only the select and the note follow an edit.
fillPick(state);
}
setDisabled(generate, !state.canGenerate);
}
+2 -1
View File
@@ -167,7 +167,8 @@ export function openAutoTag({api, scope, store, images, trigger, onTagged = () =
const options = createTaggingOptionsController({api});
// D85: the Tag image dialog's model panel without Generate; its Suggestions field is the job's count (D91).
const {element: panel, validate} = renderModelPanel({scope: local, controller: options,
heading: `Auto-tag ${countOf(filter ? count : listed.length, 'image')}`, generate: false}); // D118
heading: `Auto-tag ${countOf(filter ? count : listed.length, 'image')}`, generate: false, // D118
pickId: 'bulk-tag-instructions-pick'}); // D204
let choice = APPLY_CHOICES[0];
const actions = el('div', undefined, 'dialog-actions');
const cancel = createButton('Cancel');
+2
View File
@@ -49,6 +49,8 @@ export const OPERATION_TITLES = Object.freeze({
duplicate_prompts: 'Duplicate prompt text',
optimize: 'Optimize database',
unused_components: 'Unused prompt components',
// D192 (#550): beyond the concept, System › Maintenance › Database.
pair_metadata: 'Pair category and tags',
missing_links: 'Missing-file links',
duplicate_files: 'Duplicate image files',
// Reviewed and applied through the API and seen in Activity; Maintenance has no row for it (the concept has none).
+351
View File
@@ -0,0 +1,351 @@
/**
* System › Auto-tagging "Saved instructions" (#431, D203): named language-model instructions and the default of each
* language-model engine. It replaces the concept's "LLM tag template" textarea.
*
* A `.setting-block` (`data-setting="tag-instructions"`, `data-key="autotag_instructions"`): the label, the
* description with the count ("N of 20 saved · Built\u2011in is not counted."), a `role="listbox"` list (Built-in first,
* "Read-only", then the saved entries, each with its "Default for GGUF" / "Default for HuggingFace" chips), "+ New",
* "Duplicate" and "Delete", and the editor beside the list (under it below 700 px): "Name" (60 characters, live count)
* and "Text" ("N of 4,000 characters"), both read-only for Built-in with its note. Then two `.setting-row` selects,
* "Default for GGUF JoyCaption" and "Default for HuggingFace JoyCaption" (`data-key="autotag_default_instructions"`),
* and the note a deleted default leaves ("GGUF now uses Built-in."). Host drafts: `autotag_instructions` (the saved
* entries, never Built-in) and `autotag_default_instructions`; the server pairs them on save. Built-in's text is the
* read-only `autotag_builtin_instructions` of GET /settings. Ids are 12 lowercase hex characters made here.
*/
import { createButton } from '../shared/toolkit/core.js';
import { settingRow } from './system-controls.mjs';
export const INSTRUCTIONS_KEY = 'autotag_instructions';
export const DEFAULTS_KEY = 'autotag_default_instructions';
export const BUILTIN_TEXT_KEY = 'autotag_builtin_instructions';
export const BUILTIN_ID = 'builtin';
export const BUILTIN_NAME = 'Built-in';
export const MAX_SAVED = 20;
export const MAX_NAME_CHARS = 60;
export const MAX_TEXT_CHARS = 4000;
export const ENGINES = Object.freeze([['gguf', 'GGUF', 'GGUF JoyCaption'], ['hf', 'HuggingFace', 'HuggingFace JoyCaption']]);
export const INSTRUCTIONS_COPY = Object.freeze({
label: 'Saved instructions',
description: 'Instructions for the language-model engines. Built-in can’t be changed; duplicate it to make your own.',
count: saved => `${saved} of ${MAX_SAVED} saved · Built\u2011in is not counted.`,
readOnly: 'Read-only',
defaultFor: short => `Default for ${short}`,
newLabel: '+ New',
duplicate: 'Duplicate',
delete: 'Delete',
cap: `At most ${MAX_SAVED} saved instructions.`,
name: 'Name',
text: 'Text',
nameCount: length => `${length} of ${MAX_NAME_CHARS}`,
textCount: length => `${length.toLocaleString('en-US')} of ${MAX_TEXT_CHARS.toLocaleString('en-US')} characters`,
builtinNote: 'Built-in instructions can’t be changed. Duplicate them to make your own.',
newName: 'New instructions',
copyName: name => `${name} copy`,
defaultRow: long => `Default for ${long}`,
defaultDescription: 'The Tag image dialog and batches start from this.',
reset: shorts => `${shorts.join(' and ')} now ${shorts.length > 1 ? 'use' : 'uses'} Built-in.`,
});
/** Characters as Python's `len` counts them (code points). */
const characters = text => [...String(text ?? '')].length;
const fold = text => String(text ?? '').trim().toLowerCase();
/** The saved entries of the drafts (`[]` when unread or damaged). */
export const savedEntries = values => (Array.isArray(values?.[INSTRUCTIONS_KEY]) ? values[INSTRUCTIONS_KEY] : []);
/** Built-in (its text from GET /settings) and then the saved entries. */
export function allEntries(values) {
const text = typeof values?.[BUILTIN_TEXT_KEY] === 'string' ? values[BUILTIN_TEXT_KEY] : '';
return [{ id: BUILTIN_ID, name: BUILTIN_NAME, text }, ...savedEntries(values)];
}
/** The drafted default entry of `engine` (`gguf` or `hf`); Built-in when the id names nothing. */
export function defaultEntry(values, engine) {
const entries = allEntries(values);
const id = values?.[DEFAULTS_KEY]?.[engine];
return entries.find(entry => entry.id === id) ?? entries[0];
}
/** `base`, else `base (2)`, `base (3)`… whichever no entry uses (case-insensitive). */
export function freeName(base, entries) {
const taken = new Set(entries.map(entry => fold(entry.name)));
if (!taken.has(fold(base))) return base;
for (let n = 2; ; n += 1) if (!taken.has(fold(`${base} (${n})`))) return `${base} (${n})`;
}
/** "<name> copy" (then "(2)"…), the name cut so the whole stays within the 60-character limit. */
export function copyName(name, entries) {
for (let room = MAX_NAME_CHARS - ' copy'.length; room > 0; room -= 1) {
const base = [...String(name ?? '').trim()].slice(0, room).join('').trimEnd();
const candidate = freeName(INSTRUCTIONS_COPY.copyName(base), entries);
if (characters(candidate) <= MAX_NAME_CHARS) return candidate;
}
return freeName(INSTRUCTIONS_COPY.copyName(BUILTIN_NAME), entries);
}
/** A new entry id: 12 lowercase hex characters from `random` (crypto.getRandomValues), never one already used. */
export function newId(entries, random = bytes => globalThis.crypto.getRandomValues(bytes)) {
const used = new Set(entries.map(entry => entry.id));
for (;;) {
const id = [...random(new Uint8Array(6))].map(byte => byte.toString(16).padStart(2, '0')).join('');
if (!used.has(id)) return id;
}
}
/** The editor's note for a saved entry's name and text (the mock's short sentences), or ''. */
export function entryNote(entry, entries) {
const name = String(entry.name ?? '').trim();
if (!name) return 'Needs a name.';
if (characters(name) > MAX_NAME_CHARS) return `The name must be at most ${MAX_NAME_CHARS} characters.`;
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(name)) return 'The name must not contain a line break.';
if (fold(name) === fold(BUILTIN_NAME)) return 'The name “Built-in” is reserved.';
if (entries.some(other => other !== entry && other.id !== BUILTIN_ID && fold(other.name) === fold(name))) {
return `The name “${name}” is already used.`;
}
if (!String(entry.text ?? '').trim()) return 'The text must not be empty.';
if (characters(entry.text) > MAX_TEXT_CHARS) return `The text must be at most ${MAX_TEXT_CHARS.toLocaleString('en-US')} characters.`;
return '';
}
/**
* The server's sentence for the first saved entry it would refuse (Built-in is not counted), else null. The Save row
* shows the server's own refusal; this one holds back Preview tagging review and the batch Review.
*/
export function listProblem(values) {
const entries = savedEntries(values);
for (const [index, entry] of entries.entries()) {
// As the server checks them: a repeated name is the later entry's problem.
const note = entryNote(entry, entries.slice(0, index + 1));
if (!note) continue;
const n = index + 1;
if (note.startsWith('Needs')) return `Saved instructions: entry ${n} needs a name.`;
if (note.includes('reserved')) return 'Saved instructions: the name “Built-in” is reserved.';
if (note.includes('already used')) return `Saved instructions: entry ${n} name “${String(entry.name).trim()}” is already used.`;
if (note.includes('line break')) return `Saved instructions: entry ${n} name must not contain a line break.`;
if (note.startsWith('The name')) return `Saved instructions: entry ${n} name must be at most ${MAX_NAME_CHARS} characters.`;
if (note.includes('empty')) return `Saved instructions: entry ${n} text must be non-empty text.`;
return `Saved instructions: entry ${n} text must be at most ${MAX_TEXT_CHARS} characters.`;
}
return null;
}
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/** A `.field` with a label row (text and a muted count) and its control. */
function field(id, label, control) {
const wrap = el('div', undefined, 'field');
const caption = el('label');
caption.htmlFor = id;
const count = el('span', '', 'small muted');
caption.append(el('span', label), count);
control.id = id;
wrap.append(caption, control);
return { wrap, count };
}
/**
* Build the block and the two default rows; `drafts` is the System draft store. Returns `{nodes, repaint}`: System
* appends `nodes` in order and calls `repaint` whenever the drafts change.
*/
export function savedInstructionsBlock({ scope, drafts, random }) {
const block = el('div', undefined, 'setting-block');
block.dataset.setting = 'tag-instructions';
block.dataset.key = INSTRUCTIONS_KEY;
const text = el('div', undefined, 'setting-block-text');
const label = el('label', INSTRUCTIONS_COPY.label);
label.id = 'instructions-label';
const description = el('p', `${INSTRUCTIONS_COPY.description} `);
const count = el('span', '');
count.id = 'instructions-count';
description.append(count);
text.append(label, description);
const layout = el('div', undefined, 'instructions-layout');
const side = el('div', undefined, 'instructions-side');
const list = el('div', undefined, 'instruction-list');
list.id = 'instruction-list';
list.setAttribute('role', 'listbox');
list.setAttribute('aria-labelledby', 'instructions-label');
list.tabIndex = 0;
const actions = el('div', undefined, 'row-actions instruction-actions');
const create = createButton(INSTRUCTIONS_COPY.newLabel);
create.id = 'instruction-new';
const duplicate = createButton(INSTRUCTIONS_COPY.duplicate);
duplicate.id = 'instruction-duplicate';
const remove = createButton(INSTRUCTIONS_COPY.delete, 'danger');
remove.id = 'instruction-delete';
actions.append(create, duplicate, remove);
const cap = el('p', INSTRUCTIONS_COPY.cap, 'small');
cap.id = 'instruction-cap';
cap.hidden = true;
side.append(list, actions, cap);
const editor = el('div', undefined, 'instruction-editor');
const nameInput = el('input');
nameInput.autocomplete = 'off';
const name = field('instruction-name', INSTRUCTIONS_COPY.name, nameInput);
const textArea = el('textarea');
textArea.rows = 7;
const body = field('instruction-text', INSTRUCTIONS_COPY.text, textArea);
const note = el('p', '', 'small');
note.id = 'instruction-note';
note.setAttribute('role', 'status');
editor.append(name.wrap, body.wrap, note);
layout.append(side, editor);
block.append(text, layout);
const selects = ENGINES.map(([engine, , long]) => {
const row = settingRow({ id: `instruction-default-${engine}`, label: INSTRUCTIONS_COPY.defaultRow(long),
description: INSTRUCTIONS_COPY.defaultDescription, control: { type: 'select', options: [[BUILTIN_ID, BUILTIN_NAME]] } });
const select = row.children[1];
select.dataset.key = DEFAULTS_KEY;
return { engine, select, row };
});
const reset = el('p', '', 'small instruction-reset');
reset.id = 'instruction-reset';
reset.setAttribute('role', 'status');
let selectedId = BUILTIN_ID;
const values = () => ({ [INSTRUCTIONS_KEY]: drafts.get('host', INSTRUCTIONS_KEY),
[DEFAULTS_KEY]: drafts.get('host', DEFAULTS_KEY), [BUILTIN_TEXT_KEY]: drafts.get('host', BUILTIN_TEXT_KEY) });
const ready = () => Array.isArray(drafts.get('host', INSTRUCTIONS_KEY)) && drafts.get('host', DEFAULTS_KEY) !== undefined;
const defaults = () => ({ ...(drafts.get('host', DEFAULTS_KEY) ?? {}) });
const setEntries = next => drafts.set('host', INSTRUCTIONS_KEY, next);
const selected = () => allEntries(values()).find(entry => entry.id === selectedId) ?? allEntries(values())[0];
/** Replace the selected saved entry's `key` with `value` (a new list of new objects). */
function edit(key, value) {
if (selectedId === BUILTIN_ID) return;
setEntries(savedEntries(values()).map(entry => (entry.id === selectedId ? { ...entry, [key]: value } : entry)));
}
function add(entry) {
const entries = savedEntries(values());
if (!ready() || entries.length >= MAX_SAVED) return;
selectedId = entry.id;
reset.textContent = '';
setEntries([...entries, entry]);
}
function deleteSelected() {
const entries = savedEntries(values());
const index = entries.findIndex(entry => entry.id === selectedId);
if (index < 0) return;
const gone = entries[index];
const next = defaults();
const shorts = [];
for (const [engine, short] of ENGINES) {
if (next[engine] === gone.id) { next[engine] = BUILTIN_ID; shorts.push(short); }
}
const left = entries.filter(entry => entry.id !== gone.id);
selectedId = (left[index] ?? left[index - 1])?.id ?? BUILTIN_ID;
reset.textContent = shorts.length ? INSTRUCTIONS_COPY.reset(shorts) : '';
if (shorts.length) drafts.set('host', DEFAULTS_KEY, next);
setEntries(left);
}
function select(id) {
selectedId = id;
repaint();
}
function renderList(entries, chosen) {
list.replaceChildren(...entries.map(entry => {
const option = el('div');
option.id = `instruction-option-${entry.id}`;
option.dataset.id = entry.id;
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', String(entry.id === selectedId));
option.append(el('span', entry.name));
const badges = el('span', undefined, 'badges');
if (entry.id === BUILTIN_ID) badges.append(el('span', INSTRUCTIONS_COPY.readOnly, 'chip'));
for (const [engine, short] of ENGINES) {
if (chosen[engine] === entry.id) badges.append(el('span', INSTRUCTIONS_COPY.defaultFor(short), 'chip blue'));
}
if (badges.children.length) option.append(badges);
return option;
}));
list.setAttribute('aria-activedescendant', `instruction-option-${selectedId}`);
}
function repaint() {
// The note a deleted default left goes once the defaults are saved (or set back).
if (!Object.hasOwn(drafts.changes('host'), DEFAULTS_KEY)) reset.textContent = '';
const loaded = ready();
for (const control of [create, duplicate, remove, nameInput, textArea, ...selects.map(item => item.select)]) {
control.disabled = !loaded;
}
if (!loaded) return;
const current = values();
const entries = allEntries(current);
if (!entries.some(entry => entry.id === selectedId)) selectedId = BUILTIN_ID;
const chosen = defaults();
const saved = savedEntries(current);
renderList(entries, chosen);
const entry = selected();
const builtin = entry.id === BUILTIN_ID;
// A focused field keeps what the user is typing.
if (document.activeElement !== nameInput) nameInput.value = entry.name;
if (document.activeElement !== textArea) textArea.value = entry.text;
nameInput.readOnly = textArea.readOnly = builtin;
name.count.textContent = builtin ? '' : INSTRUCTIONS_COPY.nameCount(characters(entry.name));
body.count.textContent = INSTRUCTIONS_COPY.textCount(characters(entry.text));
const problem = builtin ? '' : entryNote(entry, entries);
note.textContent = builtin ? INSTRUCTIONS_COPY.builtinNote : problem;
note.className = problem ? 'small skip-problem' : 'small';
const full = saved.length >= MAX_SAVED;
create.disabled = duplicate.disabled = full;
remove.disabled = builtin;
cap.hidden = !full;
count.textContent = INSTRUCTIONS_COPY.count(saved.length);
for (const { engine, select: menu } of selects) {
menu.replaceChildren(...entries.map(item => {
const option = el('option', item.name);
option.value = item.id;
return option;
}));
menu.value = chosen[engine] ?? BUILTIN_ID;
}
}
scope.listen(create, 'click', () => {
const entries = allEntries(values());
add({ id: newId(entries, random), name: freeName(INSTRUCTIONS_COPY.newName, entries), text: '' });
textArea.focus?.();
});
scope.listen(duplicate, 'click', () => {
const entries = allEntries(values());
const source = selected();
add({ id: newId(entries, random), name: copyName(source.name, entries), text: source.text });
});
scope.listen(remove, 'click', deleteSelected);
// One listener for every option, however often the list is rebuilt.
scope.listen(list, 'click', event => {
let node = event.target;
while (node && node !== list && node.getAttribute?.('role') !== 'option') node = node.parentNode;
if (node && node !== list) select(node.dataset.id);
});
scope.listen(nameInput, 'input', () => edit('name', nameInput.value));
scope.listen(textArea, 'input', () => edit('text', textArea.value));
scope.listen(list, 'keydown', event => {
const ids = allEntries(values()).map(entry => entry.id);
const at = ids.indexOf(selectedId);
const next = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: ids.length - 1 }[event.key];
if (next === undefined) return;
event.preventDefault?.();
select(ids[Math.max(0, Math.min(ids.length - 1, next))]);
});
for (const { engine, select: menu } of selects) {
scope.listen(menu, 'change', () => {
reset.textContent = '';
drafts.set('host', DEFAULTS_KEY, { ...defaults(), [engine]: menu.value });
});
}
return { nodes: [block, ...selects.map(item => item.row), reset], repaint };
}
+7 -2
View File
@@ -2,10 +2,12 @@
* System › Maintenance (#249 System S7 Task 19): the concept's `renderSystem` "maintenance" body (app.js 688–690).
*
* - The notice "Check first. Review changes. Then apply." with "Run health checks" (D123), then the surfaces
* "Database" and "Images & cache" with the concept's eight operations, in its order, labels and danger marks.
* "Database" and "Images & cache" with the concept's eight operations, in its order, labels and danger marks, plus
* D192 (#550, beyond the concept): "Pair category and tags" after "Unused prompt components".
* - Integrity & relationship checks → Check runs the same read-only health check and opens the Health report (D95);
* its "Review missing links" reviews a fresh Missing-file links plan, never a cleanup.
* - Duplicate prompt text, Optimize database, Unused prompt components and Missing-file links → Review plan open the
* - Duplicate prompt text, Optimize database, Unused prompt components, Pair category and tags (`pair_metadata`: fill
* old Pair prompts' empty category and tags from their saved pairs) and Missing-file links → Review plan open the
* D100 plan review (maintenance-review.mjs), which applies only what it showed, as an Activity job.
* - Duplicate image files → Find duplicates starts the duplicate search as an Activity job and opens it; its view
* offers Review groups (D101) once it found some.
@@ -39,6 +41,9 @@ export const MAINTENANCE_SURFACES = Object.freeze([
['Optimize database', 'Reclaim unused SQLite space. This does not delete prompts or media.', 'Review plan', 'optimize'],
['Unused prompt components', 'Preview unreferenced components. Pair references and history remain protected.', 'Review plan',
'unused_components', true],
// D192 (#550): beyond the concept. Fills only empty fields, deletes nothing (no danger mark).
['Pair category and tags', 'Fill an empty category or tags on prompts older Pair node runs saved, from their saved ' +
'pairs. Only empty fields of never-edited prompts.', 'Review plan', 'pair_metadata'],
]],
['Images & cache', [
['Missing-file links', 'Remove stale database links only. Files on disk are never deleted by this operation.', 'Review plan',
+35 -30
View File
@@ -10,7 +10,8 @@
* 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.
* - D202 (#431): the "Skipped tags" block after it (system-skipped-tags.mjs, host key `autotag_skipped_tags`).
* - The `.field` "LLM tag template" (`#tag-template`, `autotag_template`).
* - D203 (#431): "Saved instructions" and the two per-engine default rows (system-instructions.mjs, host keys
* `autotag_instructions` and `autotag_default_instructions`) replace the concept's "LLM tag template" field.
* - `.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
@@ -37,6 +38,8 @@ import { NO_SELECTION } from './vocabulary.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { navigateTo } from './state.mjs';
import { skippedTagsBlock } from './system-skipped-tags.mjs';
import { BUILTIN_TEXT_KEY, DEFAULTS_KEY, INSTRUCTIONS_KEY, defaultEntry, listProblem, savedInstructionsBlock }
from './system-instructions.mjs';
export const MODELS_URL = '/prompt_manager/autotag/models';
export const UNLOAD_URL = '/prompt_manager/autotag/unload';
@@ -70,10 +73,10 @@ export const SETTING_KEYS = Object.freeze({
'threshold-general': 'autotag_general_threshold',
'threshold-character': 'autotag_character_threshold',
'tag-suggestions': 'autotag_suggestion_limit',
'tag-template': 'autotag_template',
});
/** #431: the values the instruction readers take besides the rows' (D203, D205). */
const INSTRUCTION_VALUES = Object.freeze([INSTRUCTIONS_KEY, DEFAULTS_KEY, BUILTIN_TEXT_KEY]);
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…',
@@ -93,9 +96,6 @@ export const SETTING_PROBLEMS = Object.freeze({
autotag_general_threshold_step: 'General-tag confidence must be a multiple of 0.05.',
autotag_character_threshold_step: 'Character-tag confidence must be a multiple of 0.05.',
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;
@@ -193,20 +193,23 @@ const onStep = value => Math.abs(value * 20 - Math.round(value * 20)) <= 1e-12;
// A threshold on the grid, handed on exactly as the server stores it (anything else is left for draftProblem to refuse).
const snapped = value => (unit(value) && onStep(value) ? Math.round(value * 20) / 20 : value);
/** #431 (D205): the drafted default instructions of the drafted language-model engine. */
export const engineInstructions = values => defaultEntry(values, values.autotag_model_type === 'hf' ? 'hf' : 'gguf');
/**
* The first setting the chosen model would use that the section cannot send, as `{key, message}`, else null: WD14
* reads both thresholds (0 to 1 in steps of 0.05, #369), the language models the template; Generate also reads the
* suggestion count (`limit`).
* reads both thresholds (0 to 1 in steps of 0.05, #369), the language models the saved instructions (#431: the server's
* sentence for the first entry it would refuse); 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'];
const keys = CLASSIFIERS.has(model) ? ['autotag_general_threshold', 'autotag_character_threshold'] : [INSTRUCTIONS_KEY];
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 };
if (key === INSTRUCTIONS_KEY) {
const message = listProblem(values);
if (message) return { key, message };
continue;
}
const valid = key === 'autotag_suggestion_limit' ? Number.isInteger(value) && value >= 1 && value <= 100 : unit(value);
@@ -222,7 +225,7 @@ export function taggingPreset(values) {
const options = CLASSIFIERS.has(model)
? { general_threshold: snapped(values.autotag_general_threshold),
character_threshold: snapped(values.autotag_character_threshold) }
: { instructions: values.autotag_template };
: { instructions: engineInstructions(values).text };
return { model, options: { ...options, suggestion_limit: values.autotag_suggestion_limit } };
}
@@ -232,7 +235,7 @@ export function batchBody(values) {
const options = CLASSIFIERS.has(model)
? { general_threshold: snapped(values.autotag_general_threshold),
character_threshold: snapped(values.autotag_character_threshold) }
: { instructions: values.autotag_template };
: { instructions: engineInstructions(values).text };
return { model_type: model, existing: values.autotag_existing, options };
}
@@ -287,14 +290,20 @@ export function timeText(plan) {
return durationText(Math.ceil(seconds / 60)) + (plan.estimate_device === 'gpu' ? TIME_ON_GPU : TIME_ON_CPU);
}
/** D134: the review's rows from the server's preview and the drafts it was made with (image counts, #379). */
/**
* D134: the review's rows from the server's preview and the drafts it was made with (image counts, #379). D205 (#431):
* a language-model engine shows its default instructions by name in an "Instructions" row, and Settings reads "Up to L
* tags per image".
*/
export function batchGrid(plan, values) {
const id = plan.model?.type ?? values.autotag_model_type;
let settings = CLASSIFIERS.has(id)
? `General ${values.autotag_general_threshold} · Character ${values.autotag_character_threshold}`
: `Template of ${number(characters(values.autotag_template))} characters`;
const classifier = CLASSIFIERS.has(id);
// The saved count the run uses (the preview's), not an unsaved draft.
if (Number.isSafeInteger(plan.suggestion_limit)) settings += ` · up to ${number(plan.suggestion_limit)} tags per image`;
const limit = Number.isSafeInteger(plan.suggestion_limit) ? `up to ${number(plan.suggestion_limit)} tags per image` : '';
const settings = classifier
? `General ${values.autotag_general_threshold} · Character ${values.autotag_character_threshold}${limit ? ` · ${limit}` : ''}`
: limit.charAt(0).toUpperCase() + limit.slice(1);
const instructions = classifier ? [] : [['Instructions', defaultEntry(values, id === 'hf' ? 'hf' : 'gguf').name]];
return [
['Model', `${modelLabel(id, plan.model)} · ${modelSize(id, plan.model)}`],
['Existing tags', existingLabel(plan.existing ?? values.autotag_existing)],
@@ -302,6 +311,7 @@ export function batchGrid(plan, values) {
['Already auto-tagged', number(plan.already_tagged)],
['Images to tag', `about ${number(plan.to_tag)}`],
['Time', timeText(plan)],
...instructions,
['Settings', settings],
['Backup', backupText(plan.backup)],
];
@@ -416,16 +426,9 @@ export function mountTagging(container, { api, activity, scope, drafts, notify =
const skipped = skippedTagsBlock({ api, scope, drafts });
surface.append(skipped.node);
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 });
// D203 (#431): the saved instructions and each language-model engine's default replace the template field.
const instructions = savedInstructionsBlock({ scope, drafts });
surface.append(...instructions.nodes);
for (const { key, input } of controls) {
// Text controls draft while typing (the Save row follows at once); selects and numbers on change.
@@ -454,12 +457,14 @@ export function mountTagging(container, { api, activity, scope, drafts, notify =
// 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 current = () => Object.fromEntries([...Object.values(SETTING_KEYS), ...INSTRUCTION_VALUES]
.map(key => [key, drafts.get('host', key)]));
const chosen = () => drafts.get('host', 'autotag_model_type');
const known = () => MODEL_IDS.includes(chosen());
const repaint = () => {
skipped.repaint();
instructions.repaint();
const values = current();
const unread = values.autotag_model_type === undefined;
const failed = drafts.hostState?.() === 'failed' && unread;
+25
View File
@@ -513,15 +513,40 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
.skip-add { max-width: none; /* D202 */ }
}
/* D203 (#431): "Saved instructions": the list (Built-in first, its chips) beside the Name/Text editor, stacked under
it below 700px, and the note a deleted default leaves under the two default rows. */
.instructions-layout { display: grid; grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); gap: 18px; margin-top: 12px; /* D203 */ }
.instruction-list { border: 1px solid var(--line); border-radius: 6px; background: var(--ca-field); max-height: 300px; overflow: auto; /* D203 */ }
.instruction-list [role="option"] { display: flex; flex-direction: column; gap: 4px; padding: 9px 12px; cursor: pointer; border-bottom: 1px solid var(--line); font-size: 12px; /* D203 */ }
.instruction-list [role="option"]:last-child { border-bottom: 0; /* D203 */ }
.instruction-list [role="option"][aria-selected="true"] { background: var(--soft-blue); box-shadow: inset 3px 0 0 var(--ca-accent); /* D203 */ }
.instruction-list .badges { display: flex; flex-wrap: wrap; gap: 4px; /* D203 */ }
.instruction-actions { margin-top: 10px; flex-wrap: wrap; /* D203 */ }
.instruction-editor .field:first-child { margin-top: 0; /* D203 */ }
.instruction-editor input[readonly], .instruction-editor textarea[readonly] { color: var(--ca-muted); /* D203 */ }
.instruction-editor label { display: flex; justify-content: space-between; gap: 8px; /* D203 */ }
.instruction-reset { color: var(--warning); margin: 0 0 14px; /* D203 */ }
.instruction-reset:empty { display: none; /* D203 */ }
@media (max-width: 700px) {
.instructions-layout { grid-template-columns: 1fr; /* D203 */ }
}
/* D204 (#431): the one-off edit's note under "Tagging instructions" in the Tag image and Auto-tag selected dialogs. */
.tag-model-panel .instructions-edited { margin: -6px 0 12px; /* D204 */ }
.tag-model-panel .instructions-edited[hidden] { display: none; /* D204 */ }
/* 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. Existing tags (#398) never shrinks for its row text and, past the cap, keeps its natural width beside the
text ("Replace after review"), bounded by its row like the engine select. */
.setting-row #tag-existing { flex-shrink: 0; /* D132 */ }
/* D203: the default-instructions selects show their chosen name whole, as Existing tags does (#398). */
.setting-row #instruction-default-gguf, .setting-row #instruction-default-hf { flex-shrink: 0; /* D203 */ }
@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 */ }
#workspace-main .setting-row #tag-existing { max-width: 100% !important; /* D132 */ }
#workspace-main .setting-row #instruction-default-gguf, #workspace-main .setting-row #instruction-default-hf { max-width: 100% !important; /* D203 */ }
}
/* D109: System › Diagnostics & logs' added Lines field sits in the concept's toolbar like its Level select, and a value