* feat(vocabulary): add workspace vocabulary state * feat(vocabulary): render the native Tags & categories screen * feat(vocabulary): add guarded vocabulary dialogs * fix(vocabulary): recover unconfirmed deletes and list every category in Manage * refactor(vocabulary): use the shared plural helper * test(vocabulary): compare the native screen with the approved concept * fix(vocabulary): wrap long tag names so row actions stay visible on narrow screens * docs(vocabulary): document the native vocabulary screen and gates Also aligns the shell link focus-ring offset with the concept (3px) and makes the chrome comparison poll the document title. * fix(vocabulary): refresh lists when a write lands after its dialog closes A rename, delete, create or category apply that commits after its dialog was disposed (Chromium force-closing the modal on a repeated Escape, or Back while the write is in flight) returned before onInvalidate, so the lists were never re-read and a cached Library shelf kept showing the old names. Like the recipe editor, a disposed command now still reports any outcome that may have changed storage (receipt, conflict, restore, unconfirmed) without rendering; only a failed write stays silent. * fix(vocabulary): give the Manage categories datalist its own id The proposed-name datalist reused the recipe editor's `categories` id. It was safe only because routes dispose each other's dialogs; it is now `vocabulary-category-options`. * docs(vocabulary): correct vocabulary testing notes Name the Tag column and the Create tag heading action, say that a failed Load more moves focus to Retry, place bulk actions on the admin page, describe late writes after a closed dialog, Browse's exact filter and the full deviation list, and use a test port that is not the live concept server's. * test(vocabulary): raise the vocabulary dialog branch floor coverage:update after the late-write and datalist fixes: workspace/vocabulary-dialog.mjs branches 87 -> 88. No floor is lowered. * fix(vocabulary): keep 390px row actions visible regardless of font metrics The one-column Tags table reserved a fixed 6em for the tag name. With CI's stock DejaVu Sans fallback (about 9% wider than the pinned image's) Tag + Prompts + Images + stacked actions needed 351px of a 336px wrap, so Browse and Rename / merge were pushed out of view on every row. At 700px and below the Tag column now has no fixed minimum and the actions ask for no more than their stacked width, so all remaining space goes to the tag name: short names stay on one line wherever the concept's own table fits, and a wider font wraps the name instead of clipping an action. A new test widens the metrics with letter spacing and fails without this change in every engine. * ci: run browser jobs in the pinned Playwright image Concept comparisons measure geometry, which follows the fonts a browser falls back to. The concept names Inter, which neither environment ships: the pinned image falls back to WenQuanYi Zen Hei, a stock ubuntu-latest runner to DejaVu Sans. Under DejaVu the concept's own Tags table needs more than a 390px viewport (its page scrolls sideways and, under touch emulation, the widened layout viewport resizes its dialogs), so the 390px comparisons failed in CI while passing locally. Both browser jobs now run in mcr.microsoft.com/playwright:v1.64.1-noble, the image local runs use, instead of installing browsers on the runner. A tooling test keeps the tag equal to @playwright/test in the lockfile. * fix(vocabulary): ignore a category preview edited while it is pending Editing the Category or proposed name while a slow Preview was in flight was a no-op in the saving phase, so the late response still became an applicable preview: the form showed the new values while Apply would submit the old source and target. An edit during a pending preview now marks it stale. Its late result returns the dialog to idle (Preview again) instead of showing an impact or offering Apply; a plain failure of a stale preview is not shown either, while a restore or conflict still is, since it describes storage. The inputs stay editable during the request, as in the recipe editor, which disables only its actions while saving. * ci: use the image's Python and Node in containerized browser jobs Inside mcr.microsoft.com/playwright:v1.64.1-noble, actions/setup-python failed before any test ran: `spawn /__t/Python/3.12.15/x64/bin/pip ENOENT`. The toolcache builds come from the runner, and their entry points name host interpreter paths that do not exist in the container. The jobs now use the image's Python 3.12 and Node 24. The image has no ensurepip and the job runs as uid 1001 without root, so a checksum-pinned pip wheel bootstraps a user-space venv for requirements-dev.txt; a step checks that the image Node is 24. The tooling test forbids setup-python and setup-node in these jobs and requires the pinned bootstrap.
234 lines
9.0 KiB
Python
234 lines
9.0 KiB
Python
"""Owned, deterministic vocabulary data for the native Tags & categories workflows.
|
|
|
|
The six recipes are the approved concept's (docs/concepts/workspace/app.js:71-165)
|
|
in concept order, so tag creation order and category first use match the mock.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
import shutil
|
|
from typing import TYPE_CHECKING
|
|
|
|
from aiohttp import web
|
|
|
|
if TYPE_CHECKING:
|
|
from py.api import PromptManagerAPI
|
|
|
|
|
|
CONCEPT_RECIPES = (
|
|
{
|
|
"title": "Alpine observatory",
|
|
"category": "Landscapes",
|
|
"tags": ["cinematic", "blue_hour", "architecture"],
|
|
"positive": "A remote alpine observatory at blue hour, warm light through "
|
|
"glass, dramatic mountain peaks, cinematic composition, atmospheric mist, "
|
|
"fine detail",
|
|
"negative": "blurry, low quality, low resolution",
|
|
},
|
|
{
|
|
"title": "Quiet geometry",
|
|
"category": "Architecture",
|
|
"tags": ["minimal", "warm_light", "editorial"],
|
|
"positive": "Monolithic sandstone pavilion in a vast desert, sweeping dunes, "
|
|
"warm terracotta, late afternoon sun, architectural photography, quiet "
|
|
"geometry",
|
|
"negative": "plastic, harsh shadows, noise, watermark",
|
|
},
|
|
{
|
|
"title": "Somewhere in the pines",
|
|
"category": "Landscapes",
|
|
"tags": ["forest", "mist", "peaceful"],
|
|
"positive": "A quiet cedar cabin above a still forest lake, towering "
|
|
"evergreens, soft morning mist, muted jade and moss, a small amber window, "
|
|
"cinematic still",
|
|
"negative": "oversaturated, blurry, low quality, text",
|
|
},
|
|
{
|
|
"title": "After the rain",
|
|
"category": "Environments",
|
|
"tags": ["neon", "night", "cinematic"],
|
|
"positive": "Rain-soaked city street at night, blue neon and rose "
|
|
"reflections, deep perspective, cinematic science fiction, solitary "
|
|
"architecture, detailed atmosphere",
|
|
"negative": "blurry, low resolution, watermark, oversaturated",
|
|
},
|
|
{
|
|
"title": "The long way home",
|
|
"category": "Illustration",
|
|
"tags": ["space", "minimal", "concept_art"],
|
|
"positive": "An isolated lunar research outpost, a luminous planet above the "
|
|
"horizon, sweeping pale terrain, quiet science fiction, finely detailed "
|
|
"concept art",
|
|
"negative": "blurry, low quality, watermark",
|
|
},
|
|
{
|
|
"title": "Between sea and sky",
|
|
"category": "Landscapes",
|
|
"tags": ["coast", "soft_light", "peaceful"],
|
|
"positive": "A solitary lighthouse on a rugged coastal cliff, sea mist, "
|
|
"glints of morning light on turquoise water, atmospheric perspective, fine "
|
|
"art landscape",
|
|
"negative": "oversaturated, flat lighting, watermark, text",
|
|
},
|
|
)
|
|
PAIR_ONLY_TAG = "pair_only_detail"
|
|
ARCHIVED_ONLY_TAG = "archived_pair_detail"
|
|
NEGATIVE_ONLY_TAG = "legacy_negative_detail"
|
|
UNUSED_TAG = "unused_vocabulary"
|
|
MAX_EXTRA_TAGS = 300
|
|
MAX_EXTRA_CATEGORIES = 200
|
|
_TABLES = (
|
|
"image_prompt_pairs",
|
|
"prompt_pair_tags",
|
|
"prompt_pairs",
|
|
"generated_images",
|
|
"prompt_tags",
|
|
"tags",
|
|
"prompt_library_state",
|
|
"prompts",
|
|
)
|
|
# Every owner row a vocabulary command could touch, in a stable order, for
|
|
# before/after comparisons; prompt text, pairs and images must never change.
|
|
_SNAPSHOT = {
|
|
"tags": "SELECT id, name FROM tags ORDER BY id",
|
|
"prompt_tags": "SELECT prompt_id, tag_id FROM prompt_tags ORDER BY 1, 2",
|
|
"prompt_pair_tags": "SELECT pair_id, tag_id FROM prompt_pair_tags ORDER BY 1, 2",
|
|
"prompts": "SELECT id, text, category, tags, updated_at FROM prompts ORDER BY id",
|
|
"prompt_pairs": "SELECT id, positive_prompt_id, negative_prompt_id, category,"
|
|
" archived, updated_at FROM prompt_pairs ORDER BY id",
|
|
"generated_images": "SELECT id, prompt_id, image_path FROM generated_images"
|
|
" ORDER BY id",
|
|
"image_prompt_pairs": "SELECT id, image_id, pair_id FROM image_prompt_pairs"
|
|
" ORDER BY id",
|
|
}
|
|
|
|
|
|
def seed_vocabulary(
|
|
api: PromptManagerAPI, root: Path, extra_tags: int = 0, extra_categories: int = 0
|
|
) -> dict:
|
|
"""Replace this server's disposable rows with the concept vocabulary."""
|
|
db = api.db
|
|
with db.model.get_connection() as conn:
|
|
for table in _TABLES:
|
|
conn.execute(f"DELETE FROM {table}")
|
|
for name in ("output", "input"):
|
|
shutil.rmtree(root / name)
|
|
(root / name).mkdir()
|
|
epoch = db.get_execution_epoch()
|
|
recipes = []
|
|
for recipe in CONCEPT_RECIPES:
|
|
receipt = db.create_library_recipe(
|
|
recipe["positive"],
|
|
recipe["negative"],
|
|
expected_epoch=epoch,
|
|
parent_metadata={
|
|
"title": recipe["title"],
|
|
"category": recipe["category"],
|
|
"tags": list(recipe["tags"]),
|
|
},
|
|
)
|
|
recipes.append(
|
|
{"positive_id": receipt["positive"]["id"], "pair_id": receipt["pair"]["id"]}
|
|
)
|
|
first = CONCEPT_RECIPES[0]["positive"]
|
|
# A tag carried only by an active saved pair counts its Library parent.
|
|
db.save_prompt_pair(first, "grain, banding", tags=[PAIR_ONLY_TAG])
|
|
# A tag carried only by an archived pair is hidden from Library Browse.
|
|
archived = db.save_prompt_pair(
|
|
CONCEPT_RECIPES[1]["positive"], "lens flare", tags=[ARCHIVED_ONLY_TAG]
|
|
)
|
|
db.archive_prompt_pair(archived, True)
|
|
# A legacy tag on a negative-only component is never a Library parent.
|
|
with db.model.get_connection() as conn:
|
|
negative_id = conn.execute(
|
|
"SELECT negative_prompt_id FROM prompt_pairs WHERE id=?",
|
|
(recipes[2]["pair_id"],),
|
|
).fetchone()[0]
|
|
db.update_prompt_metadata(negative_id, tags=[NEGATIVE_ONLY_TAG])
|
|
db.create_vocabulary_tag(UNUSED_TAG, expected_epoch=epoch)
|
|
for number in range(extra_tags):
|
|
db.create_vocabulary_tag(f"bulk_tag_{number:04}", expected_epoch=epoch)
|
|
# Each extra category is carried by one minimal Library recipe, after the concept's.
|
|
for number in range(extra_categories):
|
|
db.create_library_recipe(
|
|
f"Bulk category recipe {number:04}",
|
|
"",
|
|
expected_epoch=epoch,
|
|
parent_metadata={
|
|
"title": f"Bulk {number:04}",
|
|
"category": f"bulk_category_{number:04}",
|
|
},
|
|
)
|
|
with db.model.get_connection() as conn:
|
|
tags = [row[0] for row in conn.execute("SELECT name FROM tags ORDER BY id")]
|
|
api.invalidate_gallery_cache()
|
|
return {
|
|
"epoch": epoch,
|
|
"recipes": recipes,
|
|
"archived_pair_id": archived,
|
|
"negative_only_id": negative_id,
|
|
"tags": tags,
|
|
"pair_only_tag": PAIR_ONLY_TAG,
|
|
"archived_only_tag": ARCHIVED_ONLY_TAG,
|
|
"negative_only_tag": NEGATIVE_ONLY_TAG,
|
|
"unused_tag": UNUSED_TAG,
|
|
}
|
|
|
|
|
|
def vocabulary_snapshot(api: PromptManagerAPI) -> dict:
|
|
"""Plain rows of every vocabulary owner table."""
|
|
with api.db.model.get_connection() as conn:
|
|
return {
|
|
name: [list(row) for row in conn.execute(query)]
|
|
for name, query in _SNAPSHOT.items()
|
|
}
|
|
|
|
|
|
def register_vocabulary_commands(
|
|
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path
|
|
) -> None:
|
|
"""Register only fixed seed, snapshot and backup commands; no paths or SQL input."""
|
|
|
|
@routes.post("/__test__/vocabulary/reset")
|
|
async def reset(request: web.Request) -> web.Response:
|
|
data = await request.json() if request.can_read_body else {}
|
|
if not isinstance(data, dict) or set(data) - {"extra_tags", "extra_categories"}:
|
|
raise web.HTTPBadRequest()
|
|
extra = data.get("extra_tags", 0)
|
|
categories = data.get("extra_categories", 0)
|
|
for value, limit in (
|
|
(extra, MAX_EXTRA_TAGS),
|
|
(categories, MAX_EXTRA_CATEGORIES),
|
|
):
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
raise web.HTTPBadRequest()
|
|
if not 0 <= value <= limit:
|
|
raise web.HTTPBadRequest()
|
|
if request.query:
|
|
raise web.HTTPBadRequest()
|
|
manifest = await api._run_in_executor(
|
|
seed_vocabulary, api, root, extra, categories
|
|
)
|
|
return web.json_response({"success": True, **manifest})
|
|
|
|
@routes.get("/__test__/vocabulary/snapshot")
|
|
async def snapshot(request: web.Request) -> web.Response:
|
|
if request.query:
|
|
raise web.HTTPBadRequest()
|
|
rows = await api._run_in_executor(vocabulary_snapshot, api)
|
|
return web.json_response(rows)
|
|
|
|
@routes.get("/__test__/vocabulary/backup")
|
|
async def backup(request: web.Request) -> web.Response:
|
|
"""A database copy for the real restore route, which rotates the epoch."""
|
|
if request.query:
|
|
raise web.HTTPBadRequest()
|
|
path = root / "owned-vocabulary-backup.db"
|
|
created = await api._run_in_executor(api.db.model.backup_database, str(path))
|
|
if not created:
|
|
raise web.HTTPInternalServerError()
|
|
return web.Response(
|
|
body=path.read_bytes(), content_type="application/octet-stream"
|
|
)
|