Compare commits

...
33 Commits
Author SHA1 Message Date
DemonGatanjieuandClaude Fable 5.2 9956987961 Say what is off with a run in one click each
Under a run marked not quite right the user picks what is off: the
hands, the face, the framing, too dark and more (ai_run_control.ISSUES).
The AI gets it in words in the user_verdict step; while it has not
fetched the step, a changed word corrects it in place. An AI that is not
sent the pictures goes by it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 19:15:03 +08:00
DemonGatanjieuandClaude Fable 5.2 823064df04 Fix the message box: import the Hide button it draws
743a2bc drew the button without importing it, so a task did not open.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 19:14:47 +08:00
DemonGatanjieuandClaude Fable 5.2 743a2bc33a Let the user hide a few words inside a text the AI reads
The gist of the prompt box and a message of a private task went to the AI
whole or not at all. The Hide button marks the selected words; seal_marked
turns the marked words into tokens when the text is handed in, and the
page shows them again as the words they stand for.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 19:06:44 +08:00
DemonGatanjieuandClaude Fable 5.2 6560347827 Seal the names the user gave: workflow cards, saved prompts, their tags
A private task still sent the names of workflow cards and saved prompts,
their tags, and which card or combo a picture was made with. Such names
often say the model or what is drawn. They are tokens now, like a combo's
name; the AI uses a card or a combo by its token.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 19:04:08 +08:00
DemonGatanjieuandClaude Fable 5.2 f600379dac Show what a private task's AI was sent, say what privacy mode cannot cover
Privacy mode asked to be believed: nothing showed the user what had gone
out, and nothing said that it filters Anomalous's answers only.

- ai_sent.py keeps each call of a private task as it went: the arguments
  the AI wrote (tokens) and the answer's text after ai_privacy.guard. A
  line per call beside the task, the newest 300, removed with the task.
- The task's menu opens "What the AI was sent" (ui_ai_sent.js): the calls,
  and a box that looks for a word in all of them.
- ai_callers.reads_disk names the AI apps that read files and run commands
  by themselves. A private task run by one says under its header that such
  an app can open the output folder and the task records by itself; the
  switch's hints and the guide say the same.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 18:53:17 +08:00
DemonGatanjieuandClaude Fable 5.2 99ee111c81 Give a private task's AI the gist to read, keep its model names as tokens
Privacy mode left the AI blind twice over: the whole prompt was a token, so
it could not help with light, lens or style, and model and LoRA file names
still went out as written.

- The prompt box has a first field, the gist: the picture in words the user
  writes for the AI to read. seal_answer hands it on as written beside the
  tokens of the private prompts; either alone can be handed in.
- ai_sealed_models.py: while the Gates window's "Model names in a private
  task" is on (the default), each model of the library is one token ([M1],
  sealed as its model_id) however an answer writes it: model_id, loader
  name, file name in a text, card name. A model's own entry drops name,
  file, folder, links and hash and keeps type, base model, the author's
  settings and the user's note. expand writes a model token back as the
  model_id, as the loader's name among a node's values, as the card's name
  inside the AI's words. The task's menu lists what each token stands for.
- The model editor has "Note for the AI" (ai_note, note_for_ai in the
  tools, searched by search_models): what a model is for, in words the AI
  may read.
- A combo's name is sealed too: unless renamed it is made of its model's
  name and the start of its prompt.
- The privacy skill says how to write around a gist and how to pick and
  pass on model tokens.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 18:36:20 +08:00
DemonGatanjieuandClaude Fable 5.2 375b347eb6 Remove the AI's own progress bar and the divider from the panel blocks
A run's, a job sheet's and a blueprint's progress are shown by Anomalous
itself with real numbers; the AI's own bar could be stale, and the divider was
decoration. Both are refused now and gone from the guide and the page, with
the "passed" greying that only served the bar. A panel kept with such a block
is still added to; the page leaves a block it no longer draws out.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 01:38:43 +08:00
DemonGatanjieuandClaude Fable 5.2 d6c2edb1a1 Offer only the bases to pick from, stop an AI's run with no prompt, ask before swapping a named model
Found in a Gemini Flash exam (recorded in tool-exam.md):
- A later step's mask preview (only the character redrawn) was taken for a base;
  picked, the rest enlarged the mask. A base is the decoded picture itself.
- Flash tries run_workflow with no arguments: on a blueprint just built that drew
  three bases of nothing and spent the three to pick from. A run that draws from
  an empty latent with every positive prompt empty is refused once.
- A Tile refused for not fitting: when the user named the model, the AI is told
  to ask before changing it.
- A decision's rejected option given as a sentence stays one option.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 21:13:34 +08:00
DemonGatanjieu 20b3d6344e Fix job sheet stop races and early result receipts 2026-11-10 21:02:29 +08:00
DemonGatanjieuandClaude c9c9e5b21e Shorten the Ways panel, tell the ways from their own files, stop an LLLite that does not fit
- The Ways panel lists the ways that can run here as short rows that open,
  then the skills, and folds the ways lacking a pack or a model at the bottom.
- The staged_picture skill keeps the judgment only: the ways of its uses come
  from each block's "when", listed with the skill for what can run here. Its
  answer had passed the 3,800 bytes an AI app shows in the chat (3,991); a test
  keeps every skill under it.
- An Anima LLLite made for 28-block (2B) models stops a run on a 40-block
  (2.9B) one: lllite_fit counts both from the files' headers, the AI's run is
  refused with the nodes to change, and an example with a Tile opens on a model
  it fits. The warning written into four ways and two examples is removed.
- What holds for a model family is written once: the profile's notes go with
  each Anima example for an AI, the examples window says it once for the user;
  a pack two nodes come from is named once.
- The Tile upscale is want="tile", no longer the name of another use.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-10 16:25:22 +08:00
Claude f85037e6f3 Merge branch 'feature/1.63' into feature/1.64 (v1.63.0)
# Conflicts:
#	CHANGELOG.md
2026-11-10 03:00:28 +00:00
DemonGatanjieuandClaude Opus 5.5 2b6b6d88eb Enlarge a blurred background as it is and redraw only the character
New way upscale_character (use "upscale", want "character"): an upscale model enlarges the whole picture 1.5x and
leaves the background alone; the character is cut out (refine_character's mask), only its crop (InpaintCrop, 20%
more around it, not resized) is redrawn with its own copy of the canvas's model, on Anima held by the optional
Tile at 0.7, and stitched back. In a comparison a whole Tile redraw drew the far tracks and trees sharp and lost
the depth; this kept the blur, and the crop took 153 s where the whole enlarged picture took 270 s.

The official example "Two passes" gains the version "Anima · blurred background, only the character redrawn"
(made by ComfyUI's own page, files blank, Anomalous's fingerprints left out). An example's packs are now looked
for among ComfyUI's nodes: only loaders were, so a pack of another node was always said to be missing. The
staged-picture skill gains the by-part way, as agreed with the user.

Anima's Tile LLLite fits the 2B Anima models only: on Harem 2.9B the run stops with "depth_embed slices
missing" (84 modules in the file, 120 in the model). The ways and the two Tile examples say so and what to do.
Both the way and the example ran through the real page on the test ComfyUI (Harem 1.5): 223 s for a whole run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 03:24:03 +08:00
DemonGatanjieuandClaude Opus 5.5 3a49a58cb3 Hold the lines with a Tile when refining only the character or the background on Anima
On an Anima model the two ways put an animaTileRepair LLLite (0.7, the first 80% of the steps, as the Tile
upscale) on the second model and redraw at 0.7: in a test at 0.70 without it the background's windows vanished
and a bag was redrawn as another, with it they stayed and only the look changed. The Tile node is optional: a
block's "optional_lllite" is filled like its optional checkpoints, and without the file (or the pack) the node
goes and the AI is told to redraw at 0.45. Built through the real page on the test ComfyUI: nothing changed
outside; how much 0.7 changes follows the canvas's sampler (euler about 5%, dpmpp_2m_sde about 8%).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 02:15:14 +08:00
DemonGatanjieuandClaude Opus 5.5 f0a026142a Add refining only the character or only the background with another model
Two more ways under "Redraw with another model" (want: character, background). The character is cut out
(InSPyReNet, made solid, grown 12 px); the character way blurs that mask so the second model's redraw fades out
at the edge (Differential Diffusion), the background way redraws its inverse; both paste the redraw back through
the mask, so the rest of the picture stays the same to the pixel. Denoise 0.45 from GPT's comparison of
2026-11-10. Both built through the real page on the test ComfyUI on Anima (Harem 2.9B, RealSkin, Kirazuri),
1440x1024: outside the soft edge and outside the background nothing changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 01:38:42 +08:00
DemonGatanjieuandClaude Opus 5.5 9e0062f331 Give voice characters a cover, and lay their cards out like a profile
A click on a card's avatar picks a picture of the gallery or the computer; the
page cuts it square (a tall one keeps its top) and the server keeps it small in
user/anomalous/voice_covers, one per character's name, never in Anomalous_TTS's
folders; one taken away goes to the Recycle Bin. Without one, the name's first
letter shows, and "Change the cover" shows over it under the pointer. The card:
cover, name big, version in grey, one blue line of facts, plain buttons, voices
as thin-line rows; flat, no glow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:32:28 +08:00
DemonGatanjieuandClaude Opus 5.5 edc9831767 Say plainly that a model's author text unfolds
Folded, a blue "Show all" sits under it ("Less" when open) and the text lights up
under the pointer; its title reads as a heading. The user found the grey arrow
alone easy to miss.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:06:14 +08:00
DemonGatanjieuandClaude Opus 5.5 1973d0fbe1 Translate a model's author text, and keep it folded as last left
Its author's text from Civitai opens folded or open as the user last left it. A
Translate beside it (English on the Chinese window, Chinese or Japanese on the
English one) sends it paragraph by paragraph through the prompt translators
(DeepL with a key, Google, MyMemory; long pieces cut smaller) and keeps the answer
in user/anomalous/translations by the text's hash, never beside the models, so it
opens at once next time. A line that is the model's own name stays as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 16:54:59 +08:00
DemonGatanjieuandClaude Opus 5.5 4b5f385cdd Lay out the model details like a profile
The name big and its Civitai version split off under it in small grey; one small
accent line of type, size, base and the Civitai link (the Size/Base labels were
English in the Chinese window); Add node the one filled button, the rest plain
words. Trigger words, the author's settings, the files it needs, its versions and
its file are one list of rows parted by thin lines; its author's text starts
folded. Size and space carry it, not boxes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 16:34:43 +08:00
DemonGatanjieuandClaude Opus 5.5 6ff438751c Lay the blueprint out in the task's flow, done steps lit and coming ones dashed
Once an AI handed a blueprint, the flow over the task gets a second line: the
steps kept with their models and picking a base, read from the card and the runs
after it (bases run, pick, whole run), so the AI does nothing more for it. It
shows whether the user was asked or not; a click goes to the step. A job sheet's
run shows how far it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:45:31 +08:00
DemonGatanjieuandClaude Opus 5.5 cdc1b5ec53 Record the canvas hand-off exam
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:16:10 +08:00
DemonGatanjieuandClaude Opus 5.5 c890627961 Hand the canvas or a node to an AI from the right-click menu
"Hand to AI..." in a node's and the empty canvas's menu opens ready wishes (run 4
and pick, find what is wrong, explain each step, add an enlargement and face and
hand fixes; explain or tune a node) and the line to change. It goes to the task an
AI is on as a message, or starts a task about the canvas: the task's reads, runs
and copy start from that tab even with another in front. A node's saved pictures
can go along, and a failed run offers "Ask the AI" with ComfyUI's words. The AI's
job sheet now follows the task's tab like a run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:09:27 +08:00
DemonGatanjieuandClaude Opus 5.5 ce94af765a Say before a run when the prompt is in a language the model cannot read
A Chinese prompt on an English-only model (SD 1.5, SDXL, Pony, Illustrious, Flux,
Chroma: marked "reads": "English" in model_profiles.json) draws as if it were
nearly empty: in an exam the pictures followed nothing of it. The AI's run and job
sheet are refused once and told to write it in English; called again unchanged
they run. The job sheet's Start warns once and runs on the second press. The once
is kept apart for the AI and the user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:59:02 +08:00
DemonGatanjieuandClaude Opus 5.5 832e8eb7b1 Point the AI to the job sheet; a sheet's prompt fills rows without one
Flash ran five prompts one call at a time and took wait_seconds for the rest
between pictures. run_workflow's title and description now name the job sheet,
wait_seconds says it is no rest, the manual has a job_sheet topic, and a tool's
name asked as a topic points to the tool. With a sheet, prompt is the prompt of
rows that have none instead of an error. Both exams are in tool-exam.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 06:18:14 +08:00
DemonGatanjieuandClaude Opus 5.5 5c0e9ea133 Add the job sheet: many runs of one canvas, from the user or the AI
A card on the canvas (right-click > Job sheet) takes many prompts, a line each,
the runs of each, a rest between pictures and the same seeds if wanted; with no
prompt it runs the canvas as it is. The page runs it from copies of the workflow
kept at the start, as saves run, so the canvas stays as it is; pause, go on and
stop from any page, a failed or interrupted run pauses it, a reload goes on and
another page takes over a sheet whose page is gone. run_workflow(sheet=...) lets
an AI hand over prompts or settings by name; the sheet is that call's run step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 04:36:40 +08:00
DemonGatanjieuandClaude Opus 5.5 f96a3d1056 Make plans what the AI reaches for, keep the user's base pick, take models past Tile patches
From six Gemini 3.8 Flash runs of one question (Harem 1.5, then a Kirazuri v4 Tile redraw, then
faces and hands), recorded in tool-exam.md:
- begin_task's ways and the empty-canvas hint name add_nodes(plan=[...]); a route without its
  models and "several samplers" say what to do instead.
- A plan is two steps or more (no one-step probe card for the user); a model may be named by its
  name when only one main model has it; a way for a handed-over picture (fix_hands) gives way to
  its use's way that goes on from the step before.
- The user's rule is kept in run_workflow: a plan's first run is three bases, and the whole is
  refused once until they pick (an AI told "go on without" calls again).
- canvas_roles takes a "...model" path past size-bound patches (LLLite, ControlNet, T2I-Adapter):
  face detailing after a Tile redraw took the LLLite-patched model and crashed ComfyUI.
Last run: plan, build, three bases, pick, the whole from the picked base, no crash; 27 calls,
4 failed (each an empty first try).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:41:01 +08:00
DemonGatanjieuandClaude Opus 5.5 e63965b68b Show the AI's plan as a blueprint card and build it when the user says so
add_nodes(plan=[...]) takes the big steps of a picture (use/want as for add_nodes, a model for the
first step or a block's own loader). api/mcp_plans.py resolves them to ready-made blocks and
shows a plan card (panel block "plan", only Anomalous makes it). The user drops steps but the
first, swaps a step's model for one of its family listed with it, and says build, or writes what
to change; api/ai_plans.py turns a build into the steps kept and keeps "build without asking"
(ai_plans.json, POST /anomalous/ai_plans/auto). Built by block_chain.py on a new tab, with
base_only previews next when there is a second step; a card answered after the AI stopped
waiting is built by add_nodes(route=<its panel id>). web/modules/ui_ai_plan.js draws the card;
the AI apps' own card leaves it to Anomalous.

Found in the live check and fixed: the steps kept did not reach the AI (get_answers hands over
named fields only); a test now goes through the real hand-over. Checked on the test ComfyUI: a
four-step plan on two Anima models, step 3 dropped, built as picture, Tile redraw and upscale,
each on the step before's picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 01:28:07 +08:00
DemonGatanjieuandClaude Opus 5.5 8ecc261949 Let ready-made blocks say what they hand the next one, and build them as a chain
Every block in api/blocks names what it gives a block after it ("gives": its sampler and its
output node; {} for one that changes the step it is put on) and its own model loader
("own_model"). api/block_chain.py builds a list of steps on a new tab: the picture first, each
next block anchored to what the step before gave, by near_node_id and a new page-only
save_node_id (canvas_roles.js findRoles pins the output node), so a canvas with several
samplers and outputs is no guess. add_nodes' answer names the block it built.

Redraw with another model and face/hand detailing now take the step before's picture
("$save.images") instead of the sampler's decode, which after an enlargement is the older
picture. The two-model routes are built as two-step chains; routes.json no longer names the
loaders.

Checked through the real page on the test ComfyUI: the Tile route on two Anima models (base on
Harem 1.5, Tile redraw on Kirazuri v4, from the base's picture and prompts), then face and hand
detailing after it took the Tile step's colour-matched picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:42:56 +08:00
DemonGatanjieuandClaude Opus 5.5 0f43385493 Name every guide topic when the AI guesses a wrong one, and record the exam
Gemini 3.8 Flash on the isolated test ComfyUI after read_guide: a how-to question (7 calls, 0 failed), a picks panel built from the guide's fields (14, 1: a guessed topic name), standard question 3 on scenery (18, 2). 39 calls, 8% failed, no detours (1.63's standard exam: 16%). A wrong topic now lists the topics, so no second call is needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:27:30 +08:00
DemonGatanjieuandClaude Opus 5.5 6586c3766e Give the AI Anomalous's manual on demand, and free room in the tool list
read_guide (api/mcp_guide.py) hands the AI one topic at a time: the README's parts between
<!-- guide:name --> marks (both languages, same names; the Chinese one carries the words the
Chinese window shows), Model Check's rules by section, and the fields of show_panel's blocks.
Long topics come in parts that fit an answer. Nothing is written twice: the topics are the
README's and the rules' own text.

To make room for what 1.64 adds next: show_panel names its blocks and the guide has their
fields (a bad block's error points there), update_panel's details moved there too,
download_status became run_status(downloads=true), track_run's text is shorter. The tool list
went from 43,957 to 42,574 characters with one tool more (53 tools); show_panel no longer needs
a longer-description exception in the budget test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:10:51 +08:00
DemonGatanjieuandClaude Opus 5.5 16a1bc9215 Keep the 1.64 changelog's language pairs together
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:30:18 +08:00
DemonGatanjieu 6cb8a2eb37 Merge branch 'feature/1.63' into feature/1.64 2026-11-08 23:30:04 +08:00
DemonGatanjieuandClaude Opus 5.5 c51fd11e03 Show a model's guessed words, its author's settings and the files it needs in its details
- ui_model_guide.js (above the description, out of ui_detail.js): Civitai's trigger words,
  else the words the LoRA's file suggests (dashed, said to be a guess), the settings its
  author's example pictures used, and for a diffusion models file of a known family the text
  encoder and VAE it needs with the file of that name found here; one GET
  /anomalous/model_guide per page, a newer page cancels it.
- The models page's search finds the header words too.
- Checked on the isolated test ComfyUI with stand-in files: words read now and after an
  offline scan, three example pictures summed up, Anima's two parts found by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:52:33 +08:00
DemonGatanjieuandClaude Opus 5.5 414e76c935 Read a LoRA's guessed trigger words from its header, and tell the AI what a model needs
- header_words.py: modelspec.trigger_phrase, else the tags most training pictures share
  (ss_tag_frequency, generic ones left out); the scanner keeps them as
  anomalous_header_words when Civitai gives no trigger words, metadata gives headerWords,
  model search finds them.
- model_kind: Flux Schnell told from Dev (no guidance_in), Z-Image from Lumina 2 (caption
  embedder width, a GGUF by parameter count); RULES 3 so kept header answers are read again.
- api/model_guide.py + model_profiles.json: GET /anomalous/model_guide for the model details
  (example settings, header words, the text encoder and VAE a diffusion models file needs,
  found by name), and get_model's header_words and usage for the AI.
- model_advice.example_settings gives the author's example settings as values;
  author_examples says them in words as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:43:51 +08:00
172 changed files with 10498 additions and 817 deletions
+1
View File
@@ -164,5 +164,6 @@ ComfyUI 的内存、显存和 CPU 是留给出图的。插件闲着的时候应
- 架构文档只记录职责、数据流、契约和关键约束。颜色、像素值及审美描述以 CSS 为准,不每次 UX 更新都往架构入口追加长篇设计说明。
- 该更新哪份文档见第 0 节的表。规范以本文件为唯一正文,避免几份要求各自漂移。
- 完成前审查差异、死代码、临时文件和空白错误;保留现有文件编码及换行风格。
- README 里的 `<!-- guide:名字 -->` 是 AI 说明书(read_guide)的分节标记,中英两半名字和顺序一致;改 README 时保留它们,新增一节就两种语言各加一个。
**交付自查:** 新职责放对位置了吗?是否只有一个状态来源?每条关闭路径会释放资源吗?迟到请求会写错对象吗?旧代码和旧 CSS 清掉了吗?新文案已本地化吗?用户文件失败时还在吗?新接口会动文件或联网吗,只接受本机请求了吗?接口收了路径吗,读用户文件有上限吗?插件闲着时还占着 CPU、内存或显存吗?测试验证了实际行为吗?
+119 -17
View File
@@ -81,7 +81,8 @@ covered by the `styles.css` manifest.
a huge decode). `api/ai_run_control.py` stops a run (`stop_run`, the workbench's stop
button): only its own prompts leave the queue or are interrupted; the step ends `cancelled`
and a user's stop reaches the AI as a `stop` message. It also keeps the user's word on a
finished run (`set_verdict`; the buttons under its pictures are `web/modules/ui_ai_verdict.js`),
finished run (`set_verdict`; the buttons under its pictures are `web/modules/ui_ai_verdict.js`, with what
is off a click each under "not quite right", `ISSUES`),
told to the AI as a `user_verdict` step. `tests/test_ai_run_watch.py`.
- `api/ai_run_map.py` reads a run's shape from the prompt ComfyUI queued (plumbing wired
through; boxes with a role and facts, links with ComfyUI's types) and what changed since the
@@ -104,7 +105,21 @@ covered by the `styles.css` manifest.
node's STRING inputs by ComfyUI's node list, prompt fields, picture names, any sealed text),
`expand` on a call's arguments, `seal_answer` for the user's prompt box (a "prompts" panel block,
`web/modules/ui_ai_prompt_box.js`, with the prompt_pose skill's words for the canvas's model;
the privacy skill tells the AI how to work). `web/modules/ui_ai_privacy.js` is the header's chip and the switch in its ⋯
its first field is the gist, the picture in words the AI may read, handed on as written but for the
words the user marked to hide, which go as tokens inside it; `web/modules/ai_hide_mark.js` is that
mark and its button, also in a private task's message box (`ai_sealed.seal_marked`);
the privacy skill tells the AI how to work). `api/ai_sealed_models.py` keeps a private task's model names too
(the Gates window's "Model names in a private task", on unless turned off): each model of the library is one
token ("[M1]", sealed as its model_id) however an answer writes it (model_id, loader name, file name in a text,
card name), its own entry loses what tells it apart and keeps type, base model, the author's settings and the
user's note for the AI (`ai_note`, set in the model editor, `note_for_ai` in the tools); `expand` writes a
model token back as the model_id, as the loader's name among a node's values, as the card's name inside the AI's words.
`web/modules/ui_ai_privacy.js` is the header's chip, the switch in its ⋯ and what the model tokens stand for.
`api/ai_sent.py` keeps what a private task's AI wrote and was answered, as it went (a line per call beside the
task, the newest 300, gone with it), and `web/modules/ui_ai_sent.js` is the window that lists it and looks for a
word in it (the task's ⋯ "What the AI was sent"). Privacy mode covers what Anomalous sends, not what an AI app
opens by itself: `ai_callers.reads_disk` knows the apps that read files and run commands, and the head of their
private tasks says so (`ui_ai_privacy.privacyReach`)
(`tests/test_ai_privacy.py`, `tests/test_ai_sealed.py`).
`ai_pictures.mark_gone` marks a task's pictures no longer in the output folder (`gone`) on its
`GET`, which `ui_ai_images.gonePicture` shows instead of a broken picture.
@@ -184,6 +199,12 @@ covered by the `styles.css` manifest.
`more_names`); mcp_server.py runs it after the step is recorded (`tests/test_mcp_fit.py`).
`api/picture_mask.py` is what LoadImage gets from an input or preview picture (size, MASK area)
for get_image_info.
`api/mcp_guide.py` is read_guide, Anomalous's manual for the AI a topic at a time, so the tool list
keeps names and the details come when needed: the README's parts between `<!-- guide:name -->`
marks (both languages carry the same names; keep them when editing the README), Model Check's
rules by section, and the fields of show_panel's blocks (show_panel itself only names them).
Longer topics come in parts that fit an answer (`tests/test_mcp_guide.py`). run_status with
`downloads=true` tells the model downloads (the former download_status).
`api/mcp_look.py` is zoom_image, the AI's closer look: part of a picture in the output, input
or temp folder shown larger, the same part of a second picture beside it, read only (so an AI
need not crop or compare pictures with code of its own).
@@ -198,6 +219,14 @@ covered by the `styles.css` manifest.
the Civitai record a scan keeps), and add_lora / set_model's `base_model_note` when a LoRA and
the main model on its line (`graph_splice.js` loraChain / modelLineStart, reported by the page)
are for different base models. `tests/test_model_advice.py`.
`api/model_guide.py` is what using a model takes besides its file: the model details'
`GET /anomalous/model_guide` (type, folder index, subfolder, file name, never a path) and
get_model's `header_words` and `usage`. The author's example settings
(`model_advice.example_settings`), the words a LoRA's header suggests when Civitai gave none
(`header_words.py`), and from `api/model_profiles.json` (data only: per base model family) the
text encoder and VAE a diffusion models file needs, with the file of that name this ComfyUI
has, and notes for the AI that say only what was tested here or ComfyUI's own templates do.
`tests/test_model_guide.py`.
`api/mcp_canvas_edit.py` holds the tools that build a workflow where the user watches:
`find_nodes` reads this ComfyUI's node types from its own `/object_info` (kept two
minutes); `add_node`, `connect_nodes`, `set_values`, `remove_nodes` and `new_workflow` (a
@@ -229,10 +258,62 @@ covered by the `styles.css` manifest.
swapped with set_model), before canvas_wiring splices it in (`tests/canvas_roles.mjs`). A "standalone" block (the
picture from words) names no role: it is built on the model `add_nodes(model=…)` names or the task's picked one, its
family's own files found beside it ("files": Anima's text encoder and VAE), and only on a canvas making no picture.
Blocks chain: each says what it gives the next (its sampler and output node, "gives") and names its own
loader ("own_model"); `api/block_chain.py` builds a list of steps on a new tab, the picture first, each next
block anchored to what the step before gave (near_node_id and the page-only save_node_id, so several samplers
and outputs are no guess), a step's model on the block's own loader with its LoRAs (`tests/test_block_chain.py`).
`api/mcp_plans.py` is `add_nodes(plan=…)`: the AI's big steps for a picture resolved to ready-made blocks
(use/want as for add_nodes, a model for the first step or a block's own loader), shown as a plan card
(蓝图, panel block "plan", which only it makes; `api/ai_plans.py` keeps the card's rules: the user drops
steps but the first, swaps a step's model for one of the same family listed with it, says build or what
to change, and "build without asking" in ai_plans.json, `POST /anomalous/ai_plans/auto`), built by
block_chain.py when they say build; a card answered after the AI stopped waiting is built by
`add_nodes(route=<its panel id>)` (`tests/test_mcp_plans.py`). `web/modules/ui_ai_plan.js` draws the card.
A plan is two steps or more; a model may be named by its file or name when only one main model has it; a way
for a picture the user hands over (fix_hands) gives way to its use's way that goes on from the step before.
Once a plan of several steps is built, the user's rule holds in `mcp_actions.run_workflow`: its first run is
three bases (task "plan_previews"), and the whole waits for their pick ("plan_pick": refused once without one).
canvas_roles.js takes a "…model" path past size-bound patches (LLLite, ControlNet, T2I-Adapter) on that line.
`api/mcp_routes.py` (data: `api/routes.json`) turns two picked main models into choices the user makes: `ask_user(routes=true)`
shows the routes this ComfyUI can build for them, a saved card using both first, its answer says what to call, and
`add_nodes(route=…)` builds one from the ways on a new tab; `get_toolbox` lists each picked model's cards as its examples.
`add_nodes(route=…)` builds one from the ways on a new tab (a two-step chain); `get_toolbox` lists each picked model's cards as its examples.
An AI placing nodes itself is a new route (`ai_gates.route_rule`): one reminder a task, or the user's OK.
- The job sheet (任务单): many runs of one canvas one after another, each row a prompt, settings by name or the canvas
as it is, with its runs, and a rest between pictures if wanted (the viewers' prompt rotation and run interval).
`web/modules/ui_job_sheet.js` is its card on the canvas (a node's and the empty canvas's menu entry in `canvas_menu.js`),
`web/modules/job_sheet_run.js` runs it on the page as saves run, from detached copies of the workflow kept at the start
(the canvas stays as it is), the row filled in by `mcp_fill.js` fillPlan; with a rest the next run goes in once the one
before has left ComfyUI's queue, with none one waits behind the one going. `web/modules/job_sheet_plan.js` holds the
rules (`tests/job_sheet_plan.mjs`). `api/job_sheets.py` keeps the one sheet and where it stands, hears the page's
reports, passes pause / stop / go on to it (from any page, the workbench's stop or the AI's stop_run; with its page
gone another page takes it over) and writes a sheet the AI gave with `run_workflow(sheet=…)` on that call's run step,
so run_status and the workbench follow it like a run (`tests/test_job_sheets.py`). Clearing or replacing a stale
sheet stops its registered pending runs; a stop wins over a late pause report, and a queued run reported after
stopping is cancelled. Attaching the AI step carries over the sheet's recent pictures once. Interrupted and
removed runs count as unsuccessful (`tests/test_job_sheet_edges.py`).
- A model's author text from Civitai in its details opens folded as the user last left it and can be translated
into the window's language: `web/modules/detail_intro.js` (`tests/detail_intro.mjs`) keeps the fold and picks the
language, `api/description_translation.py` translates it paragraph by paragraph through
`translation_routes.translate_chain` (DeepL with a key, Google, MyMemory; long pieces cut smaller) and keeps the
answer in user/anomalous/translations by the text's hash, never beside the models (`tests/test_description_translation.py`).
- Covers of the voice characters: `api/voice_covers.py` keeps one small square PNG per character's name (its
versions share it) in user/anomalous/voice_covers, never in Anomalous_TTS's folders, and takes one away to the
Recycle Bin; `web/modules/voice_covers.js` draws the cover or the name's first letter in a card's avatar and, on
a click, picks a picture from the gallery (`ui_ai_gallery_pick.js` pictureGrid) or the computer and cuts it
square, a tall one keeping its top (`tests/test_voice_covers.py`, `tests/voice_covers.mjs`).
- A prompt the canvas's model cannot read. `api/prompt_language.py` says whether the prompts a run sends are mostly
Chinese, Japanese or Korean while a loader's model is of a family marked "reads": "English" in `api/model_profiles.json`
(SD 1.5, SDXL, Pony, Illustrious, Flux, Chroma: CLIP and T5 read English only, and the picture would follow nothing).
`web/modules/prompt_language.js` asks it before the AI's run_workflow and job sheet (refused once: called again
unchanged, it runs) and before the job sheet's Start (said once) (`tests/test_prompt_language.py`). The AI's
run is refused once too when the canvas draws from an empty latent and every positive prompt box is empty
(`prompt_boxes.promptMissing`): an AI trying the tool out with no arguments drew pictures of nothing.
- An Anima LLLite on a model it was not made for. `api/lllite_fit.py` counts, from the two files' headers, the
blocks an LLLite has modules for and the blocks of the drawing model it is put on (28 for the 2B Anima models,
40 for the 2.9B ones): with more blocks than modules the pack stops the run with "depth_embed slices missing".
`web/modules/lllite_fit.js` reads the uses from the prompt a run would send (each LLLite node and the loader at
the top of its model line) and asks before the AI's run_workflow, which is refused with the nodes to change; an
example with a Tile opens on a model the Tile fits (`tests/test_lllite_fit.py`, `tests/lllite_fit.mjs`).
- Saves of a workflow. `api/workflow_saves.py` keeps them, a group for each canvas, found by the key in the workflow's
extra or by its file (AD-023), and finds a run's pictures in ComfyUI's history; `web/modules/snapshot_store.js` is the page's
side of its routes, every change going through it so what shows a group hears of it. `web/modules/ui_snapshot_bar.js` is the bar
@@ -256,7 +337,10 @@ covered by the `styles.css` manifest.
begin_task lists the ones on (name and a line), `get_toolbox(skill=…)` gives one, tool
descriptions only point at them; each one fetched is a "skill" step of the task (`note_use`); advice that depends on the canvas's main model comes in the
form for its size, its parameters counted from the file's header (`model_kind.diffusion_params`,
`model_advice.main_params`) (`tests/test_ai_skills.py`).
`model_advice.main_params`). A skill on choosing among blocks names their uses ("ways"): its answer lists
the blocks of those uses that can run here, each with when to take it, from the blocks' own "when"
(`reference_blocks.whens`), so its words hold only the judgment and no block is written into them;
every answer must fit `mcp_fit.BUDGET` (`tests/test_ai_skills.py`).
`web/modules/mcp_canvas_brief.js` is the canvas in brief for an AI (pure): bounded path chains and node lines,
the inputs a run needs that nothing feeds yet with the outputs that fit them, ComfyUI's
errors; every canvas change answers with what is still unwired. `api/mcp_situation.py` is
@@ -316,7 +400,8 @@ covered by the `styles.css` manifest.
and unsupported host request IDs are refused before queueing; wrappers/listeners are released.
`run_workflow(base_only=true)` queues the first pass alone (`web/modules/base_only.js` cuts each
queued prompt down to the samplers nothing samples before and what they feed, picture nodes made
PreviewImage, a base handed on unfinished finished and decoded; the canvas never changes); when it
PreviewImage, a base handed on unfinished finished and decoded; a picture made from the base, as the
mask a later step previews, is not one of them; the canvas never changes); when it
ends `api/ai_bases.py` puts the previews on a picks panel with their seeds, copies the picked one
into the output folder and holds its seed, which the task's next run puts on that sampler "fixed"
and keeps under every try. Where the rest takes the base's picture, that run takes the kept picture
@@ -389,10 +474,16 @@ covered by the `styles.css` manifest.
Options Anomalous makes name a page word (`label_key`). `tests/test_ai_repaint.py`.
`web/modules/ui_ai_gate.js` draws the card under a step that asks and the Gates window;
`ui_ai_flow.js` the flow over a task (its stages in order, each done, running, waiting or
failed; a stage leads to its first step). `ui_ai_handoff.js` is "Hand to AI" on a model's
failed; a stage leads to its first step; a job sheet's run with how far it is), and under it the blueprint's line
(`ai_task_view.js` planFlow: the newest plan card's kept steps, then picking a base, lit as the bases run, the pick
and the whole run after the card go; coming steps dashed; read from the steps, nothing asked of the AI). `ui_ai_handoff.js` is "Hand to AI" on a model's
page, an output image's inspector and a combo card: a task the user starts with `about`
(the model's `model_id`, the image, the combo's name; checked in `ai_tasks.start_task`),
which `begin_task` hands to the AI, and the sentence to paste into the AI app. It tells the page through the
which `begin_task` hands to the AI, and the sentence to paste into the AI app. `ui_ai_canvas_hand.js` is "Hand to
AI…" in a node's and the empty canvas's right-click menu: ready wishes for the canvas or the node, sent to the task
an AI is on now as a message or as a new task about the canvas (`about` {type: canvas, tab}: `ai_task_canvas.spec`
gives the page that tab as `from`, so the task's reads, runs and copy start from it, `mcp_task_tab.js`); a run that
fails on the page offers "Ask the AI" with ComfyUI's words. It tells the page through the
`anomalous.ai_task` event; `api/ai_tasks_routes.py` serves `/anomalous/ai_tasks` (list, one
task, start, rename or remove, a panel's answer, a message). The user can start a task first
(New task): it waits, and the next app that calls takes it over, so its work starts there
@@ -455,11 +546,12 @@ covered by the `styles.css` manifest.
which keeps what made the newest picture as a recipe, a combo or a saved prompt through the
keep menu's own `keepPictureAs`; its button counts what came since it was last seen
(`stage.outputsSeen`). `ui_ai_blocks.js` is the side column's Ways view (做法): the ready-made
blocks by job (GET /anomalous/ai_blocks, `reference_blocks.status`), each block's words from its
data file, greyed with the pack (a way into ComfyUI-Manager) or model it lacks, and a switch that
keeps it from the AI (POST, `user/anomalous/ai_blocks.json`); under them the skills
(`ai_skills.status`), each with the same switch (`user/anomalous/ai_skills.json`), those of the
task's kind only (a private task's, else the others), as the AI gets. For a run the AI did not wait
blocks that can run here by job (GET /anomalous/ai_blocks, `reference_blocks.status`), a short row each
(a line of its words from its data file, the rest once opened) with a switch that keeps it from the AI
(POST, `user/anomalous/ai_blocks.json`); then the skills (`ai_skills.status`), each with the same
switch (`user/anomalous/ai_skills.json`), those of the task's kind only (a private task's, else the
others), as the AI gets; last, folded, the blocks lacking a pack (a way into ComfyUI-Manager) or a
model, each saying what. For a run the AI did not wait
for (`ai_gates.run_wait` "call") the server marks the ended run `call_ai` until the AI is told,
and the lines over the message box are about it meanwhile. `ui_ai_side.js` is the
stage, the column on the right where the AI's panels are shown (the timeline keeps a card per
@@ -509,7 +601,7 @@ covered by the `styles.css` manifest.
this way rather than in its own chat.
Panels the AI draws (`show_panel`, `update_panel`): `api/ai_panels.py` checks every block
against a fixed set (heading, text, images from the output folder, models by id with their
covers, table, list, params, choices, input, progress, divider; plain strings of bounded
covers, table, list, params, choices, input; plain strings of bounded
length, nothing run or read as markup; the parts of a block: `api/ai_panel_parts.py`, also a
slider's or picks' `zoom`, the part of every picture shown enlarged) and keeps the panel as a step. `update_panel` preserves
the order already seen: new blocks append; omitted blocks remain; unchanged resubmitted
@@ -625,7 +717,13 @@ covered by the `styles.css` manifest.
node pack may read them there: `form`; an IP-Adapter carrying LoRA layers stays an
IP-Adapter; a file two loaders read from different folders, such as SUPIR or a whole CLIP,
is never `sure`); model import and the scanner's offline base model use it. Its rules are
checked in `tests/test_model_import.py`, layouts once misread included.
checked in `tests/test_model_import.py`, layouts once misread included. Like ComfyUI it tells
Flux Schnell from Dev (no guidance input) and Z-Image from Lumina 2 (the caption embedder's
width; a GGUF by its parameter count). `header_words.py` reads what a LoRA's header suggests
for its prompt when Civitai gives no trigger words: the maker's `modelspec.trigger_phrase`,
else the tags most training pictures share (kohya's `ss_tag_frequency`, generic ones left
out); the scanner keeps them in the `.info` (`anomalous_header_words`), metadata gives them as
`headerWords`, never inside `trainedWords` (`tests/test_header_words.py`).
- `recycle_bin.py` is the only way the plugin deletes the user's files (models, covers,
outputs, recipes, materials, notes, audio): to the system Recycle Bin, together, or not
at all (`TrashUnavailable` on drives without one). `api/trash.py` is the API's import of it
@@ -797,7 +895,8 @@ covered by the `styles.css` manifest.
page `script`) and Audio (shell page `audio-gallery`, `ui_audio_gallery.js`).
`ui_shell_nav.js` keeps the rail on Voices and reopens the view used last, from the
rail and from the 9 key; the rail stays within the nine number keys.
`ui_audio_studio.js` owns the Characters view: the character cards, preview playback,
`ui_audio_studio.js` owns the Characters view: the character cards (laid out like a profile: cover, name big,
version under it, one line of facts, plain buttons, voices as thin-line rows), preview playback,
tag copying, dragging a card header onto a node through the shared `bindMaterialDrag`
with `targetHint`/`rejectHint` telling the user what releasing does, each card's
"Voice-over" button (`owner.openScript(group)`), and its Delete character button
@@ -1050,7 +1149,8 @@ covered by the `styles.css` manifest.
card placeholder ergonomics (eliminating misleading unclickable text in favor of pure centered icon and status),
card action buttons (one-click canvas addition with plus icon, model metadata editor, direct precision scanner without wizard modal popups)
with absolute positioning cascades immune to tooltip target conflicts, vibrant hover affordance,
safe docked sidebar preservation upon node addition, and multi-type node dispatch; `ui_detail.js`
safe docked sidebar preservation upon node addition, and multi-type node dispatch; `ui_model_guide.js`
(the details' trigger words, header words, author settings and needed files); `ui_detail.js`
coordinates detail display (cover column, then the head from `model_detail_head.js`: back crumb, name, source line,
Add node / Edit / Folder / Outputs; the foot with the path, Copy and Delete, and Show in folder, in
`model_location.js`), `ui_model_editor.js` owns metadata editing (cover, name, source link, notes;
@@ -1129,7 +1229,9 @@ covered by the `styles.css` manifest.
prompts) and keeps the summary in step with widget edits using the same node rules; which
LoRAs a node loads is `recipe_loras.js`, and reading a node's boxes `recipe_widgets.js`.
`api/example_workflows.py` serves the examples Anomalous ships (`api/examples/*.json`: plans, a version per
model family, no model or picture named: filled in from what is here, what is missing said);
model family, no model or picture named: filled in from what is here, a version with a Tile on a model the
Tile fits, what is missing said; what holds for a whole family is written once, in `model_profiles.json` for
an AI and in the page's words for the user);
`ui_recipe_examples.js` is their one card and window on the Workflows page, and list_workflows gives those
that can run here to an AI when no saved workflow fits, and names them when one does. A long list goes without
settings: an AI app files an answer over about 4,000 characters away, and it may not read it.
+77
View File
@@ -1,5 +1,82 @@
# 📈 Anomalous Model Browser Changelog
## v1.64.0 — unreleased
- 任务单停止时,刚提交、迟到回报的运行也会取消,暂停回报不会覆盖停止请求;清除或替换失联任务单会同时停止它已登记的运行。很快完成、早于 AI 步骤绑定的图片会补回工作台,重复绑定不重复添图;中断或被移出队列的运行计入未成功次数。
- Stopping a job sheet also cancels runs reported just after the stop; a pause report cannot erase a stop request. Clearing or replacing a stale sheet stops its registered runs. Pictures completed before the AI step was attached are carried into the workbench without duplicates on reattachment; interrupted or removed runs count as unsuccessful.
- 没有 C 站触发词的 LoRA(自己炼的、从别处下的),扫描时会从模型文件里读出训练图片大多都带的标签(作者写了触发词就用作者的),和 C 站的触发词分开存,搜模型时也能用这些词搜到。
- For a LoRA without trigger words from Civitai (trained yourself, downloaded elsewhere), a scan reads from the file the tags most of its training pictures share (or the trigger phrase its maker wrote) and keeps them apart from Civitai's trigger words; model search finds them too.
- 从模型文件认底模时分得清 Flux.1 Dev 和 Schnell(Schnell 没有引导输入),也认得出 Z-Image 和 Lumina 2 这两种结构相近的模型:以前 Z-Image 的 GGUF 被当成 Lumina,safetensors 两种都认不出。已经扫过的模型重新扫描后才会更新。
- When the base model comes from the file itself, Flux.1 Dev and Schnell are told apart (Schnell has no guidance input), and Z-Image and Lumina 2, two models of one layout, are recognised: a Z-Image GGUF was taken for Lumina, and neither was recognised as safetensors. Models scanned before keep their old answer until scanned again.
- AI 查模型时多知道三件事:没有触发词的 LoRA 从文件里推测的词(标明是推测);分开加载的模型(Anima、Flux、Chroma、Qwen-Image、Z-Image、Lumina 2)还要哪个文本编码器和 VAE,你这里有没有这个名字的文件;这一类模型要注意什么,只写这里测过的和 ComfyUI 官方模板里的做法,比如 Anima 出多张要跑几次而不是调批量,Flux Dev 的 CFG 保持 1。
- Looking up a model, the AI learns three more things: for a LoRA without trigger words, the words guessed from its file (said to be a guess); for a model loaded in parts (Anima, Flux, Chroma, Qwen-Image, Z-Image, Lumina 2), the text encoder and VAE it needs and whether you have a file of that name; and what to mind with its kind of model, only what was tested here or ComfyUI's own templates do, such as running Anima several times for several pictures instead of raising the batch, and keeping Flux Dev at cfg 1.
- 模型详情里多了三样,放在描述上面:没有 C 站触发词的 LoRA 显示「文件头里的常用词(推测)」(虚线框,点一下复制,以前扫过的打开时现读);扫描时拿到作者例图的,显示「作者例图常用设置」(采样器、步数、CFG、尺寸,LoRA 还有权重),以前只有 AI 看得到;Anima、Flux 这类要分开加载的模型显示「还需要的文件」(文本编码器和 VAE),写明你这里有没有这个名字的文件。
- The model details show three more things above the description: for a LoRA without trigger words from Civitai, "Common words in its file (a guess)" (dashed, click to copy; a LoRA scanned before is read when opened); with the author's example pictures from a scan, "Settings of its author's examples" (sampler, steps, CFG, size and a LoRA's weight), which only the AI saw before; and for a model loaded in parts such as Anima or Flux, "Files it needs beside it" (text encoder and VAE), saying whether you have a file of that name.
- AI 能查 AMB 自己的说明书了(`read_guide`):哪个页面、哪个功能怎么用,模型检查的完整规则,按一节一节查,中文用户就照中文界面上的字教你。内容就是 README 和规则文档里的那几节,不另写一份。为了给以后的新功能腾地方,展示面板那段很长的格式说明也挪进了说明书,看下载进度并进了 `run_status`:给 AI 的工具清单从 43,957 字减到 42,600 字左右,还多了一个工具。
- The AI can read Anomalous's own manual (read_guide): how each page and feature works, Model Check's full rules, a section at a time, and guides a Chinese user with the words of the Chinese window. It is the README's and the rules' own sections, not a second copy. To make room for what comes next, the long format of show_panel's blocks moved into it and the download status into run_status: the tool list AI apps get went from 43,957 to about 42,600 characters, with one tool more.
- 现成的做法(积木)都写明了做完以后交给下一块什么(哪个采样器、哪张图),AMB 能把几块按顺序接起来,不再在画布上有好几个采样器和保存节点时去猜接哪个。"换模型精修"和"脸手细化"接在别的步骤后面时,处理的是上一步出的那张图(以前拿的是采样器刚解码的图,排在放大后面会去改放大前那张)。两个模型的现成路线也改用这种接法搭。
- The ready-made ways (blocks) now say what they hand the next one (which sampler, which picture), so Anomalous chains several in order instead of guessing when the canvas holds several samplers and outputs. Redraw with another model and face and hand detailing, put after another step, work on that step's picture (they took the sampler's fresh decode, so after an enlargement they changed the picture from before it). The two-model routes are built this way too.
- AI 工作台多了「蓝图」:AI 想用几块现成做法一步步画一张图时(比如先出图、换个模型精修、修脸和手、再放大),先把步骤画成一张卡给你看。每一步写着做法和模型,第一步以外都能去掉,有模型的步骤能从同一类模型里换一个;点「照这个搭」AMB 就在新标签页一次搭好、连好线,有第二步时先出三张小样让你挑。想改就点「我想改一下」写一句话交给 AI。勾上「以后直接搭,不用问我」后不再问,卡上的「以后先问我」能改回来。整条要等你挑好底图才跑:AI 抢先跑会被拦一次并告诉它原因。
- The AI workbench has a Blueprint: when the AI means to draw a picture in several ready-made steps (the picture, a redraw with another model, faces and hands, an enlargement), it shows them on a card first, each with its way and model. Any step but the first can be left out, and a step's model swapped for another of its kind; "Build it like this" builds and wires the whole of it on a new tab, with three base previews to pick from first when there is a second step. "Change something" hands the AI your words instead. With "From now on build without asking me" ticked it no longer asks; "Ask me first next time" on the card turns that off. The whole plan waits for the base you pick: an AI that runs it before is stopped once and told so.
- 新的「任务单」:画布右键「任务单(批量换提示词)」。一次贴很多条提示词(一行一条),每条跑几次、每张之间歇几秒、要不要每条用同一组种子,然后「开始」。跑的是开始时那张画布的副本,你的画布不动;可以暂停、继续、停止,出错会自己暂停并写明原因,刷新页面会接着跑。一条也不写,就是照画布原样隔一段时间跑一张。AI 也能排任务单:跟它说"这几条提示词各跑 4 张"或"cfg 4、5、6 各跑 3 张",它交给 AMB 一张张跑,工作台里看进度和图。
- New "Job sheet": right-click the canvas, "Job sheet (many prompts)". Paste many prompts (a line each), say how many runs each gets, the seconds to rest between pictures and whether every prompt gets the same seeds, then Start. It runs copies of the canvas as it was at the start, so your canvas stays as it is; pause, go on and stop any time, a failed run pauses it with the reason, and a reload goes on. With no prompt it runs the canvas as it is, with a rest between pictures. An AI can hand over a sheet too ("these prompts, 4 each"; "cfg 4, 5 and 6, 3 each"): AMB runs it one by one and the workbench shows how far it is and its pictures.
- 模型详情页的开头重新排了:大字写模型名,版本放在下面一行灰色小字(原来那串长文件名挪到下面的清单里),一行蓝色小字写类型、大小、底模和 Civitai 链接,按钮只留「插入节点」一个实心的。训练词、作者常用设置、还需要的文件、版本、文件名排成一张细线隔开的清单;作者的介绍默认收起,点开再看。中文界面里那行 “Size:” “Base:” 也改成了中文。
- 角色语音的角色卡可以设封面了:点卡片左边的头像,从图库里点一张或从电脑选一张,自动裁成方形(竖图保留上面一截),同一个角色的几个版本共用。没设封面时显示名字的第一个字。卡片也按模型详情的思路重新排了:大字角色名、灰色版本、一行蓝色小字,按钮变成文字按钮,声音列表用细线隔开。
- Voice characters can have a cover: click a card's avatar and pick a picture of the gallery or the computer; it is cut square (a tall one keeps its top) and a character's versions share it. Without one, the name's first letter shows. The cards are laid out like the model details too: the name big, the version in grey, one small blue line, plain buttons, voices parted by thin lines.
- 模型详情里「作者的介绍」:记住你上次是展开还是收起;英文的介绍旁边多了「翻译」,一段一段翻成中文(有 DeepL 的 key 用 DeepL,没有用谷歌,再不行用 MyMemory,走设置里的代理),「看原文」切回去。翻过的存在 AMB 自己的文件夹里,下次打开直接显示。
- In a model's details, its author's text remembers whether you left it open or folded, and English text gets a Translate (Chinese text on the English window too): paragraph by paragraph through DeepL with your key, else Google, else MyMemory, by your proxy; Original goes back. A translation is kept in AMB's own folder and shows at once next time.
- The model details open like a profile: the model's name big, its version under it in small grey (the long file name moved to the list below), one small blue line with its type, size, base and the Civitai link, and only Add node filled among the buttons. Trigger words, its author's settings, the files it needs, its versions and its file sit in one list parted by thin lines; its author's text from Civitai starts folded.
- 工作台时间线顶上的流程,在 AI 交过蓝图时多一行:蓝图的每一步(带模型名)和“挑一张小样”,做完的点亮、正在做的闪、还没做的是虚线框,点一下跳到对应的那一步。AI 直接搭(你设了不用问)还是先问你,都在这里看得到。AI 排任务单时,跑图那一格写“任务单 6/10”。
- The flow over a task in the workbench gets a second line once the AI handed a blueprint: its steps with their models and picking a base, done ones lit, the running one pulsing, the coming ones dashed; a click goes to that step. It shows whether the AI asked you first or built it as your setting says. A job sheet's run shows "Job sheet 6/10".
- 画布右键(节点或空白处)新增「交给 AI…」:几个现成说法(出 4 张挑最好的、找问题帮我修、讲讲每一步、后面加放大和修脸修手;节点上是讲讲这个节点、帮我调参数),点一下就能改了发出。AI 正在做任务时直接作为留言发给它,也可以开新任务;新任务的 AI 从这张画布开始。节点上有图时可以一起发。运行出错时右下角会出现「让 AI 看看」,报错已经写好。
- New "Hand to AI…" in the canvas's right-click menu (a node or the empty canvas): ready wishes (run 4 and show the best, find what is wrong and fix it, explain each step, add an enlargement and face and hand fixes; for a node, explain it or tune it), one click to fill in and change. With an AI on a task it goes to that task as a message, or starts a new task that begins from this canvas; a node's pictures can go along. When a run fails, "Ask the AI" appears with the error already written.
- 画布上的模型只读得懂英文(SD 1.5、SDXL、Pony、Illustrious、Flux 这类)而提示词是中文、日文或韩文时,跑之前先说一声:这样出的图和提示词无关,是模型自己偏爱的东西。AI 来跑会先被拦一次,让它改写成英文(你就是要照原样跑,它再叫一次就放行);任务单点「开始」时提醒一次,再点就照样跑。Anima、Qwen-Image、Z-Image 这些读得懂中文的不拦。
- When the canvas's model reads English only (SD 1.5, SDXL, Pony, Illustrious, Flux and the like) and the prompt is Chinese, Japanese or Korean, AMB says so before running: such pictures follow nothing of the prompt and show whatever the model leans to. An AI's run is stopped once and told to write the prompt in English (called again unchanged, it runs as written); the job sheet's Start warns once and runs on the second press. Models that read Chinese (Anima, Qwen-Image, Z-Image) are not stopped.
- 现成做法接在别的步骤后面时,不再接上一步模型线上的 Tile LLLite、ControlNet 这类跟图片尺寸绑定的补丁:那一步的补丁只属于那一步。以前在 Tile 放大后面接脸手细化或再放大,可能出错甚至让 ComfyUI 整个崩掉(测试里崩过一次)。
- A ready-made way going on from another step no longer takes the size-bound patches on that step's model line (a Tile LLLite, a ControlNet): they belong to their own pass. Face and hand detailing or another enlargement after a Tile redraw could fail, or crash ComfyUI (it did once in a test).
- 换模型精修多了两种:**只改人物**和**只改场景**。AMB 先自动抠出人物,人物那一种只让第二个模型重画人物,边缘慢慢减弱,场景一个像素都不动;场景那一种只重画背景,人物和它边上一圈原样保留。这样可以先用一个模型把整张图的构图和动作定好(人坐不坐得住椅子,要两样一起画才对),再让擅长人物或擅长场景的模型只改它擅长的那一部分。在 Anima 上,有 Tile 模型(animaTileRepair,AMB 的 Tile 放大也用它)时会自动加上 Tile,让第二个模型照着原图的线条画,强度用 0.7,画风能换得更明显(改多少还跟采样器有关:AMB 画 Anima 默认的 dpmpp_2m_sde 改得多,euler 改得少,线条都保得住):实测强度开到 0.70 时,不加 Tile,背景里的窗户会消失、包会被画成另一个;加了 Tile,它们都留在原位,只换画法。没有这个模型,或者不是 Anima 时,不加 Tile,强度用 0.45。重画的是哪一块,会在预览里显示(白色的部分)。自动抠图常会把人坐着的椅子、手里拿的伞和包也算进人物:只改人物时它们跟着人一起重画,接触的地方还对得上;只改场景时它们跟人一起原样保留。原来的整张重画保持不变。实测 RTX 4050 6GB、Anima(Harem 出图,RealSkin 只改人物、Kirazuri 只改场景),1440×1024,加 Tile,一次跑完三张约 337 秒。
- Refining with another model has two more kinds: **only the character** and **only the background**. AMB cuts the character out first; the character kind has the second model redraw only the character, fading out at its edge, the scene not changed by a single pixel; the background kind redraws only the background, the character and a band around it left as they were. So one model can settle the whole picture's layout and pose first (a character only sits on a bench when both are drawn together), and a model good at characters or at scenery changes only its part. On Anima, with the Tile model there (animaTileRepair, the one Anomalous's Tile upscale uses), a Tile is added so the second model follows the picture's lines, and the strength is 0.7 for a clearer change of look (how much it changes follows the sampler: the dpmpp_2m_sde Anima pictures are drawn with more, euler less, the lines kept either way): at 0.70 without the Tile the background's windows vanished and a bag was redrawn as another; with it they stayed in place in the new look. Without that model, or on other models, no Tile and 0.45. The preview shows what is redrawn (white). The cut-out often takes the bench the character sits on and the umbrella or bag it holds along: redrawn with the character in the first kind, so where they touch still fits, and kept with it in the second. The whole-picture refine is unchanged. Tried on an RTX 4050 6 GB with Anima (Harem draws, RealSkin only the character, Kirazuri only the background), 1440×1024 with the Tile: about 337 s for the three pictures in one run.
- 新做法 **放大·只重画人物**,给背景本来就虚化的图用:整张用放大模型拉大 1.5 倍,背景不再动;自动抠出人物,只裁出那一块重画,再缝回去。实测整张重画的 Tile 放大会把远处的铁轨、树画得清清楚楚,图也发灰,景深就没了;这样做虚化原样保住,人物变清楚,只裁人物来重画也快得多(270 秒降到 150 秒左右)。Anima 上有 Tile 模型时照着原图线条画(强度 0.7),没有就用 0.45。背景清楚的图还是用 Tile 锁图放大。官方示例的「两段放大」也多了一个版本「Anima · 背景虚化,只重画人物」,手动跑也能用(背景用普通拉大,不需要放大模型)。AI 的「分阶段出图的思路」多了一段按部位换模型:先把整张画好,再只改人物或只改场景,想明显换画风就加 Tile,背景虚化的图放大时只重画人物。实测 RTX 4050 6GB、Anima,832×1216 出图再放大到 1248×1824,一次跑完约 223 秒。
- New way **Upscale, only the character redrawn**, for a picture whose background is blurred on purpose: an upscale model enlarges it all 1.5x and the background stays as it is; the character is cut out, only its crop is redrawn and stitched back. A whole Tile redraw drew the far tracks and trees sharp and hazed the picture, and the depth was lost; this way keeps the blur, sharpens the character, and the crop is much faster (about 150 s where 270). On Anima with the Tile model it follows the picture's lines (0.7), without it 0.45. A sharp background still takes the Tile upscale. The official example "Two passes" has a version "Anima · blurred background, only the character redrawn" for running it by hand (the background enlarged plainly, no upscale model needed). The AI's skill "Drawing a picture in stages" gained the by-part way: the whole picture first, then only the character or only the background, a Tile for a bigger change of look, and on enlarging a blurred background only the character redrawn. Tried on an RTX 4050 6 GB with Anima, 832×1216 drawn and enlarged to 1248×1824: about 223 s for a whole run.
- AI 工作台的「做法」面板变短了:先只列这里能用的做法,每块一行(名字、一句话、「这次用它」、开关),点名字前的小三角才展开全文、来源、测过的耗时和推荐的模型;接着是 skill;缺节点包或模型的做法收进最下面的「还差东西的」,默认折起,点开每块写着缺什么。以前 24 行排了五屏多,能用的和缺东西的混在一起,skill 压在最底下。
- The Ways panel in the AI workbench is short now: first only the ways that can run here, a row each (name, a line, "Use this time", the switch), opening by the small triangle for all its words, where it came from, how long it took when tried and the models it recommends; then the skills; the ways lacking a node pack or a model are folded at the bottom, each saying what it lacks. It used to be 24 rows over five screens, usable and missing ones mixed and the skills last.
- Anima 的 Tile(和别的 Anima LLLite)是给 2B 的 Anima 模型做的,放到 2.9B 这类更大的模型上,跑到一半会报 "depth_embed slices missing"。现在 AMB 从两个文件的文件头里数出块数(2B 是 28 块,2.9B 是 40 块),AI 要跑不适配的画布时直接拦下,告诉它是哪个节点、该换哪个加载器的模型;带 Tile 的官方示例打开时也只挑适配的模型。原来写在四块做法和两个示例里的那段提醒删掉了。
- Anima's Tile (and its other LLLite models) is made for the 2B Anima models; on a larger one such as a 2.9B the run stops midway with "depth_embed slices missing". Anomalous now counts the blocks in the two files' headers (28 for 2B, 40 for 2.9B): an AI's run on a canvas where one does not fit is refused with the node and the loader to change, and an official example with a Tile opens on a model the Tile fits. The warning written into four ways and two examples is gone.
- 官方示例窗口:Anima "要多张图就多跑几次,别调批次" 只在 Anima 的版本上面说一次,不再每个版本各写一遍;一个版本的两个节点来自同一个节点包时,「缺节点包」只列一次(以前 ComfyUI-Inpaint-CropAndStitch 会出现两次)。AI 那边,这句话也只写在模型档案一处,AMB 自动附在每个 Anima 示例后面。
- The official examples window says Anima's "run several times, not a bigger batch" once, above the Anima versions, not in each of them; a pack two nodes of a version come from is named once under what it lacks (ComfyUI-Inpaint-CropAndStitch showed twice). For an AI the sentence is written once too, in the model profile, and goes with every Anima example.
- skill「分阶段出图的思路」只留怎么拿主意(先出底图给你挑、两个模型什么时候必须同类、画风 LoRA 要不要带到第二个模型),不再用文字把每条路子抄一遍:有哪些路子、各自什么时候用,写在每块做法自己的文件里,AI 取这个 skill 时 AMB 只列出这里装得上、开着的那几块。以后加做法不用再改 skill。原来的全文已经略超过 AI 软件在聊天里能直接显示的长度(3,991 字节,线是 3,800),再长就会被存成文件、AI 看不到;现在最长 3,586 字节,有测试看着。
- The skill "Drawing a picture in stages" keeps only the judgment (the base shown first for you to pick, when two models must be of one family, whether a style LoRA goes onto the second model) and no longer retells every way in words: which ways there are and when each fits is written in each way's own file, and when the AI fetches the skill Anomalous lists only those installed and switched on here. Adding a way no longer means editing the skill. Its full text had grown just past what an AI app shows in the chat (3,991 bytes against a line of 3,800), beyond which it is filed away unread; it is 3,586 at its longest now, and a test keeps it there.
- 给 AI 的叫法改了一个词:Tile 锁图放大从 `want="detail"` 改成 `want="tile"`,免得和"修脸和手"那个用途(也叫 detail)混在一起。
- One word changed for the AI: the Tile upscale is asked for with `want="tile"`, not `want="detail"`, which was also the name of the use that fixes faces and hands.
- 「挑一张底图」里不会再混进一张黑白遮罩了。用“只重画人物”这类做法时,后面那一步有个“要重画哪块”的遮罩预览,它也被当成了一张底图;挑中它,后面就照着遮罩去放大,成品是废的。现在只把真正的底图拿出来给你挑,遮罩预览留到整条跑的时候才出。
- "Pick a base" no longer shows a black-and-white mask among the bases. With a way that redraws only the character, the later step's preview of what it will redraw was taken for a base too; picked, the rest enlarged the mask and the picture was ruined. Only the bases themselves are offered now, and the mask preview comes with the whole run.
- AI 不带任何参数试跑一下时,如果画布是从零开始画、提示词框还空着,先拦一次,告诉它把提示词一起给出来(你就是要空着跑,它再叫一次就照跑)。实测 Gemini Flash 习惯先这样空着试一次,蓝图刚搭好时这一下会白画三张没有内容的小样,还把“先出三张给你挑”用掉了。
- When an AI tries a run with no arguments and the canvas draws from nothing with its prompt box still empty, the run is refused once and it is told to give the prompt with the run (called again unchanged, it runs). Gemini Flash tries tools this way; on a blueprint just built that drew three bases of nothing and used up the three bases you pick from.
- Tile 不适配被拦时,如果那个模型是你点名要用的,AI 会被提醒先问你再换,不是自己悄悄换掉。AI 记决策时把“否决了什么”写成一句话,不再被拆成一个个字。
- When a run is refused because a Tile does not fit and you named that model yourself, the AI is told to ask you before changing it. A decision whose rejected option the AI wrote as one sentence is no longer split into letters.
- AI 往展台上画面板时少了两种积木:它自己报数的进度条和分隔线。出图、任务单、蓝图的进度现在都由 AMB 自己显示,是真实的数,AI 另报一条反而可能是旧数;分隔线只是装饰。以前的任务里 AI 画过的进度条不再显示,其余内容照旧。模型卡、表格、列表、流程图都留着。
- Two blocks are gone from the panels an AI draws on the stage: the progress bar with its own numbers, and the divider. A run's, a job sheet's and a blueprint's progress are shown by Anomalous itself, with real numbers, where the AI's own bar could be stale; the divider was decoration. A bar an AI drew in an earlier task is no longer shown; the rest of that panel stays. Model cards, tables, lists and diagrams remain.
- **隐私模式的提示词框多了一格“画面大意”**。以前整段提示词都变成 [P1],AI 什么都看不到,没法帮你想光影、镜头和画风。现在最上面这一格是原样给 AI 看的:用不介意让它看到的话说说想要什么,它照着补全,再把你的私密提示词以代号嵌进去。两格可以只填一格。
- **The prompt box of privacy mode has a new field, the gist.** With the whole prompt a token, the AI could not help with light, lens or style. The first field now goes to the AI as written: say what you want in words you do not mind it reading, and it writes the rest around the tokens of your private prompts. Either field alone can be handed in.
- **隐私模式把模型名也藏起来了**。模型和 LoRA 的文件名、卡片名、所在文件夹、C 站链接和指纹都不再发给 AI,它拿到的是 [M1] 这样的代号,一个模型不管出现在哪都是同一个;AI 把代号填到要用模型的地方,AMB 换回真模型。它凭类型、底模、作者推荐参数和触发词(代号)来挑。任务标题栏 ⋯ 里的“模型代号对照”能查每个代号是哪个模型。不想藏,在关卡窗口把“隐私任务里的模型名”改成“照常给 AI”。
- **Privacy mode keeps model names too.** The file names, card names, folders, Civitai links and hashes of models and LoRAs no longer go to the AI; it gets tokens like [M1], one per model wherever it shows, writes the token where the model goes, and Anomalous puts the model back. It picks by type, base model, the author's settings and the trigger words (as tokens). "Model tokens" in the task's ⋯ says which model each is. To send names as before, set "Model names in a private task" to "Sent to the AI" in the Gates window.
- 模型的编辑窗口多了一栏 **给 AI 的用途说明**:一句不介意让 AI 看到的话,说这个模型是干什么用的(比如“双人互动姿势”)。AI 搜模型时能看到、也能按它搜;隐私模式藏起模型名时,它就靠这句话来分辨。不填就不给。
- The model editor has a new field, **Note for the AI**: a line you do not mind an AI reading on what the model is for. The AI sees it when it searches models and can search by it; with names hidden in privacy mode, it tells the models apart by it. Left empty, nothing is sent.
- 隐私模式补了一个漏洞:搭配的名字现在也换成代号。没改过名的搭配,名字是自动拿“模型名 + 提示词开头”拼的,以前会原样给到 AI。AI 用代号照样能把搭配放上画布。
- A hole in privacy mode is closed: a combo's name is a token now. A combo you never renamed is called by its model's name and the start of its prompt, which went to the AI as written. The AI places a combo by its token as before.
- **隐私任务能查“发给 AI 了什么”**。任务标题栏 ⋯ 里新增这一项:隐私模式开着时,AI 每一次调用发来的参数和 AMB 回给它的文字都原样记着,可以搜一个词(某个模型名、提示词里的词)看它有没有出去过。只留最近 300 次调用,任务移除时一起删掉。
- **A private task shows what the AI was sent.** New in the task's ⋯: while privacy mode is on, every call is kept as it went, what the AI wrote and the text Anomalous answered, and you can look for a word (a model name, a word of a prompt) to see whether it ever went out. The newest 300 calls are kept and removed with the task.
- **说清了隐私模式管不到的地方**:它过滤的是 AMB 发给 AI 的内容。Antigravity、Cursor、Claude Code 这类能自己读硬盘、跑命令的 AI 软件,可以直接打开出图文件夹和 AMB 的任务记录,这个拦不住;用它们开隐私任务时,标题栏下面会有一行提醒,隐私开关的说明里也写明了。只会调用工具的聊天软件不受影响。
- **Privacy mode now says what it cannot cover**: it filters what Anomalous sends to the AI. An AI app that reads the disk and runs commands by itself (Antigravity, Cursor, Claude Code and the like) can open the output folder and Anomalous's task records, and this cannot stop it; a private task run by such an app says so under its header, and so do the switch's hints. A chat app that only calls tools is not affected.
- 隐私模式里,**你起的名字也都是代号了**:工作流卡片名、提示词库的条目名、它们的标签,以及图片信息里“用哪张卡片、哪个搭配做的”。这些名字里常常写着模型名或画的内容。AI 用代号照样能放搭配、用卡片;你在聊天里说出名字,它照样搜得到。
- In privacy mode **the names you gave are tokens too**: workflow card names, saved prompt names, their tags, and which card or combo a picture was made with. Such names often say the model or what is drawn. The AI still places a combo and uses a card by its token, and still finds one by a name you say in the chat.
- 隐私模式新增 **藏**:在提示词框的“画面大意”里,或隐私任务的留言框里,选中几个字点“藏”,这几个字 AI 只拿到代号,其余的它照常看、照常改。不用再为了几个词把整段都藏起来。
- Privacy mode has **Hide**: in the gist of the prompt box, or in a private task's message box, select a few words and press Hide; the AI gets a token for them and reads and edits the rest. A few words no longer mean hiding the whole text.
- 跑完的图点了 **不太行**,下面多一排 **哪里不行**:手坏了、脸不对、构图偏了、太暗、太亮、糊、画风不对……点哪个,AI 就知道该改哪个,可以点几个。隐私模式下 AI 看不到图,你点一下就是它的眼睛;普通任务里也省得打字。
- Under a run you marked **not quite right** there is now **what is off**: hands, face, framing, too dark, too bright, blurry, wrong style and more. A click tells the AI which to fix, and you may pick several. In privacy mode the AI is not sent the pictures, so your click is its eyes; in any task it saves the typing.
## v1.63.0 (AI Ways and Skills, Saves, Official Example Workflows, Privacy Mode) — 2026-11-10
- 画布左上角的立方体入口不再压在 ComfyUI 自己的菜单上面:以前它在最顶层,打开左上角的 C 菜单时会盖住“新建”那一行。现在它在画布上面、顶栏和菜单、对话框下面。README 的安装第 2 步也配上了图,写明装好后入口在哪、看不到时怎么办。
+43 -3
View File
@@ -33,6 +33,7 @@
> **Anomalous Model Browser** takes care of the chores around ComfyUI in one window: browse your models by their Civitai covers and trigger words, recover workflows from past outputs, keep tuned workflows with visual presets, snapshot canvas saves to compare fairly and jump back to, reuse model + LoRA combos and tag-based prompts, find missing models again (for sure when the workflow carries this plugin's fingerprints), let an AI app run and refine workflows on a copy tab while you steer each step, and—with the optional companion pack [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)—give your characters a voice. Everything the plugin changes is recorded in **Activity** with **Undo**.
### 🧭 The idea
<!-- guide:idea -->
**Not another WebUI, not a replacement for ComfyUI: just ComfyUI made easier to use.**
@@ -54,13 +55,14 @@
---
### ✨ AI Workbench: You direct in plain words, ComfyUI draws with your models
<!-- guide:ai_workbench -->
Most "AI + image" tools either hide ComfyUI behind a cloud generator or ask an LLM to guess raw node connections from scratch—often breaking your open workflow. **AI Workbench** takes a practical middle route built around your own ComfyUI library:
- **Your open workflow stays safe.** By default (**AI edits a copy**), the first canvas action duplicates your workflow into a dedicated task tab. Your own tab is never touched, and every step on the timeline has **Undo** and **Find on canvas**.
- **Tested building blocks instead of guesswork.** Rather than inventing fragile graphs, the AI opens **your saved workflows**, **any gallery picture's embedded workflow**, or **Official examples**, and extends them with **Ways** tested here (two-pass Tile upscale, mid-way model switch, crop-and-stitch or LanPaint area repaint, hand repair, background swap, pose/depth control…) plus on-demand **Skills** (such as matching pose complexity to your model's parameter scale, or planning multi-stage passes). You can also click **Use it this time** on any Way to lock the AI to your preferred method.
- **Tested building blocks instead of guesswork.** Rather than inventing fragile graphs, the AI opens **your saved workflows**, **any gallery picture's embedded workflow**, or **Official examples**, and extends them with **Ways** tested here (two-pass Tile upscale, mid-way model switch, another model on only the character or only the background, a blurred background enlarged with only the character redrawn, crop-and-stitch or LanPaint area repaint, hand repair, background swap, pose/depth control…) plus on-demand **Skills** (such as matching pose complexity to your model's parameter scale, or planning multi-stage passes). You can also click **Use it this time** on any Way to lock the AI to your preferred method.
- **Steer before committing GPU time.** On multi-pass workflows, the AI can run a few fast first-pass **base previews** first; **Pick a base** opens on the right-hand **Stage**, you click **Take this one**, and the second pass sharpens that exact image without redrawing the base. Finished runs appear with a **before/after comparison slider**, **Turned out well / Not quite** feedback buttons, and a `✎` brush tool to mark regions for local repainting.
- **Clear boundaries (`Gates`).** You decide what the AI may **Go ahead** with, must **Ask me first** about, or may **Never** do: changing the canvas, using the GPU, downloading models, scanning models online, moving model files. Two more settings cover nodes it places by itself: any custom node pack or only those of the Ways, and whether an untried route of its own waits for your OK (with an optional per-task `🔒` privacy switch when you want prompt text and pictures kept local as `[P1]` / `[img1]` tokens instead of sent to the AI). Works locally with Claude, Cursor, Antigravity, Cherry Studio, VS Code and other MCP-capable apps—copy one sentence from **Connect an AI** to set it up, or see [docs/guides/mcp.md](docs/guides/mcp.md).
- **Clear boundaries (`Gates`).** You decide what the AI may **Go ahead** with, must **Ask me first** about, or may **Never** do: changing the canvas, using the GPU, downloading models, scanning models online, moving model files. Two more settings cover nodes it places by itself: any custom node pack or only those of the Ways, and whether an untried route of its own waits for your OK (with an optional per-task `🔒` privacy switch when you want prompt text, pictures and model names kept local as `[P1]` / `[img1]` / `[M1]` tokens instead of sent to the AI). Works locally with Claude, Cursor, Antigravity, Cherry Studio, VS Code and other MCP-capable apps—copy one sentence from **Connect an AI** to set it up, or see [docs/guides/mcp.md](docs/guides/mcp.md).
<p align="center"><img src="docs/images/en-ai-workbench.webp" width="840" alt="AI Workbench: pick one of three fast first-pass base previews, then inspect the 2nd-pass upscale with a before/after slider and choose the next step"></p>
@@ -68,11 +70,13 @@ Most "AI + image" tools either hide ComfyUI behind a cloud generator or ask an L
### 💡 Everyday ComfyUI chores it solves (with or without AI)
<!-- guide:names -->
**1. "Which LoRA was `download (3).safetensors` again?"**
Press **Scan** once: every model is matched on Civitai by its SHA-256 file hash and gets its real title, cover, trigger words and base model badge. Multiple versions of the same Civitai model fold neatly into one card—click its numbered side tab to slide all versions out under an orange bar, pin the version you use most, or check and download other versions from Civitai.
<p align="center"><img src="docs/images/en-scan.webp" width="560" alt="Scan results matching cryptic local filenames to their real Civitai titles"></p>
<!-- guide:model_check -->
**2. Someone else's workflow opens with red missing-model nodes.**
**Model Check** looks as soon as a workflow opens, without changing it. How far it gets depends on what the workflow carries:
- **With fingerprints** (its author has this plugin too, which records each model's SHA-256 in the workflows and pictures it saves, unless turned off): the same file on your computer under another name or in another folder is found for certain and goes back in with one press of **Put in**; a model you really lack is looked up on Civitai by its fingerprint and checked against it after downloading.
@@ -83,44 +87,56 @@ Press **Scan** once: every model is matched on Civitai by its SHA-256 file hash
<p align="center"><img src="docs/images/en-model-check.webp" width="820" alt="Model Check replacing three renamed/moved missing models in one click"></p>
<!-- guide:workflows -->
**3. Keep tuned workflows, see their stages at a glance, and switch presets.**
**Official examples** (text-to-image, two-pass upscale, img2img, inpaint) open ready to run on a model you already own. On any saved workflow card, a compact **structure flow diagram** maps out models, LoRAs and control bars across each sampling stage (`① Base picture` → `② Enlarge and redraw`). Click a **preset tab** and every stage or LoRA it changes turns **orange**; click any box in the flow to see `default → preset` parameter diffs right underneath, apply it to the canvas, or export a `.zip` recipe package / short `⇅` share code.
<p align="center"><img src="docs/images/en-workflows.webp" width="840" alt="Workflows page: Official examples, structure flow diagram, orange preset highlights and node diff"></p>
<!-- guide:saves -->
**4. Try another idea without losing this one — Canvas Saves.**
Right-click the canvas → **Save this state** snapshots the whole workflow onto a compact bar under the canvas; tweak anything and press `+` to save again. Run every save in batch with **Random** or **Locked** seeds for a fair side-by-side test. **Saves** (at the top of Workflows) lists what each save changed beside the pictures it made; click `1`, `2` or `3` on the bottom bar to jump back on the same tab—bringing that save's output node previews back too (`Ctrl+Z` returns; **Keep as a workflow** turns a favorite into a workflow card).
<p align="center"><img src="docs/images/en-saves.webp" width="840" alt="Canvas Saves: side-by-side diffs with locked-seed renders, and switching saves 1, 2, 3 on the bottom bar"></p>
<!-- guide:job_sheet -->
Many prompts on one canvas instead? Right-click the canvas → **AMB** → **Job sheet (many prompts)**: paste a prompt a line, set **Runs of each** and **Seconds between pictures**, and press **Start**. It runs copies of the canvas as it was at the start, one after another (your canvas stays as it is), with **Pause**, **Go on** and **Stop**; with no prompt it runs the canvas as it is, a rest between pictures. An AI can hand over a sheet too ("these prompts, 4 each"; "cfg 4, 5 and 6, 3 each").
<!-- guide:picture_workflow -->
**5. "How did I make this picture?"**
Search your `output` folder by prompt words, checkpoint, LoRA, seed or model hash (even hundred-megapixel upscales stay smooth via background thumbnails), then drag the picture straight onto the canvas to bring its full workflow back.
<p align="center"><img src="docs/images/en-gallery.webp" width="820" alt="Gallery search by tag chip, then dragging an output image onto the canvas to restore its workflow"></p>
<!-- guide:combos -->
**6. Stop rewiring the same Checkpoint, LoRAs and prompt.**
Select those nodes on the canvas, right-click → **AMB** → **Save as combo**, and keep them as a card beside the canvas. Press **Put on canvas** (or drag the card) to lay out the wired node group wherever you click without altering existing nodes. Need several LoRAs on an existing loader? **Current node → Insert LoRA** lets you click multiple LoRA cards in `1 · 2 · 3` order and wires the whole chain (or an rgthree / Easy-Use LoRA stack) in one `Ctrl+Z` step.
<p align="center"><img src="docs/images/en-combo.webp" width="820" alt="Placing a saved Checkpoint + LoRA + Prompt combo onto the canvas as a wired group"></p>
<!-- guide:prompts -->
**7. Edit long prompts as clickable tags — Prompt Studio.**
Dock **Prompt Studio** beside the canvas, select a prompt node, and edit its positive and negative text live as tag pills: click `+` / `−` to adjust weights, double-click to edit, drag to reorder, type in Chinese, Japanese or other languages to insert English tags automatically, and drag saved prompt cards straight onto canvas prompt boxes.
<p align="center"><img src="docs/images/en-prompt-studio.webp" width="820" alt="Prompt Studio docked beside the canvas, weighting tags live on a selected CLIP Text Encode node"></p>
<!-- guide:voices -->
**8. Characters that talk.**
With the optional companion node pack [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS): manage GPT-SoVITS characters with a reference clip per emotion, fix pronunciations, and generate dialogue lines from a script page.
<p align="center"><img src="docs/images/en-voices.webp" width="760" alt="Character voices page with emotion reference clips"></p>
<!-- guide:undo -->
**Changed your mind?**
Every change made by Anomalous or an AI task is logged in **Activity** with before → after values, **Find on canvas** and one-click **Undo**.
<p align="center"><img src="docs/images/en-activity.webp" width="760" alt="Activity log showing expanded node changes with Undo and Find on canvas buttons"></p>
<!-- guide:light -->
**Light on your computer**: the plugin itself loads no AI models and takes **zero VRAM** away from image generation; grids use cached thumbnails rather than full originals, and browser image memory is released 90 seconds after closing the window. Voice models in `Anomalous_TTS` yield VRAM automatically whenever ComfyUI generates images, or completely on **Unload models**.
### 📦 Install
<!-- guide:install -->
1. In **ComfyUI Manager**, search `Anomalous Model Browser`, install it and restart ComfyUI. Or clone it into `custom_nodes`:
```bash
@@ -138,6 +154,7 @@ Scanning reaches Civitai by file hash (no extra Python dependencies). Without in
<details>
<summary><b>📖 Every page and tool, in detail</b></summary>
<!-- guide:pages -->
Every page has an icon on the left rail; when docked beside the canvas, drag the window's right edge (`480px` minimum, double-click to reset to `660px`) to resize.
@@ -160,12 +177,14 @@ Tools at the bottom of the rail (ordered by frequency of use):
* **Scan**: hashes model files locally, reads headers (including offline base-model & architecture detection checked against 300+ model layouts), and fetches Civitai covers, names and trigger words.
* **Settings**: language (switches live across all panels, Stage and Saves bar), interface scale, theme, thumbnails, video covers, model download mirrors, backups, and **Report a problem** (pre-filled with version & GPU info).
<!-- guide:connect_ai -->
**Connecting AI apps (MCP)**: Claude, Cursor, Antigravity, Cherry Studio, VS Code and other MCP clients connect locally via `http://127.0.0.1:8188/anomalous/mcp` (localhost only). Full setup and tool reference in [docs/guides/mcp.md](docs/guides/mcp.md).
</details>
<details>
<summary><b>⚙️ Opening, shortcuts and updating</b></summary>
<!-- guide:open -->
Open the browser with **Shift + B** (press again to close), the cube button at the canvas's top left, or **Extensions → Anomalous Model Browser** in ComfyUI's menu. Customize the shortcut, language and launcher style under **ComfyUI Settings → Anomalous Model Browser → Interface**.
@@ -175,6 +194,7 @@ Open the browser with **Shift + B** (press again to close), the cube button at t
<details>
<summary><b>🌐 Network and backups</b></summary>
<!-- guide:network -->
**On a cloud server (AutoDL, RunPod…):** overseas servers work out of the box; servers in mainland China only in part (Civitai is mostly out of reach there; see the `civitai.red` fallback and `hf-mirror.com` below). Tested on one AutoDL server so far. Opened from another machine, the plugin defaults to read-only for safety; connect via an SSH tunnel so ComfyUI sees `127.0.0.1`, or read [docs/guides/cloud-server.md](docs/guides/cloud-server.md).
@@ -202,6 +222,7 @@ Open the browser with **Shift + B** (press again to close), the cube button at t
<p align="center"><img src="docs/images/models.webp" width="880" alt="中文模型库界面:Civitai 封面、底模标签与多版本同卡展开"></p>
### 🧭 理念
<!-- guide:idea -->
**不再造一个套壳 WebUI,也不取代 ComfyUI,只是把 ComfyUI 身边最磨人的杂活接过去。**
@@ -223,11 +244,12 @@ Open the browser with **Shift + B** (press again to close), the cube button at t
---
### ✨ AI 工作台:你用人话当导演,AMB 搭线,ComfyUI 用你的模型出图
<!-- guide:ai_workbench -->
很多人一听“在 ComfyUI 里接 AI”,第一反应往往是两个顾虑:要么怕它像云端生图软件一样绕开自己的本地模型库,要么怕大模型瞎猜节点连线,把自己辛辛苦苦调好的画布搅得一团糟。**AI 工作台**走的是另一条路——**让 AI 当你的操作助手,规矩由 AMB 守住**:
- **默认只改“副本”,绝不弄乱你开着的画布。** 任务默认开启 **AI 改副本**:AI 第一次动手前,AMB 自动把工作流复制到专属的任务标签页里操作,你原来的标签页一笔不动;时间线上的每一步都能点 **撤销** 或 **在画布上找到**。
- **不靠瞎猜连线,优先用你的工作流和实测“做法”。** AI 会直接调用**你存好的工作流及预设**、**图库某张图自带的工作流**,或是开箱即用的 **官方示例**;需要加功能时,由 AMB 按你已装的节点包搭上实测过的 **做法**(Tile 锁图二段放大、半途换模型接着画、裁切缝合 / LanPaint 局部重绘、修手先放大再修、换背景、姿势/深度控制……),并配合按需 **Skill**(按主模型参数量给动作建议、分阶段出图思路)。你还能在做法面板点 **这次用它**,直接指定本次任务必须走哪套做法。
- **不靠瞎猜连线,优先用你的工作流和实测“做法”。** AI 会直接调用**你存好的工作流及预设**、**图库某张图自带的工作流**,或是开箱即用的 **官方示例**;需要加功能时,由 AMB 按你已装的节点包搭上实测过的 **做法**(Tile 锁图二段放大、半途换模型接着画、换模型只改人物或只改场景、背景虚化的图放大时只重画人物、裁切缝合 / LanPaint 局部重绘、修手先放大再修、换背景、姿势/深度控制……),并配合按需 **Skill**(按主模型参数量给动作建议、分阶段出图思路)。你还能在做法面板点 **这次用它**,直接指定本次任务必须走哪套做法。
- **先挑快底图,再跑慢精修,展台拉帘对比。** 跑两段放大这类耗时流程时,AI 可以先快速跑几张第一段底图预览(不污染正式输出目录);右侧**展台**弹出挑选面板,你点 **就选这张**,第二段直接接着这张图做细节重绘,省掉重跑底图的时间。出图后展台提供**前后拉帘对比**、**效果好 / 不太行** 按钮,哪里不满意还能点图片右上角 `✎` 用画笔涂出区域让它只重画那一块。
- **权限全由“关卡”把关。** 改画布、用显卡出图、下载模型、联网扫描模型、挪动模型文件,都能在 **关卡** 里设为“直接做 / 先问我 / 不许做”;AI 自己往画布上加节点时,能不能用任意第三方节点包、没验证过的新路线要不要先问你,也在这里设(任务标题栏还带可选的 `🔒` 隐私开关,开启后提示词与图片名会换成 `[P1]`、`[img1]` 代号,不把原图和原文发给 AI)。支持连接 Antigravity、Claude、Cursor、Cherry Studio、VS Code 等支持 MCP 的本机软件——点工作台里的 **连接 AI** 复制一句话发给它就能配好,详见 [docs/guides/mcp.md](docs/guides/mcp.md)。
@@ -237,11 +259,13 @@ Open the browser with **Shift + B** (press again to close), the cube button at t
### 💡 日常玩 ComfyUI,它帮你省掉哪些麻烦(不接 AI 也一样主力)
<!-- guide:names -->
**1. 模型塞满硬盘,文件名全是 `download (3).safetensors`。**
点一次 **扫描**,每个模型按文件 SHA-256 哈希去 C 站认领真名、高清封面、触发词和底模角标。同一个 C 站模型的不同版本还会自动收进一张卡片,点右侧数字滑出全部版本(顶部橙边相连),点图钉设默认版本,详情里还能直接核对下载该模型在 C 站上的其他版本。
<p align="center"><img src="docs/images/scan.webp" width="560" alt="扫描明细:把乱七八糟的本地文件名精准匹配回 C 站模型名"></p>
<!-- guide:model_check -->
**2. 打开别人分享的工作流,满屏红框缺模型。**
**模型检查** 在打开工作流时自动只读体检,能做到哪一步,看工作流里带了什么:
- **带指纹的**(作者也装了本插件,出图、存工作流时默认记下每个模型的 SHA-256):你电脑里其实有、只是改了名或挪了文件夹的同一个文件,能确定认出来,横幅上点一下 **换上** 就全部就位;真缺的按指纹去 C 站找,下载完核对一致才换上。
@@ -252,47 +276,59 @@ Open the browser with **Shift + B** (press again to close), the cube button at t
<p align="center"><img src="docs/images/model-check.webp" width="820" alt="模型检查:一键把 3 个改名或挪过文件夹的红框缺失节点换回正确模型"></p>
<!-- guide:workflows -->
**3. 存下调好的工作流,结构流程图和橙色预设差异一眼看清。**
顶部自带 **官方示例**(文生图、两段放大、图生图、局部重绘,自动配好你本机已有的模型),新手装上就能出图。存下的每张工作流卡点开就是清爽的 **结构流程图**:底模、LoRA、控制模型横跨在它们管辖的采样阶段(`① 出底图` → `② 放大重画`)上方。切换下方的 **预设标签**,和默认参数不同的阶段直接亮起 **橙色**,点任意方格就能在下方展开 `默认 → 当前预设` 的逐项对比;还能打成 `.zip` **配方包** 或生成短短一截 `⇅` 汉字/字母分享码发给别人。
<p align="center"><img src="docs/images/workflows.webp" width="840" alt="工作流页:官方示例、结构流程图、切换预设后橙色高亮改动阶段及节点参数对比"></p>
<!-- guide:saves -->
**4. 想试几种新参数或加个 LoRA,又怕改坏现在这版 —— 画布存档。**
在画布上右键 → **存档当前工作流**,当前状态就存进画布底部的存档条;接着随便改参数、加节点,点 `+` 再存几版。存档条支持选 **锁种**(同一组种子公平对比)或 **随机**,一键把几个存档按顺序排进队列。“工作流”页顶部的 **存档** 把每个存档改了什么和它跑出的图并排摆在一起;点底部存档条的数字 `1`、`2`、`3` 就能在当前标签页秒回那一版——**连输出节点上的预览图也会跟着切回那个存档跑出来的图**(`Ctrl+Z` 可撤回,满意的随时点 **收藏为工作流**)。
<p align="center"><img src="docs/images/saves.webp" width="840" alt="画布存档:并排查看三个存档的参数节点改动与锁种出图,并在底部存档条一键切换回退"></p>
<!-- guide:job_sheet -->
想用同一张画布跑一大串提示词?画布右键 → **AMB** → **任务单(批量换提示词)**:一行贴一条提示词,填 **每条跑几次**、**每张之间歇几秒**,点 **开始**。跑的是开始时那张画布的副本,一张接一张,你的画布不动;随时 **暂停**、**继续**、**停止**。一条也不写,就是照画布原样隔一段时间跑一张。也可以让 AI 排任务单(“这几条各跑 4 张”“cfg 4、5、6 各跑 3 张”)。
<!-- guide:picture_workflow -->
**5. 翻到一张老图:“当时到底怎么调出来的?”**
在 **图库** 里按提示词、大模型、LoRA、种子或模型哈希秒搜(哪怕几亿像素的超大图也在后台独立进程生成缩略图,绝不卡死页面),把图往画布上一拖,当时的完整工作流直接回来。
<p align="center"><img src="docs/images/gallery.webp" width="820" alt="图库按标签词块搜索图片,拖入右侧画布立即还原完整工作流"></p>
<!-- guide:combos -->
**6. 每次开新图都要重连一遍大模型、LoRA 和起手提示词。**
框选画布上的这几个节点,右键 → **AMB** → **存为搭配**,就存成一张搭配卡。下次点 **放到画布**(或直接拖卡片),点哪里就在哪里放出一组连好线的节点,画布原有的东西分毫不动。想在已有节点后面加几个 LoRA?到 **当前节点 → 插入 LoRA** 里连点几张卡片,卡片上的数字 `1 · 2 · 3` 就是串联顺序(装了 rgthree 或 Easy-Use 还能一键塞进同一个 LoRA 堆叠节点),一次 `Ctrl+Z` 整串退回。
<p align="center"><img src="docs/images/combo.webp" width="820" alt="把存好的模型 + LoRA + 提示词搭配一键放到画布上自动连好线"></p>
<!-- guide:prompts -->
**7. 提示词一长串逗号,改权重、调顺序眼都花了 —— 提示词工坊。**
点 **停到画布旁** 把工坊挂在画布左侧,选中任意提示词节点,正负面文本立刻拆成可视化词块:点 `+` / `−` 调权重,双击改字,拖拽换顺序,直接敲中文回车自动译成英文词块加进去,常用词卡还能直接拖进画布的正向(绿框)/ 反向(红框)输入框里。
<p align="center"><img src="docs/images/prompt-studio.webp" width="820" alt="提示词工坊停靠在画布旁,按词块实时调整选中节点的提示词权重"></p>
<!-- guide:voices -->
**8. 想让画出来的角色开口说话。**
配合可选节点包 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS):导入 GPT-SoVITS 角色,按情绪绑定参考音频、修正多音字读音,在配音页写好台词直接生成语音。
<p align="center"><img src="docs/images/voices.webp" width="760" alt="角色语音页面:按情绪管理参考音频与台词配音"></p>
<!-- guide:undo -->
**改错了也不怕。**
插件和 AI 在画布上改过的每一个参数、连过的每一根线,都按天记在 **记录** 里,展开就能看修改前后的数值,点 **在画布上找到** 直接跳过去,点 **撤销** 一键退回。
<p align="center"><img src="docs/images/activity.webp" width="760" alt="操作记录页面:展开查看节点修改前后数值,一键在画布上找到或撤销"></p>
<!-- guide:light -->
**不抢你的显存和内存**:
- 插件自身不加载任何生图模型,**零显存占用**,绝不和 ComfyUI 出图抢资源。
- 浏览网格全程走小缩略图缓存,不把原图塞进浏览器;关闭窗口一分半后自动释放图片内存。
- `Anomalous_TTS` 的语音模型在 ComfyUI 生图需要显存时会自动让出,点 **卸载模型** 则彻底清空。
### 📦 安装
<!-- guide:install -->
1. 在 **ComfyUI Manager** 里搜索 `Anomalous Model Browser`,安装后重启 ComfyUI;或在 `custom_nodes` 目录下手动克隆:
```bash
@@ -310,6 +346,7 @@ Open the browser with **Shift + B** (press again to close), the cube button at t
<details>
<summary><b>📖 每个页面和工具的详细说明</b></summary>
<!-- guide:pages -->
窗口左侧是页面图标栏;切换为“侧边停靠”时,按住窗口右边缘即可左右拖宽窄(最窄 `480px`,双击边缘恢复默认 `660px`)。
@@ -332,12 +369,14 @@ Open the browser with **Shift + B** (press again to close), the cube button at t
* **模型扫描**:按文件哈希向 C 站匹配模型信息与封面;离线时也已针对 Manager 清单 300+ 真实模型文件头校准过底模与类型识别,不乱下误判结论。
* **设置**(最下方):中英文实时切换(工作台展台、留言框与底部存档条立即生效,无需刷新页面)、界面缩放、深浅主题、缩略图与视频封面、Hugging Face 国内镜像、数据备份导出/导入,以及自动附带版本与显卡信息的 **报告问题**。
<!-- guide:connect_ai -->
**接入 AI 软件(MCP)**:Antigravity、Claude、Cursor、Cherry Studio、VS Code 等支持 MCP 的客户端可通过本机地址 `http://127.0.0.1:8188/anomalous/mcp` 连接(仅接受本机请求)。完整配置与工具清单见 [docs/guides/mcp.md](docs/guides/mcp.md)。
</details>
<details>
<summary><b>⚙️ 打开方式、快捷键和更新</b></summary>
<!-- guide:open -->
打开方式有三种:按 **Shift + B**(再按一次关闭)、点画布左上角的立方体按钮,或从 ComfyUI 菜单 **Extensions → Anomalous Model Browser** 进入(中文界面下这一项也叫 Extensions)。在 **ComfyUI 设置 → Anomalous Model Browser → 界面** 中可自定义快捷键、语言及入口按钮样式。
@@ -347,6 +386,7 @@ Open the browser with **Shift + B** (press again to close), the cube button at t
<details>
<summary><b>🌐 网络和备份</b></summary>
<!-- guide:network -->
**云服务器(AutoDL、RunPod 等):** 海外云服务器完整支持;国内云服务器只能部分支持:多半连不上 Civitai,有的能连上 `civitai.red` 认出模型名与触发词(封面较难下载)。目前只实测过一台 AutoDL 服务器。从非本机浏览器直接访问时插件默认只读保护,建议通过 SSH 隧道映射到本地打开,详见 [docs/guides/cloud-server.md](docs/guides/cloud-server.md)(内附可直接丢给 AI 助手照着配的步骤)。
+15
View File
@@ -3,6 +3,7 @@ from . import (
model_folder, model_media, model_metadata, model_resolution, model_type_listing, kept_images, quick_index, node_material, recipe_packages, recipes, scan_choice, scan_report, scan_summary,
local_guard, lora_info, mcp_server, model_download, model_import, model_placement, translation_routes, version_manager, tts_version, backup,
network_check, model_heads, model_usage, usage_ledger, sidecar_layout, ai_tasks_routes, snapshot_routes, workflow_saves, example_workflows,
model_guide, job_sheets, prompt_language, lllite_fit, description_translation, voice_covers,
)
from .scanner import *
from .config import *
@@ -47,6 +48,19 @@ def setup_routes(app):
app.router.add_post('/anomalous/saves/ran', workflow_saves.api_ran)
app.router.add_post('/anomalous/saves/remove', workflow_saves.api_remove)
app.router.add_post('/anomalous/saves/remove_group', workflow_saves.api_remove_group)
app.router.add_get('/anomalous/job_sheet', job_sheets.api_get)
app.router.add_get('/anomalous/job_sheet/workflow', job_sheets.api_workflow)
app.router.add_post('/anomalous/job_sheet/start', job_sheets.api_start)
app.router.add_post('/anomalous/job_sheet/report', job_sheets.api_report)
app.router.add_post('/anomalous/job_sheet/ask', job_sheets.api_ask)
app.router.add_post('/anomalous/job_sheet/clear', job_sheets.api_clear)
app.router.add_post('/anomalous/prompt_language', prompt_language.api_check)
app.router.add_post('/anomalous/lllite_fit', lllite_fit.api_check)
app.router.add_post('/anomalous/translate_description', description_translation.api_translate_description)
app.router.add_get('/anomalous/voice_covers', voice_covers.api_list)
app.router.add_get('/anomalous/voice_cover', voice_covers.api_get)
app.router.add_post('/anomalous/voice_cover', voice_covers.api_set)
app.router.add_post('/anomalous/voice_cover/remove', voice_covers.api_remove)
try:
folder_types.upgrade_folder_defaults()
except Exception as error:
@@ -67,6 +81,7 @@ def setup_routes(app):
app.router.add_post('/anomalous/delete_model', model_metadata.api_delete_model)
app.router.add_post('/anomalous/open_model_folder', model_folder.api_open_model_folder)
app.router.add_get('/anomalous/compatible_models', model_catalog.api_compatible_models)
app.router.add_get('/anomalous/model_guide', model_guide.api_model_guide)
app.router.add_get('/anomalous/notebooks', api_get_notebooks)
app.router.add_get('/anomalous/notebooks/by_node_type', api_get_notebooks_by_node_type)
app.router.add_post('/anomalous/save_notebook', api_save_notebook)
+1 -1
View File
@@ -38,7 +38,6 @@ TOOLS = {
"describe_canvas": Entry("look", "canvas", None),
"get_situation": Entry("look", None, None), # the task, the canvas in brief and the next step (mcp_situation.py)
"check_workflow_models": Entry("look", "model", None),
"download_status": Entry("look", "model", None),
"list_voices": Entry("look", "voice", None),
# acting
"set_prompt": Entry("canvas", "prompt", "canvas"),
@@ -76,6 +75,7 @@ TOOLS = {
"run_status": Entry("look", "run", None),
"stop_run": Entry(None, "run", None), # ends a run step of its own; the step says who stopped it
"get_toolbox": Entry(None, None, None),
"read_guide": Entry("look", None, None), # Anomalous's own manual (mcp_guide.py)
# Anomalous's card in the conversation (mcp_apps.py): its own calls, not the AI's steps
"app_task_view": Entry(None, None, None),
"app_answer": Entry(None, None, None),
+12
View File
@@ -11,6 +11,18 @@ _CLIENT_NAMES = (
)
# AI apps that read files and run commands by themselves, by a word of what they call themselves.
_READS_DISK = ("claude code", "claude-code", "cursor", "windsurf", "codex", "gemini", "antigravity", "vs code", "vscode",
"cline", "roo", "kilo", "copilot", "trae", "zed", "opencode", "goose", "aider")
def reads_disk(client):
"""Whether AI app `client` (a task's) is known to open files and run commands by itself: privacy mode filters
what Anomalous sends it, not what it opens on its own (the output folder, the task records)."""
lowered = str(client or "").lower()
return any(word in lowered for word in _READS_DISK)
def client_of(headers, params):
"""A short name for the AI app making a call: the client info it sends, else its User-Agent."""
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
+2
View File
@@ -24,6 +24,8 @@ def log(client, title, decision, reason="", rejected=None, by="user", related=No
raise ValueError("title (what was decided about) and decision (what was decided) are needed")
if by not in BY:
raise ValueError(f"by is one of {', '.join(BY)}")
if isinstance(rejected, str): # one option written as a sentence, not a list: its letters are no options
rejected = [rejected]
rejected = [ai_tasks._short(str(item).strip(), 200) for item in (rejected or []) if str(item).strip()][:MAX_REJECTED]
now = time.time() if now is None else now
record = {"title": ai_tasks._short(title, 200), "decision": ai_tasks._short(decision, MAX_TEXT),
+31 -20
View File
@@ -7,8 +7,8 @@ at places given as fractions of the picture), slider (two pictures over each oth
drag between them), picks (pictures with a reason and "take this one"), models (the user's, by
model_id), diagram (boxes and arrows, laid out by the page; a box can name a canvas node),
table, list, params, choices (buttons; one or several picks) and input (a line of text),
prompts (the user's prompt box: texts and reference pictures the AI gets as tokens, ai_sealed.py),
progress, divider; and area (the user's repaint area beside the AI's, ai_repaint.py). Nothing the AI sends is run or rendered as markup, and no drawing of its own
prompts (the user's prompt box: texts and reference pictures the AI gets as tokens, ai_sealed.py);
and area (the user's repaint area beside the AI's, ai_repaint.py). Nothing the AI sends is run or rendered as markup, and no drawing of its own
(SVG) is taken: every block is checked here to be one of these shapes, with plain strings and
numbers of bounded size, pictures only from the output folder and models only from the user's
folders (the parts: ai_panel_parts.py). The page draws them (ui_ai_panel.js).
@@ -37,7 +37,7 @@ MAX_COLUMNS = 8
MAX_WAIT = 55
TONES = ("normal", "muted", "tip", "warning")
LAYOUTS = ("row", "grid", "compare")
INTERACTIVE = ("choices", "input", "picks", "prompts")
INTERACTIVE = ("choices", "input", "picks", "prompts", "plan")
MAX_PROMPT_BOXES = 8
INPAINT_NOTE = (
"inpaint_image is the picture with the area to repaint as its alpha, already in ComfyUI's input folder: do not "
@@ -168,16 +168,11 @@ def clean_block(block, at, taken):
raise PanelError(f"{where}.count: 1 to {MAX_PROMPT_BOXES} prompts")
out.update(question=_text(block.get("question"), 200, f"{where}.question"), count=count,
negative=block.get("negative") is True, pictures=block.get("pictures") is True)
elif kind == "progress":
value, top = block.get("value"), block.get("max", 100)
if not all(isinstance(n, (int, float)) and not isinstance(n, bool) for n in (value, top)) or top <= 0:
raise PanelError(f"{where}: value and max must be numbers, max above 0")
out.update(label=_text(block.get("label"), 120, f"{where}.label"), value=max(0, min(value, top)), max=top)
elif kind == "divider":
pass
elif kind == "plan": # the steps of an AI's plan, ready-made blocks; only mcp_plans makes it
out["steps"] = [_plan_step(step, f"{where}.steps[{at}]") for at, step in enumerate(_list(block.get("steps"), 6, f"{where}.steps"))]
else:
raise PanelError(f"{where}.type {kind!r} is not one of heading, text, images, slider, picks, models, diagram, "
"table, list, params, choices, input, prompts, progress, divider")
"table, list, params, choices, input, prompts")
if kind in INTERACTIVE or block.get("id") is not None:
block_id = _text(block.get("id") or f"q{at + 1}", 40, f"{where}.id")
if block_id in taken:
@@ -187,6 +182,19 @@ def clean_block(block, at, taken):
return out
def _plan_step(step, where):
from .reference_blocks import BLOCKS
if not isinstance(step, dict) or step.get("block") not in BLOCKS:
raise PanelError(f"{where}: a plan's step is one of Anomalous's ready-made blocks")
out = {"block": step["block"], "title": BLOCKS[step["block"]]["title"], "model": step.get("model") and str(step["model"])[:300]}
if step.get("model"):
out.update(model_name=_text(step.get("model_name") or step["model"], 200, f"{where}.model_name"),
choices=[{"model_id": str(item["model_id"])[:300], "name": _text(item.get("name") or item["model_id"], 200, f"{where}.choices")}
for item in (step.get("choices") or [])[:12] if isinstance(item, dict) and item.get("model_id")])
return out
def clean_blocks(blocks):
blocks = _list(blocks, MAX_BLOCKS, "blocks")
if not blocks:
@@ -225,11 +233,6 @@ def _merge_blocks(existing, incoming):
# A client may send its entire previous layout again. That cannot reorder it.
at = next((i for i, old in enumerate(existing) if i not in used
and {key: value for key, value in old.items() if key != "id"} == block), None)
if at is None and block["type"] == "progress":
matches = [i for i, old in enumerate(existing) if old["type"] == "progress"
and old.get("label") == block.get("label")]
if len(matches) == 1:
at = matches[0]
if at is None:
merged.append(block)
else:
@@ -282,7 +285,6 @@ def update(panel_id, title=None, blocks=None, closed=None):
panel["title"] = _text(title, 120, "title", True)
if blocks is not None:
panel["blocks"] = _merge_blocks(panel["blocks"], blocks)
panel["changed"] = time.time() # when its numbers were written: the page greys a progress bar the task went past
if closed is not None:
panel["closed"] = bool(closed)
step["panel"] = panel
@@ -313,8 +315,13 @@ def answer(task_id, step_id, block_id, choice=None, text=None, now=None, skip=Fa
elif block["type"] == "choices" and block.get("other") and not choice and isinstance(text, str) and text.strip():
entry.update(choice=["other"], labels=["other"], text=_text(text, 2000, "text", True))
elif block["type"] == "prompts":
if not (extra or {}).get("prompts"):
if not ((extra or {}).get("prompts") or (extra or {}).get("gist")):
raise PanelError("Prompts are written in Anomalous's prompt box")
elif block["type"] == "plan": # build (ai_plans.prepare made the steps kept) or what to change
if (extra or {}).get("plan"):
entry.update(choice=["build"], labels=["build"])
else:
entry["text"] = _text(text, 2000, "text", True)
elif block["type"] in ("choices", "picks"):
picked = choice if isinstance(choice, list) else [choice]
options = block["options"] if block["type"] == "choices" else block["items"]
@@ -344,13 +351,17 @@ def leave_message(task_id, text, refs=None, now=None):
"""A message from the user to the AI working on task `task_id`, with what its "@" point at and
the pictures attached (ai_message_refs.py); words may be left out when something is attached.
Returns the step."""
from . import ai_message_refs
from . import ai_message_refs, ai_sealed
try:
refs = ai_message_refs.clean(refs)
except ai_message_refs.RefsError as error:
raise PanelError(str(error))
text = _text(text, MAX_MESSAGE, "text", not refs)
if ai_sealed.MARKED.search(text): # words the user marked to hide reach the AI as tokens
seals = ai_sealed.Seals(task_id)
text = ai_sealed.seal_marked(seals, text)
seals.save()
now = time.time() if now is None else now
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "user_message", "kind": "user", "ok": True,
"summary": "user_message", "text": text, "delivered": False, "results": [], **({"refs": refs} if refs else {})}
@@ -435,7 +446,7 @@ def _collect(client, panel_id, task_id=None):
given.update(delivered=True, delivered_at=time.time())
changed.add(task["id"])
item = {"panel": panel["id"], "question": block["id"], "asked": block.get("question", "")}
item.update({key: given[key] for key in ("choice", "labels", "text", "skipped", "inpaint_image", "note", "recommended_on_timeout", "prompts", "negative", "pictures") if key in given})
item.update({key: given[key] for key in ("choice", "labels", "text", "skipped", "inpaint_image", "note", "recommended_on_timeout", "prompts", "negative", "pictures", "gist", "plan") if key in given})
out.append(item)
for tid in changed:
ai_tasks._save(tasks[tid])
+93
View File
@@ -0,0 +1,93 @@
"""The plan card (蓝图) in the AI workbench: the steps an AI laid out for a picture (add_nodes(plan=…),
mcp_plans.py), each a ready-made block (reference_blocks.py) with its model, shown to the user before
anything is built. The card is a panel block only Anomalous makes ("plan", id "answer"): the user drops
steps but the first, swaps a step's model for one of the same family listed with it, and says build, or
writes what to change. prepare() turns a build into the steps kept, which the AI's call then builds. The
user's "build without asking" is kept in ai_plans.json beside the tasks. A plan of several steps once built,
its first run shows three bases to pick from (the user's rule, kept on the task as "plan_previews" until a
first pass alone has run), and the whole waits for the user's pick ("plan_pick": the first run without one is
refused once, so an AI that hears "go on without" can go on): mcp_actions.run_workflow."""
PREVIEWS, PICK = "plan_previews", "plan_pick"
import json
import os
from . import ai_tasks
from .path_utils import atomic_write_json
def _path():
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_plans.json")
def auto():
"""Whether the user said to build plans without asking."""
try:
with open(_path(), encoding="utf-8") as source:
return json.load(source).get("auto") is True
except (OSError, ValueError, AttributeError):
return False
def set_auto(on):
atomic_write_json(_path(), {"auto": bool(on)})
def mark(task_id, key, on):
"""Sets or clears a plan's rule `key` (PREVIEWS, PICK) on task `task_id`."""
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
if not task or bool(task.get(key)) == bool(on):
return
if on:
task[key] = True
else:
task.pop(key)
ai_tasks._save(task)
def due(task_id, key):
return bool(task_id and (ai_tasks.get_task(task_id) or {}).get(key))
def card_of(step):
"""The plan block of a panel step, or None."""
return next((block for block in ((step or {}).get("panel") or {}).get("blocks") or [] if block.get("type") == "plan"), None)
def kept(steps, edits):
"""[{block, model}] the user kept of a card's `steps`, with the models they swapped in. `edits`:
{drop: [step index], models: {step index: model_id}}, indices from 0; ValueError for an edit not theirs."""
edits = edits if isinstance(edits, dict) else {}
try:
drop = {int(at) for at in edits.get("drop") or []}
models = {int(at): str(model) for at, model in (edits.get("models") or {}).items()}
except (TypeError, ValueError, AttributeError):
raise ValueError("Edits are {drop: [step index], models: {step index: model_id}}.")
if 0 in drop:
raise ValueError("The first step makes the picture: it stays.")
out = []
for at, step in enumerate(steps):
if at in drop:
continue
model = step.get("model")
if at in models:
if models[at] not in {choice["model_id"] for choice in step.get("choices") or []} | {model}:
raise ValueError(f"Step {at + 1}: pick one of the models listed with it.")
model = models[at]
out.append({"block": step["block"], "model": model})
return out
def prepare(task_id, step_id, choice, edits, auto_on):
"""{"plan": the steps kept} when the user says build on a plan card, else None; their "build without
asking" is saved with it."""
step = next((item for item in (ai_tasks.get_task(task_id) or {}).get("steps") or [] if item.get("id") == step_id), None)
card = card_of(step)
if not card or choice != ["build"]:
return None
plan = kept(card["steps"], edits)
if auto_on is True:
set_auto(True)
return {"plan": plan, "note": f"add_nodes(route={step['panel']['id']!r}) builds it as the user left it, if your call stopped waiting."}
+31 -12
View File
@@ -3,8 +3,9 @@ text. The AI still builds and runs workflows and knows the models; it hears how
and whether the run went well, and the user looks at them in Anomalous. Prompts and picture names
reach it as tokens it passes on (ai_sealed.py); the user writes prompts in a prompt box. Each task has its switch (task["privacy"],
in the task's header); new tasks follow the default the user sets in the Gates window
(user/anomalous/ai_privacy.json). Turning it on or off is a step the AI is told of, and one of the
task's decisions (ai_decisions.py).
(user/anomalous/ai_privacy.json, {default, models}). Turning it on or off is a step the AI is told of, and one of the
task's decisions (ai_decisions.py). A private task keeps its models' names too, as tokens the AI picks by type,
base model and the user's note (ai_sealed_models.py), unless the user lets the names through in the Gates window.
All of it happens at one place: mcp_server.py puts the texts back into a call's arguments
(ai_sealed.expand) and hands every tool result to guard() before it leaves, whatever tool or later
@@ -21,21 +22,39 @@ import uuid
from . import ai_sealed, ai_tasks
from .path_utils import atomic_write_json
NOTE = ("Privacy mode: no pictures or prompt text reach you, only tokens like [P1] and [img1] to pass on as they "
"are. get_toolbox(skill=\"privacy\") first.")
NOTE = ("Privacy mode: no pictures or prompt text reach you, only tokens like [P1], [img1] and [M1] (a model) to pass "
"on as they are. get_toolbox(skill=\"privacy\") first.")
def _path():
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_privacy.json")
def default_on():
"""Whether new tasks are private (the Gates window); off unless the user turned it on."""
def _kept():
try:
with open(_path(), encoding="utf-8") as source:
return json.load(source).get("default") is True
except (OSError, ValueError, AttributeError):
return False
data = json.load(source)
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def default_on():
"""Whether new tasks are private (the Gates window); off unless the user turned it on."""
return _kept().get("default") is True
def hides_models():
"""Whether a private task keeps its models' names too (the Gates window); it does unless the user lets them through."""
return _kept().get("models") is not False
def set_hides_models(on):
if not isinstance(on, bool):
raise ValueError("privacy_models is true or false")
with ai_tasks._lock:
atomic_write_json(_path(), {**_kept(), "models": on})
return on
def set_default(on):
@@ -48,7 +67,7 @@ def set_default(on):
if "privacy" not in task:
task["privacy"] = True
ai_tasks._save(task)
atomic_write_json(_path(), {"default": on})
atomic_write_json(_path(), {**_kept(), "default": on})
return on
@@ -90,7 +109,7 @@ def applies(task_id):
def guard(result, private, task_id=None, info=None, tool=""):
"""`result` as it may leave: a private call's pictures taken out (with a word on why), its prompt
text and picture names as tokens (ai_sealed.mask; `info`: ComfyUI's node list)."""
text, picture names and (hides_models) models as tokens (ai_sealed.mask; `info`: ComfyUI's node list)."""
if not private or not isinstance(result, dict):
return result
content = [item for item in result.get("content") or [] if not (isinstance(item, dict) and item.get("type") == "image")]
@@ -99,4 +118,4 @@ def guard(result, private, task_id=None, info=None, tool=""):
result["content"] = content
if isinstance(result.get("structuredContent"), dict):
result["structuredContent"] = {**result["structuredContent"], "privacy": NOTE}
return ai_sealed.mask(result, task_id, info, tool)
return ai_sealed.mask(result, task_id, info, tool, hides_models())
+37 -14
View File
@@ -12,6 +12,11 @@ from . import ai_task_runs, ai_tasks
VERDICTS = ("good", "bad")
# What is off with a run's pictures, a click each under "not quite right" (ui_ai_verdict.js), in the words the AI gets:
# the eyes of an AI that is not sent the pictures (ai_privacy.py), and quicker than writing for any.
ISSUES = {"hands": "the hands are wrong", "face": "the face is off", "body": "the body or the pose is wrong",
"composition": "the framing is off", "dark": "too dark", "bright": "too bright", "blurry": "blurry or short of detail",
"style": "not the style they want", "subject": "not the subject they asked for", "extra": "something is extra or missing"}
class RunControlError(ValueError):
@@ -40,9 +45,10 @@ def stop(step_id, by="user", task_id=None, now=None):
step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
if not step or step.get("tool") != "run_workflow":
raise RunControlError("No such run in this task")
if step.get("state") != "running" or not step.get("prompt_ids"):
sheet = step.get("sheet") # a job sheet: its own stop takes its runs out (job_sheets.py)
if step.get("state") != "running" or not (step.get("prompt_ids") or sheet):
raise RunControlError("This run is not going any more")
prompts = list(step["prompt_ids"])
prompts = list(step.get("prompt_ids") or [])
step["stop"] = by
if by == "user":
task["steps"].append({
@@ -53,6 +59,14 @@ def stop(step_id, by="user", task_id=None, now=None):
del task["steps"][:-ai_tasks.MAX_STEPS]
task["updated"] = max(task.get("updated", 0), now)
ai_tasks._save(task)
if sheet:
from . import job_sheets
try:
job_sheets.ask(sheet, "stop")
except job_sheets.SheetError:
pass # gone or ended already: nothing left to stop
ai_tasks._notify(task["id"])
return {"task": task["id"], "removed": 0, "interrupted": 0}
queue = ai_task_runs._queue()
removed = sum(1 for prompt_id in prompts if queue.delete_queue_item(lambda item, pid=prompt_id: item[1] == pid))
interrupted = sum(1 for prompt_id in prompts if _interrupt(queue, prompt_id))
@@ -60,31 +74,40 @@ def stop(step_id, by="user", task_id=None, now=None):
return {"task": task["id"], "removed": removed, "interrupted": interrupted}
def set_verdict(task_id, step_id, verdict):
"""The user's word on finished run `step_id` of task `task_id`: "good", "bad" or None (taken back);
a word given goes to the AI. Returns it."""
def set_verdict(task_id, step_id, verdict, issues=None):
"""The user's word on finished run `step_id` of task `task_id`: "good", "bad" or None (taken back), with
"bad" what is off (`issues`, keys of ISSUES). A word given goes to the AI; one it has not fetched yet is
corrected where it stands, so picking issues one by one says it once. Returns the verdict."""
if verdict not in (*VERDICTS, None):
raise RunControlError("verdict is good, bad or null")
issues = [name for name in ISSUES if name in issues] if verdict == "bad" and isinstance(issues, list) else []
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
steps = (task or {}).get("steps", [])
step = next((item for item in steps if item["id"] == step_id), None)
if not step or step.get("tool") != "run_workflow" or step.get("state") != "done":
raise RunControlError("No such finished run")
if step.get("verdict") == verdict:
if step.get("verdict") == verdict and step.get("issues", []) == issues:
return verdict
if verdict:
step["verdict"] = verdict
step.update(verdict=verdict, issues=issues)
number = sum(1 for item in steps[:steps.index(step) + 1] if item.get("tool") == "run_workflow")
ai_tasks._add_step(task, {"id": uuid.uuid4().hex[:10], "at": time.time(), "ms": 0, "tool": "user_verdict", "kind": "user",
"ok": True, "state": "done", "summary": "user_verdict", "verdict": verdict, "run": step_id,
"delivered": False, "results": [], "text": (
f"The user says run {number} of this task (step {step_id}) turned out well."
if verdict == "good" else
f"The user says run {number} of this task (step {step_id}) did not turn out well: "
"change the way, a setting or the prompt before running again, or ask them what is off.")})
text = (f"The user says run {number} of this task (step {step_id}) turned out well." if verdict == "good" else
f"The user says run {number} of this task (step {step_id}) did not turn out well: "
+ ("; ".join(ISSUES[name] for name in issues) + ". Change what causes that (the way, a setting, the prompt) "
"before running again." if issues else
"change the way, a setting or the prompt before running again, or ask them what is off."))
told = next((item for item in reversed(steps) if item.get("tool") == "user_verdict" and item.get("run") == step_id
and not item.get("delivered")), None)
if told:
told.update(at=time.time(), verdict=verdict, issues=issues, text=text)
else:
ai_tasks._add_step(task, {"id": uuid.uuid4().hex[:10], "at": time.time(), "ms": 0, "tool": "user_verdict", "kind": "user",
"ok": True, "state": "done", "summary": "user_verdict", "verdict": verdict, "issues": issues,
"run": step_id, "delivered": False, "results": [], "text": text})
else:
step.pop("verdict", None)
step.pop("issues", None)
ai_tasks._save(task)
ai_tasks._notify(task_id)
return verdict
+68 -27
View File
@@ -1,34 +1,45 @@
"""What a private task (ai_privacy.py) keeps from the AI besides pictures: prompt text and picture
names. Each is sealed: kept in the task (task["sealed"], {token: text}) and given to the AI as a
token, "[P3]" for a text and "[img2]" for a picture's name. The AI writes the token where the text
goes (a prompt, a picture to load, its own words around it) and expand() puts the text back before
the tool runs, so the canvas and the runs get it while the AI never reads it.
"""What a private task (ai_privacy.py) keeps from the AI besides pictures: prompt text, picture
names, and model names while the user keeps those too. Each is sealed: kept in the task
(task["sealed"], {token: text}) and given to the AI as a token, "[P3]" for a text, "[img2]" for a
picture's name, "[M1]" for a model (ai_sealed_models.py). The AI writes the token where the text
goes (a prompt, a picture to load, a model to set, its own words around it) and expand() puts the
text back before the tool runs, so the canvas and the runs get it while the AI never reads it.
mask() seals what a tool's answer holds, in this order: every text input of a node (ComfyUI's node
list says which inputs are STRING: prompts, file name prefixes; model names, numbers and choices
stay), the prompt fields of saved prompts, combos, workflows, models and changes (PROMPT_KEYS, and
a few per tool), picture file names anywhere, and last any sealed text wherever it still shows up.
list says which inputs are STRING: prompts, file name prefixes; numbers and choices stay), the
prompt fields of saved prompts, combos, workflows, models and changes (PROMPT_KEYS, and a few per
tool), what the user named (NAMED: the names and tags of combos, workflow cards and saved prompts;
a combo's name is its model's name and the start of its prompt unless they renamed it), the models
when asked to (`models`), picture file names anywhere, and last any sealed text wherever it still
shows up.
seal_answer() takes the user's prompt box (a "prompts" panel block, ui_ai_prompt_box.js): the
prompts and reference pictures, sealed, with the tokens the AI gets for them.
prompts and reference pictures, sealed, with the tokens the AI gets for them, and the gist: the
picture in words the user wrote for the AI to read, handed on as written but for the words they
marked to hide (seal_marked, also for a message they leave): those are tokens inside it.
"""
import json
import re
from . import ai_tasks
from . import ai_sealed_models, ai_tasks
from .ai_sealed_models import MODEL_FILE, WIDGET_KEYS
from .mcp_tools import tool_text
TOKEN = re.compile(r"\[(?:P|img)\d+\]")
TOKEN = re.compile(r"\[(?:P|img|M)\d+\]")
WORD = re.compile(r"[^\W\d_]") # a letter of any script
PICTURE = re.compile(r"[^\s\"'<>|*?,;()\[\]{}]*\.(?:png|jpe?g|webp|gif|bmp|mp4|webm|mov)\b", re.IGNORECASE)
MODEL_FILE = (".safetensors", ".gguf", ".ckpt", ".pt", ".pth", ".bin", ".sft", ".onnx")
# Fields whose text is a prompt, or holds one (a workflow's settings, a change's before and after).
PROMPT_KEYS = {"prompt", "prompts", "positive", "negative", "positive_prompt", "negative_prompt", "notes",
"settings", "before", "after", "filename_prefix", "trigger_words", "trainedWords"}
TOOL_KEYS = {"get_model": {"description", "user_notes"}}
"settings", "before", "after", "filename_prefix", "trigger_words", "trainedWords", "header_words",
"tags", "in_cards", "recipe", "combo", "said_as"} # the last five: names the user gave, where they stand alone
NAMED = ("combos", "workflows", "prompts") # lists of what the user named: each one's name and tags are theirs
TOOL_KEYS = {"get_model": {"description", "user_notes"}, "place_combo": {"name"},
**{tool: {"more_names"} for tool in ("list_combos", "list_workflows", "list_saved_prompts")}}
MIN_SEALED = 3 # shorter texts are not looked for elsewhere (they would match inside other words)
MAX_PROMPTS = 8
MAX_PROMPT = 4000
MAX_GIST = 1000
MARKED = re.compile("\u27e6([^\u27e6\u27e7]*)\u27e7") # words the user marked to hide, the page's button (web/modules/ai_hide_mark.js)
MAX_PICTURES = 4
_loose = {} # tokens of calls tied to no task, while this ComfyUI runs
@@ -65,16 +76,30 @@ class Seals:
def expand(task_id, value):
"""`value` (a tool's arguments) with every known token replaced by its text (a text sealed with a
token in it, its text too)."""
token in it, its text too). A model's token is its model_id, the name its loader lists it by among a
node's values, and its card's name inside other words (ai_sealed_models.py)."""
tokens = Seals(task_id).tokens
def text(words):
def put(match):
token = match.group(0)
return ai_sealed_models.named(tokens[token]) if token.startswith("[M") and token in tokens else tokens.get(token, token)
def text(words, widget):
if words.startswith("[M") and words in tokens:
return ai_sealed_models.written(tokens[words], widget)
for _ in range(3):
words, before = TOKEN.sub(lambda match: tokens.get(match.group(0), match.group(0)), words), words
words, before = TOKEN.sub(put, words), words
if words == before:
break
return words
return _strings(value, text)
def walk(item, widget=False):
if isinstance(item, dict):
return {key: walk(part, widget or key in WIDGET_KEYS) for key, part in item.items()}
if isinstance(item, list):
return [walk(part, widget) for part in item]
return text(item, widget) if isinstance(item, str) else item
return walk(value)
def _sealable(text):
@@ -118,6 +143,9 @@ def _walk(value, seals, info, keys):
else _walk(text, seals, info, keys) for name, text in item.items()}
elif key in keys and (isinstance(item, str) or isinstance(item, list) and all(isinstance(text, str) for text in item)):
out[key] = seal_text(seals, item)
elif key in NAMED and isinstance(item, list):
out[key] = [{**named, **{part: seal_text(seals, named[part]) for part in ("name", "filename") if part in named}}
if isinstance(named, dict) else named for named in _walk(item, seals, info, keys)]
else:
out[key] = _walk(item, seals, info, keys)
return out
@@ -140,15 +168,19 @@ def _hide(seals, text):
return text
def mask(result, task_id, info, tool=""):
"""Tool result `result` of a private task with its prompt text and picture names as tokens."""
def mask(result, task_id, info, tool="", models=False):
"""Tool result `result` of a private task with its prompt text and picture names as tokens, and its
models too when `models` (the user keeps their names, ai_sealed_models.py)."""
if not isinstance(result, dict):
return result
seals = Seals(task_id)
data = result.get("structuredContent")
masked = None
def hidden(value):
return _strings(ai_sealed_models.hide(value, seals) if models else value, lambda text: _hide(seals, text))
if isinstance(data, (dict, list)):
masked = _strings(_walk(data, seals, info or {}, PROMPT_KEYS | TOOL_KEYS.get(tool, set())), lambda text: _hide(seals, text))
masked = hidden(_walk(data, seals, info or {}, PROMPT_KEYS | TOOL_KEYS.get(tool, set())))
result["structuredContent"] = masked
content = []
for item in result.get("content") or []:
@@ -157,7 +189,7 @@ def mask(result, task_id, info, tool=""):
same = masked is not None and json.loads(item.get("text") or "") == data
except ValueError:
same = False
item = {**item, "text": tool_text(masked) if same else _hide(seals, str(item.get("text") or ""))}
item = {**item, "text": tool_text(masked) if same else hidden(str(item.get("text") or ""))}
content.append(item)
if "content" in result:
result["content"] = content
@@ -165,21 +197,30 @@ def mask(result, task_id, info, tool=""):
return result
def seal_answer(task_id, prompts, negative="", pictures=None):
def seal_marked(seals, text):
"""`text` with the words the user marked as tokens of `seals`; the rest stays for the AI to read."""
return MARKED.sub(lambda match: seals.seal(match.group(1).strip()) if match.group(1).strip() else "", text)
def seal_answer(task_id, prompts, negative="", pictures=None, gist=""):
"""The user's prompt box: {prompts, negative, pictures} as tokens, the texts and picture names sealed
in the task, and a line saying them."""
in the task, the gist as the user wrote it for the AI (left out when empty), and a line saying them."""
prompts = [str(item).strip() for item in (prompts if isinstance(prompts, list) else []) if str(item or "").strip()]
if not prompts or len(prompts) > MAX_PROMPTS or any(len(item) > MAX_PROMPT for item in prompts):
raise ValueError(f"Write 1 to {MAX_PROMPTS} prompts of up to {MAX_PROMPT} characters")
gist = str(gist or "").strip()[:MAX_GIST]
if not (prompts or gist) or len(prompts) > MAX_PROMPTS or any(len(item) > MAX_PROMPT for item in prompts):
raise ValueError(f"Write the gist, or 1 to {MAX_PROMPTS} prompts of up to {MAX_PROMPT} characters")
negative = str(negative or "").strip()[:MAX_PROMPT]
named = []
for picture in (pictures if isinstance(pictures, list) else [])[:MAX_PICTURES]:
if isinstance(picture, dict) and isinstance(picture.get("filename"), str) and PICTURE.fullmatch(picture["filename"]):
named.append("/".join(part for part in (str(picture.get("subfolder") or "").strip("/"), picture["filename"]) if part))
seals = Seals(task_id)
gist = seal_marked(seals, gist)
answer = {"prompts": [seals.seal(item) for item in prompts], "negative": seals.seal(negative) if negative else "",
"pictures": [seals.seal(item, "img") for item in named]}
seals.save()
answer["text"] = " ".join(answer["prompts"] + ([f"negative {answer['negative']}"] if negative else [])
+ ([f"pictures {' '.join(answer['pictures'])}"] if named else []))
+ ([f"pictures {' '.join(answer['pictures'])}"] if named else [])) or "gist"
if gist:
answer["gist"] = gist
return answer
+114
View File
@@ -0,0 +1,114 @@
"""Model names of a private task as tokens, while the user keeps those too (ai_privacy.hides_models):
a file name, its folder, the name on its card and its pages can say as much as a prompt does. Each
model of the library is one token ("[M1]", sealed in the task as its model_id, ai_sealed.py) however
a tool's answer writes it: its model_id, the name a loader lists it by, its file name inside a text;
its card's name where a name stands (NAME_KEYS), and inside a text when it is more than one plain
word (a model called "Anima" is not taken out of every sentence about Anima). A model's own entry
(anything with a model_id) loses what tells it apart (ENTRY_DROPS); its type, base model, author's
settings and the user's note for the AI stay, to pick by. A model file the library does not have
(one a workflow names) is a token of its own. written() and named() are the way back: the model_id
where a tool takes one, the loader's name among a node's values, the card's name inside the AI's words."""
import os
import re
from . import ai_toolbox_catalog
MODEL_FILE = (".safetensors", ".gguf", ".ckpt", ".pt", ".pth", ".bin", ".sft", ".onnx")
MODEL_ID = re.compile(r"[\w-]+:\d+:.+")
LOOSE = re.compile(r"[^\s\"'<>|*?,;()\[\]{}]+\.(?:safetensors|gguf|ckpt|pth|pt|bin|sft|onnx)\b", re.IGNORECASE)
ENTRY_DROPS = ("name", "file", "folder", "rel", "cover", "civitai_name", "version", "civitai_url", "download_link", "sha256")
NAME_KEYS = {"name", "names", "more_names", "model", "models", "main_model", "loras", "label", "labels"}
PLAIN_KEYS = {"type", "kind", "base", "base_model", "baseModel", "family", "source"} # never a model's name
WIDGET_KEYS = {"values", "settings", "inputs", "widgets", "widgets_values"} # a node's values: models by their loader's names
MAX_NAME = 260
def _library():
"""The library's models [{id, name, file}] as the materials picker lists them (read at most every few seconds)."""
return ai_toolbox_catalog.everything()["models"]
def _distinct(name):
"""Whether card name `name` is looked for inside a text: not one plain word, which a sentence may use as itself."""
return len(name) >= 4 and not (name.isascii() and name.isalpha())
def _cut(words):
"""The model whose card name begins with `words`, a name a tool cut short (mcp_fit.NAME), or None."""
return next((model["id"] for model in _library() if str(model["name"] or "").startswith(words)), None) if len(words) >= 20 else None
def _gather(value, out):
if isinstance(value, dict):
for key, item in value.items():
out.append(str(key))
_gather(item, out)
elif isinstance(value, list):
for item in value:
_gather(item, out)
elif isinstance(value, str):
out.append(value)
def _forms(blob):
"""What the strings of an answer (`blob`) may call the library's models: {form: model_id} for anywhere
in a text, {name: model_id} for where a name stands. Only the models `blob` can be naming."""
anywhere, names = {}, {}
for model in _library():
shown, stem = str(model["name"] or ""), os.path.splitext(model["file"])[0]
if not any(form and form in blob for form in (shown, stem)):
continue
place = model["id"].split(":", 2)[2]
for form in (model["id"], place, place.replace("/", "\\"), model["file"]):
anywhere.setdefault(form, model["id"])
for form in (shown, stem):
if form:
names.setdefault(form, model["id"])
if _distinct(form):
anywhere.setdefault(form, model["id"])
return anywhere, names
def hide(value, seals):
"""Tool answer `value` (its data, or a text of it) with every model as its token of `seals`."""
strings = []
_gather(value, strings)
blob = "\n".join(strings)
anywhere, names = _forms(blob)
found = sorted((form for form in anywhere if form in blob), key=len, reverse=True)
# A form counts where it stands by itself: "anima.safetensors" is not taken out of "my_anima.safetensors".
known = re.compile(r"(?<![\w.-])(?:" + "|".join(re.escape(form) for form in found) + r")(?!\w)") if found else None
def text(words, key):
if key in PLAIN_KEYS:
return words
whole = anywhere.get(words) or ((names.get(words) or _cut(words)) if key in NAME_KEYS else None)
if whole:
return seals.seal(whole, "M")
swapped = known.sub(lambda match: seals.seal(anywhere[match.group(0)], "M"), words) if known else words
if swapped == words and words.lower().endswith(MODEL_FILE) and len(words) <= MAX_NAME and "\n" not in words and ": " not in words:
return seals.seal(words, "M") # a model file the library does not have, spaces in its name and all
return LOOSE.sub(lambda match: seals.seal(match.group(0), "M"), swapped)
def walk(item, key=""):
if isinstance(item, dict):
entry = isinstance(item.get("model_id"), str)
return {text(name, "") if isinstance(name, str) else name: walk(part, name)
for name, part in item.items() if not (entry and name in ENTRY_DROPS)}
if isinstance(item, list):
return [walk(part, key) for part in item]
return text(item, key) if isinstance(item, str) else item
return walk(value)
def written(text, widget):
"""What the token of the model sealed as `text` puts into a call: the name its loader lists it by among
a node's values (`widget`), else the model_id the tools take."""
return text.split(":", 2)[2].replace("/", os.sep) if widget and MODEL_ID.fullmatch(text) else text
def named(text):
"""The card's name of the model sealed as `text`, for where the AI wrote its token inside its own words."""
found = next((model for model in _library() if model["id"] == text), None)
return str(found["name"] or found["file"]) if found else text
+71
View File
@@ -0,0 +1,71 @@
"""What a private task's AI was sent: every tool call as it came in (its arguments, tokens and
all) and the text of the answer as it left, after ai_privacy.guard took out what the task keeps.
Kept beside the task (user/anomalous/ai_sent/<task>/calls.jsonl, gone with it) so the user can
read for themselves what went out and look for a word in it (the task's menu, "What the AI was
sent", GET /anomalous/ai_tasks/{id}/sent). A line per call, the newest MAX_CALLS of them; a long
text is cut at MAX_TEXT with how much was cut. Pictures are counted, not kept: a private task
sends none."""
import json
import os
import threading
import time
from . import ai_tasks
MAX_CALLS = 300
SPARE = 50 # lines over MAX_CALLS before the file is cut back to it
MAX_TEXT = 20_000
MAX_ARGUMENTS = 4_000
_lock = threading.Lock()
_counts = {} # task id -> lines in its file, while this ComfyUI runs
def _path(task_id):
return os.path.join(ai_tasks.marks_dir(task_id, "ai_sent"), "calls.jsonl")
def _cut(text, limit):
return text if len(text) <= limit else f"{text[:limit]}… (+{len(text) - limit})"
def _lines(task_id):
try:
with open(_path(task_id), encoding="utf-8") as source:
return source.readlines()
except OSError:
return []
def keep(task_id, tool, arguments, result):
"""One call of private task `task_id` as its AI had it: what it wrote and what it was answered."""
content = [item for item in (result.get("content") or [] if isinstance(result, dict) else []) if isinstance(item, dict)]
texts = [str(item.get("text") or "") for item in content if item.get("type") == "text"]
line = json.dumps({"at": time.time(), "tool": tool, "arguments": _cut(json.dumps(arguments, ensure_ascii=False), MAX_ARGUMENTS),
"text": _cut("\n".join(texts), MAX_TEXT), "pictures": sum(1 for item in content if item.get("type") == "image"),
"error": bool(isinstance(result, dict) and result.get("isError"))}, ensure_ascii=False) + "\n"
path = _path(task_id)
with _lock:
if task_id not in _counts:
_counts[task_id] = len(_lines(task_id))
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "a", encoding="utf-8") as out:
out.write(line)
_counts[task_id] += 1
if _counts[task_id] > MAX_CALLS + SPARE:
kept = _lines(task_id)[-MAX_CALLS:]
with open(path + ".part", "w", encoding="utf-8") as out:
out.writelines(kept)
os.replace(path + ".part", path)
_counts[task_id] = MAX_CALLS
def calls(task_id):
"""The calls kept for `task_id`, oldest first; [] when it has none."""
found = []
for line in _lines(task_id)[-MAX_CALLS:]:
try:
found.append(json.loads(line))
except ValueError:
continue
return found
+12 -2
View File
@@ -6,6 +6,11 @@ line a point), and what the user's Ways panel shows (title, about, in both langu
web/modules/ui_ai_blocks.js). Advice that depends on the canvas's main model (by_model) comes
in the form for its size: "large" from `large_from` parameters, "small" under it, "unknown" when the
model or its size cannot be told (model_advice.main_params counts them from the file's header).
A skill about choosing among ready-made blocks names their uses ("ways"): its answer then lists the
blocks of those uses that can run here, each with when to take it, from the blocks' own files
(reference_blocks.whens), so the skill's words hold only the judgment and stay as they are when a
block is added. An answer must fit what an AI app shows in the chat (mcp_fit.BUDGET): a longer one
is filed away unread.
begin_task lists the skills on (name and short); get_toolbox(skill=…)
gives one. The tools' own descriptions only point at a skill, so the list every app holds stays
small. The user turns a skill off in the panel (user/anomalous/ai_skills.json): the AI is not
@@ -99,9 +104,10 @@ def _model_name(model):
return os.path.splitext(os.path.basename(str((model or {}).get("name") or "").replace("\\", "/")))[0]
def get(name, model=None, private=False):
def get(name, model=None, private=False, info=None):
"""The advice of skill `name`, for get_toolbox; `model`: the canvas's main model {name, params},
for a skill whose advice depends on it; `private`: the task is private (only its skills)."""
for a skill whose advice depends on it; `private`: the task is private (only its skills); `info`:
ComfyUI's node list, for a skill that lists the ways that can run here (None: all of them)."""
if name not in SKILLS:
raise SkillError(f"No skill {name!r}; begin_task lists them: {', '.join(SKILLS)}.")
if name in turned_off():
@@ -119,6 +125,10 @@ def get(name, model=None, private=False):
size = f"{model['params'] / 1e9:.1f} billion" if form != "unknown" else ""
lines[1:1] = [line.format(model=_model_name(model) or "The model", size=size) for line in forms[form]]
answer["for_model"] = {"form": form, "model": _model_name(model), "billions": round(model["params"] / 1e9, 1) if size else None}
if skill.get("ways"):
from . import reference_blocks
answer["ways"] = reference_blocks.whens(skill["ways"], info)
return answer
+6 -3
View File
@@ -24,7 +24,7 @@ DEFAULT = "new"
# Page actions (web/modules/mcp_bridge.js) that work on the canvas, so on the task's tab.
TAB_ACTIONS = {"describe_canvas", "set_prompt", "set_model", "add_lora", "place_combo", "check_models", "fix_models",
"run_workflow", "download_missing", "add_node", "add_nodes", "connect_nodes", "set_values", "remove_nodes", "new_workflow", "open_workflow",
"fill_slots"}
"fill_slots", "job_sheet"}
_call = contextvars.ContextVar("anomalous_task_canvas", default=None)
@@ -82,14 +82,17 @@ def begin_call(task_id):
def spec(action):
"""What the page needs for page action `action` of the call running now: {mode, tab, title}, or None."""
"""What the page needs for page action `action` of the call running now: {mode, tab, title, from?}, or None.
`from`: the tab of a canvas the user handed over (the task's "about"), what the task's work starts from."""
holder = _call.get()
if not holder or not holder.get("task") or action not in TAB_ACTIONS:
return None
task = ai_tasks.get_task(holder["task"])
if not task:
return None
return {"mode": mode_of(task), "tab": (task.get("canvas") or {}).get("tab"), "title": (task.get("title") or "")[:60]}
handed = next((item["tab"] for item in task.get("about") or [] if item.get("type") == "canvas"), None)
return {"mode": mode_of(task), "tab": (task.get("canvas") or {}).get("tab"), "title": (task.get("title") or "")[:60],
**({"from": handed} if handed else {})}
def remember(tab):
+7 -6
View File
@@ -77,18 +77,19 @@ def _path(task_id):
return os.path.join(tasks_dir(), f"{task_id}.json")
def marks_dir(task_id):
"""Where the pictures the user marked for a task are kept (ai_marks.py); gone with the task."""
return os.path.join(os.path.dirname(tasks_dir()), "ai_marks", re.sub(r"[^\w-]", "", task_id))
def marks_dir(task_id, kind="ai_marks"):
"""Where a task's side files are kept, gone with the task: the pictures the user marked (ai_marks.py); `kind` "ai_sent": what its AI was sent (ai_sent.py)."""
return os.path.join(os.path.dirname(tasks_dir()), kind, re.sub(r"[^\w-]", "", task_id))
def _forget(task_id):
"""The task's own files: its record and its marked pictures (the plugin's, not the user's pictures)."""
"""The task's own files: its record, its marked pictures and what its AI was sent (the plugin's, not the user's pictures)."""
try:
os.remove(_path(task_id))
except OSError:
pass
shutil.rmtree(marks_dir(task_id), ignore_errors=True)
for kind in ("ai_marks", "ai_sent"):
shutil.rmtree(marks_dir(task_id, kind), ignore_errors=True)
def _load():
@@ -216,7 +217,7 @@ def _take_waiting(client, now, session=""):
return task
ABOUT_TYPES = {"model": "model_id", "image": "image", "combo": "name", "workflow": "name"} # what a task the user starts can be about
ABOUT_TYPES = {"model": "model_id", "image": "image", "combo": "name", "workflow": "name", "canvas": "tab"} # what a user's task is about
def _about(items):
+34 -9
View File
@@ -8,7 +8,7 @@ import asyncio
from aiohttp import web
from . import activity_log, ai_decisions, ai_gates, ai_marks, ai_pictures, ai_privacy, ai_run_control, ai_task_canvas, ai_task_changes, ai_task_links, ai_tasks, ai_toolbox
from . import activity_log, ai_callers, ai_decisions, ai_gates, ai_marks, ai_pictures, ai_privacy, ai_sent, ai_run_control, ai_task_canvas, ai_task_changes, ai_task_links, ai_tasks, ai_toolbox
async def api_list(request):
@@ -33,17 +33,34 @@ async def api_start(request):
return web.json_response({"task": ai_tasks.task_summary(ai_tasks.start_task(title, about=body.get("about"), from_task=parent))})
async def api_plan_auto(request):
"""POST /anomalous/ai_plans/auto: {auto} - build the AI's plans without asking, or ask again (ai_plans.py)."""
from . import ai_plans
body = await _body(request)
ai_plans.set_auto((body or {}).get("auto") is True)
return web.json_response({"auto": ai_plans.auto()})
async def api_get(request):
task = ai_tasks.get_task(request.match_info["task_id"])
if task:
task["canvas_mode"] = ai_task_canvas.mode_of(task) # the task's own choice, else the user's default
task["private"] = ai_privacy.is_private(task) # the same for privacy mode
task["reads_disk"] = ai_callers.reads_disk(task.get("client")) # an app privacy mode cannot keep from the files
task["decisions"], task["decisions_gap"] = ai_decisions.decisions(task), ai_decisions.gap(task)
task["links"] = ai_task_links.links(task["id"]) # the task it goes on from, those going on from it
ai_pictures.mark_gone(task) # pictures deleted since: the page says so
return web.json_response({"task": task}) if task else web.json_response({"error": "not_found"}, status=404)
async def api_sent(request):
"""GET /anomalous/ai_tasks/{id}/sent: {calls: [{at, tool, arguments, text, pictures, error}]}, what the AI of
a private task wrote and was answered, as it went (ai_sent.py)."""
calls = await asyncio.to_thread(ai_sent.calls, request.match_info["task_id"])
return web.json_response({"calls": calls})
async def api_change(request):
"""POST /anomalous/ai_tasks/{id}: {"title": "…"} renames it, {"remove": true} takes it off the list,
{"canvas": "new" | "current"} sets whether the AI works in a tab of the task's own, {"privacy": bool}
@@ -80,9 +97,9 @@ async def api_change(request):
async def api_answer(request):
"""POST /anomalous/ai_tasks/{id}/answer: {step, block, choice: [ids] | text | prompts, negative, pictures | skip: true} -
the user's answer on a panel; a prompt box's texts are sealed and the AI gets tokens (ai_sealed.py)."""
from . import ai_bases, ai_panels, ai_repaint, ai_sealed
"""POST /anomalous/ai_tasks/{id}/answer: {step, block, choice: [ids] | text | prompts, negative, pictures, gist | skip: true} -
the user's answer on a panel; a prompt box's texts are sealed and the AI gets tokens, its gist as written (ai_sealed.py)."""
from . import ai_bases, ai_panels, ai_plans, ai_repaint, ai_sealed
try:
body = await request.json()
@@ -93,12 +110,14 @@ async def api_answer(request):
task_id, step_id, skip = request.match_info["task_id"], str(body.get("step")), body.get("skip") is True
try:
if "prompts" in body and not skip:
extra = await asyncio.to_thread(ai_sealed.seal_answer, task_id, body["prompts"], body.get("negative"), body.get("pictures"))
extra = await asyncio.to_thread(ai_sealed.seal_answer, task_id, body["prompts"], body.get("negative"), body.get("pictures"), body.get("gist"))
else:
# A repaint area picked: its picture is made first (outside the lock), so the AI gets it with the answer;
# a base picked: it is kept in the output folder first (ai_bases.py).
extra = (await asyncio.to_thread(ai_repaint.prepare, task_id, step_id, body.get("choice"), body.get("text"), skip)
or await asyncio.to_thread(ai_bases.prepare, task_id, step_id, body.get("choice"), skip))
or await asyncio.to_thread(ai_bases.prepare, task_id, step_id, body.get("choice"), skip)
or (None if skip else await asyncio.to_thread(ai_plans.prepare, task_id, step_id, body.get("choice"),
body.get("plan"), body.get("auto"))))
except (ai_repaint.RepaintError, ValueError, OSError) as error:
return web.json_response({"error": str(error)}, status=400)
try:
@@ -161,7 +180,8 @@ async def api_gate(request):
async def api_gates(request):
"""GET /anomalous/ai_gates: {policies: {gate: policy}, canvas_tab, tools}; POST {gate, policy} changes one,
POST {canvas_tab: new | current} whether new tasks work in a tab of their own (ai_task_canvas.py),
POST {privacy: bool} whether new tasks are private (ai_privacy.py),
POST {privacy: bool} whether new tasks are private, POST {privacy_models: bool} whether a private task
keeps its models' names too (ai_privacy.py),
POST {runs: call | wait}
how the AI waits for a run (ai_gates.run_wait), POST {answer_wait: 60 | 180 | 600} for the user's answer,
POST {no_answer: stop | recommended} what happens when that time runs out,
@@ -169,6 +189,7 @@ async def api_gates(request):
POST {routes: allow | ask} an AI placing nodes by itself (ai_gates.route_rule)."""
def settings():
return {"policies": ai_gates.policies(), "canvas_tab": ai_task_canvas.default_mode(), "privacy": ai_privacy.default_on(),
"privacy_models": ai_privacy.hides_models(),
"runs": ai_gates.run_wait(), "answer_wait": ai_gates.answer_wait(), "no_answer": ai_gates.no_answer(),
"nodes": ai_gates.node_rule(), "routes": ai_gates.route_rule()}
@@ -180,6 +201,8 @@ async def api_gates(request):
ai_task_canvas.set_default(body["canvas_tab"])
elif "privacy" in (body or {}):
ai_privacy.set_default(body["privacy"])
elif "privacy_models" in (body or {}):
ai_privacy.set_hides_models(body["privacy_models"])
elif "runs" in (body or {}):
ai_gates.set_run_wait(body["runs"])
elif "answer_wait" in (body or {}):
@@ -289,12 +312,12 @@ async def api_stop(request):
async def api_verdict(request):
"""POST /anomalous/ai_tasks/{id}/runs/{step}/verdict {verdict: good | bad | null} - the user's word on a run, told to the AI."""
"""POST /anomalous/ai_tasks/{id}/runs/{step}/verdict {verdict: good | bad | null, issues?: [what is off]} - the user's word on a run, told to the AI."""
from . import ai_panels
body = await _body(request) or {}
try:
verdict = await asyncio.to_thread(ai_run_control.set_verdict, request.match_info["task_id"], request.match_info["step_id"], body.get("verdict"))
verdict = await asyncio.to_thread(ai_run_control.set_verdict, request.match_info["task_id"], request.match_info["step_id"], body.get("verdict"), body.get("issues"))
except ai_run_control.RunControlError as error:
return web.json_response({"error": str(error)}, status=400)
ai_panels.wake()
@@ -346,8 +369,10 @@ def register_routes(app):
activity_log.add_listener(ai_task_changes.note_activity) # the user's changes while an AI works
app.router.add_get("/anomalous/ai_tasks", api_list)
app.router.add_post("/anomalous/ai_tasks", api_start)
app.router.add_post("/anomalous/ai_plans/auto", api_plan_auto)
app.router.add_get("/anomalous/ai_tasks/{task_id}", api_get)
app.router.add_post("/anomalous/ai_tasks/{task_id}", api_change)
app.router.add_get("/anomalous/ai_tasks/{task_id}/sent", api_sent)
app.router.add_post("/anomalous/ai_tasks/{task_id}/answer", api_answer)
app.router.add_post("/anomalous/ai_tasks/{task_id}/message", api_message)
app.router.add_post("/anomalous/ai_tasks/{task_id}/typing", api_typing)
+55
View File
@@ -0,0 +1,55 @@
"""A chain of ready-made blocks (reference_blocks.py) built on a new tab, one after another: the plan an AI
lays out in big steps (make the picture, redraw it with another model, fix the faces, enlarge), built by
Anomalous so the AI wires nothing. The first step makes the picture (a block that stands alone, on the
model it names). Each next block goes on from what the step before gave (a block's "gives": the sampler
a next one takes its model, prompts and settings from, and the output node whose picture it works on),
both named to the page, so nothing is guessed when the canvas holds several samplers and outputs. A block
that gives nothing changes the step it is put on (a pose to follow, a split between two models), and the
next goes on from the same place. A step may name a model for the block's own loader ("own_model"), and
LoRAs: on the picture's model, or on a bare second loader (a copied model line has them already)."""
from . import reference_blocks
from .mcp_tools import ToolError
def _loader(step, block, nodes):
"""The node id of the block's own model loader in `nodes` (add_nodes' key -> id), or None."""
own = reference_blocks.BLOCKS[block].get("own_model") or {}
found = next((nodes[key] for key in own.get("nodes") or () if key in nodes), None)
if step.get("model") and found is None:
raise ToolError(f"Step {block} has no loader of its own for a second model; leave model out.")
return None if found is None else int(found)
def build(ctx, steps, name="plan"):
"""`steps` ([{use, want | block, model, loras, image}], the first a picture) on a new tab named `name`.
{"steps": [{"block", "nodes"}], "nodes": every step's nodes by "<step>.<key>"} for what was built."""
from . import mcp_actions, mcp_canvas_edit
if not steps:
raise ToolError("A plan has at least its first step, the picture.")
mcp_canvas_edit.new_workflow(ctx, name)
at, built = {"sampler": None, "save": None}, []
for index, step in enumerate(steps):
how = {key: step[key] for key in ("use", "want", "block", "image") if step.get(key) is not None}
if index == 0:
how["model"] = step.get("model")
else:
how.update(near_node_id=at["sampler"], save_node_id=at["save"])
added = mcp_canvas_edit.add_nodes(ctx, **how)
block, nodes = added.get("block") or step.get("block"), added.get("nodes") or {}
if index == 0 and not reference_blocks.BLOCKS.get(block, {}).get("standalone"):
raise ToolError("A plan starts with the picture (use=picture).")
loader = int(nodes["model"]) if index == 0 else _loader(step, block, nodes)
if index and step.get("model"):
mcp_actions.set_model(ctx, loader, step["model"])
copied = (reference_blocks.BLOCKS[block].get("own_model") or {}).get("copies_loras")
if loader is not None and not copied:
for lora in step.get("loras") or ():
mcp_actions.add_lora(ctx, lora, after_node_id=loader)
for role, key in (reference_blocks.BLOCKS[block].get("gives") or {}).items():
if key in nodes:
at[role] = int(nodes[key])
built.append({"block": block, "nodes": nodes})
return {"steps": built, "nodes": {f"{at_step}.{key}": node for at_step, item in enumerate(built, 1)
for key, node in item["nodes"].items()}}
+1
View File
@@ -108,6 +108,7 @@
}
},
"after": "Write the new scene in the prompt and keep the character's own words (who, hair, clothes) so its edge fits, then run_workflow. The picture's size is the drawing's: the empty latent is no longer used. The kept character (cut out) is among the run's previews: if part of it is missing or the old background clings to it, say so; a halo at its edge: raise the GrowMask expand (6) a little.",
"gives": {},
"notes": "Denoise 1.0: the background is drawn anew, not changed. GrowMask lets the background reach 6 px into the character so the seam is redrawn.",
"models": [],
"tested": {
+1
View File
@@ -80,6 +80,7 @@
}
},
"after": "Set the picture's size to the drawing's shape (get_image_info on it; the empty latent's width and height), write the colours and style in the prompt (what is drawn, hair, clothes, background), then run_workflow. Its lines (white on black) are among the run's previews: if they are black on white, the drawing was light lines on dark: remove_nodes the ImageInvert and wire the picture straight in.",
"gives": {},
"notes": "A drawing is black lines on white; a lineart ControlNet reads white lines on black, hence ImageInvert.",
"models": [
"union_promax_sdxl",
+1
View File
@@ -84,6 +84,7 @@
}
},
"after": "run_workflow: the picture now keeps that layout (strength 0.7, for the first 70% of the steps; lower either to let the model change more). The prompt still says what is drawn. Its depth map is among the run's previews: if it is empty or wrong (a crowded or very stylised picture), the picture is too hard to read.",
"gives": {},
"variants": {
"anima": {
"packs": {
+1
View File
@@ -89,6 +89,7 @@
}
},
"after": "run_workflow: the picture now takes that pose (strength 0.8, for the first 80% of the steps; lower either for a looser pose). The prompt still says who and what: change it if it fights the pose. Its pose skeleton is among the run's previews: if it is empty or wrong (a crowded or very stylised picture), try control_depth.",
"gives": {},
"variants": {
"anima": {
"packs": {
+5 -2
View File
@@ -39,8 +39,8 @@
"class_type": "FaceDetailer",
"inputs": {
"image": [
"$decode",
0
"$save.images",
"*"
],
"model": [
"$sampler.model",
@@ -117,6 +117,9 @@
}
},
"after": "run_workflow saves the picture as before and the detailed one beside it (prefix detailed).",
"gives": {
"save": "save"
},
"models": [
"face_yolov8m",
"hand_yolov8s"
+3
View File
@@ -231,6 +231,9 @@
}
},
"after": "A fallback: a FLUX-Fill redraw of one square crop around each hand did better in the user's tests; use that when you have it. The picture is enlarged to about 4 megapixels (2× for one of about 1 megapixel, at most 2×, not at all for a large one) and drawn again lightly (the sampler now takes it, at denoise 0.4), then the hands are redrawn larger: with SAM3 here, first each hand it finds by the word inside its own outline (shaped_hands), then each hand the detector finds, in its box (hands, denoise 0.45), saved with prefix hands_fixed (without SAM3, which the user can get in the ways panel, only the detector's pass); the enlarged one is saved too, without the hand pass. About 3 tries in 4 gave a good hand in the pictures tried (one or two people, simple poses), no rate for crowds or hard poses: run_workflow with batch 4 (each try its own seed), then check them before the user does: zoom_image each hands_fixed picture on the hand, compare_with the enlarged one (the light redraw alone already changes faces and hands), and drop a hand with the wrong number of fingers, fused or melted fingers, one far bigger or smaller than its arm, a seam at its edge, or a hand that was fine made worse; say if a hand was missed. None good: run_workflow batch 4 again, at most twice. Then a picks panel zoomed on the hand lets the user choose among the good ones.",
"gives": {
"save": "save"
},
"notes": "Tried on the Codex test picture (1280×832, three characters, the hand about 110×78 px): the hand pass alone gave a good hand in about half the seeds whatever its size (512 or 768) and denoise (0.45 or 0.55); enlarging 1.5× first gave good hands in 3 of 4, 2× gave sharper fingers in the same seeds (about 105 s instead of 75 s). Denoise 0.4 for the enlarged redraw kept the layout; faces and splashes change a little. The SAM3 pass (2026-11-03): its word says how many it takes, \"hand\" alone only the likeliest hand, \"hand:8\" up to eight; on a 832×1216 picture of a girl waving both hands (enlarged 2×) it outlined both hands closely at threshold 0.5 (0.2 found no more), about 112 s with both passes on Anima with the Turbo LoRA. GPT's check the same day (two people, one hand with fused fingers, two seeds): SAM3 found both hands (one outline took in the wrist), both versions gave acceptable hands; on that one picture the SAM3 pass was not shown better than the detector alone.",
"models": [
"hand_yolov8s",
+1
View File
@@ -67,6 +67,7 @@
}
},
"after": "The sampler repaints only the area, at denoise 0.5 so it keeps the picture's look (higher changes more). The marked area's size becomes the size of what is painted there: mark tightly around it (a hand, not the arm beside it). Keep denoise 0.35-0.5 to fix something and keep its size and pose; 0.7 and up paints a new thing that fills the whole mark. Hands and faces vary a lot from seed to seed: run_workflow with batch 4 and let the user pick, rather than judging one. Then run_workflow.",
"gives": {},
"models": [],
"tested": {}
}
+1
View File
@@ -96,6 +96,7 @@
}
},
"after": "The sampler now repaints only the cropped area, at denoise 0.45 (lower keeps more of the old). Its prompt is still the whole picture's: set_prompt it to what belongs in the area (\"a hand, five fingers\"), or it paints the whole subject there. The marked area's size becomes the size of what is painted there: mark tightly around it (a hand, not the arm beside it). Keep denoise 0.35-0.5 to fix something and keep its size and pose; 0.7 and up paints a new thing that fills the whole mark. Hands and faces vary a lot from seed to seed: run_workflow with batch 4 and let the user pick, rather than judging one. Then run_workflow; the saved picture is the whole one, stitched.",
"gives": {},
"notes": "Denoise 0.45: a fix. At 0.55-0.75 a hand came out the size of its mark (too big, 2026-10-29 Codex tests A-C), at 1 a model not made for inpainting paints a whole new subject into the crop; 0.4 kept the hand's size. Context factor 2.0 is the source's.",
"variants": {
"anima": {
+3
View File
@@ -77,6 +77,9 @@
}
},
"after": "The KSampler is now LanPaint's (same steps, cfg and seed): it samples the whole picture and changes only the area, blending it in. Keep the prompt about the whole picture and add what the area should show (set_prompt mode append: \"a hand, five fingers\"). Then run_workflow; it takes a few times longer than before.",
"gives": {
"sampler": "sampler"
},
"models": [],
"tested": {
"gpu": "RTX 4050 Laptop",
+4
View File
@@ -230,6 +230,10 @@
}
},
"after": "The picture's nodes are on the canvas, on the checkpoint picked (the loader titled Model), with common SDXL settings (28 steps, euler_ancestral, cfg 5.5, 832x1216; get_model's author_examples has what the model's author used). run_workflow with prompt and negative fills them in. A style LoRA the user picked: add_lora. A second pass after it (enlarge, another model): add_nodes with that use.",
"gives": {
"sampler": "sampler",
"save": "save"
},
"notes": "SDXL's values are common ones, not tested per model here; Anima's are the base pass of the user's tested two-pass card.",
"tested": {
"gpu": "RTX 4050 Laptop",
+1
View File
@@ -164,6 +164,7 @@
}
},
"after": "Write both in the prompt, the character (who, hair, clothes) and the place as it looks, then run_workflow. The size is the character picture's; the place is cropped to it. The pasted picture before the redraw is among the previews. The place changes too much: lower the denoise (0.5); the character still looks pasted on: raise it a little.",
"gives": {},
"notes": "Denoise 0.5 redraws the place lightly around the character, keeping its look; the character itself is not redrawn but for 6 px of its edge.",
"models": [],
"tested": {
+277
View File
@@ -0,0 +1,277 @@
{
"job": "refine",
"want": "background",
"rank": 9,
"short": "background only",
"when": "another model redraws only the background, the character kept to the pixel.",
"title": {
"zh": "换模型精修·只改场景",
"en": "Refine only the background"
},
"about": {
"zh": "自动抠出人物,换另一个模型只重画背景,人物和它边上一圈原样不动;Anima 上有 Tile 时照着原图的线条画,可以改得更多。另存一张。",
"en": "Cuts the character out; another model redraws only the background, the character and a band around it left as they were; on Anima with the Tile it follows the picture's lines and may change more. Saved beside it."
},
"source": {
"zh": "常见的“抠图换背景”做法,只是背景不换新、只轻轻重画,强度按 2026-11-10 的场景重画对照定",
"en": "The common cut-out-and-replace-the-background way, the background only lightly redrawn, not replaced; its strength from a background redraw comparison of 2026-11-10"
},
"packs": {
"InspyrenetRembg": "comfyui-inspyrenet-rembg"
},
"extras": {
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
},
"nodes": {
"second_model": {
"class_type": "CheckpointLoaderSimple",
"like": "$loader",
"inputs": {}
},
"second_positive": {
"class_type": "CLIPTextEncode",
"like": "$sampler.positive",
"inputs": {
"clip": ["second_model", 1]
}
},
"second_negative": {
"class_type": "CLIPTextEncode",
"like": "$sampler.negative",
"inputs": {
"clip": ["second_model", 1]
}
},
"cut": {
"class_type": "InspyrenetRembg",
"inputs": {
"image": ["$save.images", "*"],
"torchscript_jit": "default"
}
},
"solid": {
"class_type": "ThresholdMask",
"inputs": {
"mask": ["cut", 1],
"value": 0.5
}
},
"edge": {
"class_type": "GrowMask",
"inputs": {
"mask": ["solid", 0],
"expand": 12,
"tapered_corners": true
}
},
"background": {
"class_type": "InvertMask",
"inputs": {
"mask": ["edge", 0]
}
},
"encode": {
"class_type": "VAEEncode",
"inputs": {
"pixels": ["$save.images", "*"],
"vae": ["second_model", 2]
}
},
"area": {
"class_type": "SetLatentNoiseMask",
"inputs": {
"samples": ["encode", 0],
"mask": ["background", 0]
}
},
"redraw": {
"class_type": "KSampler",
"like": "$sampler",
"inputs": {
"model": ["second_model", 0],
"positive": ["second_positive", 0],
"negative": ["second_negative", 0],
"latent_image": ["area", 0],
"denoise": 0.45
}
},
"decode": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["redraw", 0],
"vae": ["second_model", 2]
}
},
"paste": {
"class_type": "ImageCompositeMasked",
"inputs": {
"destination": ["$save.images", "*"],
"source": ["decode", 0],
"x": 0,
"y": 0,
"resize_source": false,
"mask": ["background", 0]
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": ["paste", 0],
"filename_prefix": "refined_background"
}
},
"as_picture": {
"class_type": "MaskToImage",
"inputs": {
"mask": ["background", 0]
}
},
"look": {
"class_type": "PreviewImage",
"inputs": {
"images": ["as_picture", 0]
}
}
},
"after": "The redraw's loader (second_model) starts as the same checkpoint: set_model it to the model the user wants for the background (ask_user, search_models), add_lora a style LoRA onto it if they want one, and write its prompts (second_positive, second_negative, copied from the first) in that model's style. Then run_workflow; the picture is saved beside it (prefix refined_background), the character the same to the pixel. Denoise 0.45: textures and light take the second model's look and the layout stays; at 0.6 the ground gets heavier and the edge round the character may show (dark chair legs most). The preview shows what is redrawn (white): part of the character in it, say so.",
"gives": {
"sampler": "redraw",
"save": "save"
},
"own_model": {
"nodes": ["second_model"],
"copies_loras": false
},
"notes": "The background is the inverse of the character's mask grown 12 px, so the character and a 12 px band around it, where it meets a bench or the ground, stay as they were; the paste-back keeps them to the pixel. In the 2026-11-10 comparison (an Anima picture, Kirazuri redrawing the background) this way at 0.45 changed the scene about 3.6% on average and the character not at all; the bench still held the character up. The cut-out takes what stands out in front, often with what the character sits on or holds (a bench, an umbrella, a bag): kept with it, so where they touch stays as it was. On Anima an animaTileRepair LLLite (strength 0.7, the first 80% of the steps, as the Tile upscale) holds the picture's lines: in a test of 2026-11-10 at denoise 0.70 without it the background's windows vanished and the bag was redrawn as another, with it they stayed (lines kept 61% to 85% in the background, 77% to 92% on the character), so it redraws at 0.7; without the Tile file the node goes and 0.45 is the strength. How much 0.7 changes follows the sampler: dpmpp_2m_sde (the Anima picture way's) about 8% on the character in that test, euler on a canvas there about 5%, the lines kept either way.",
"models": [],
"tested": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"date": "2026-11-10",
"runs": [
{
"model": "Anima (Harem 2.9B, then Kirazuri v4.0 on the background, the Tile, denoise 0.7)",
"size": "1440×1024",
"seconds": 250
}
]
},
"variants": {
"anima": {
"optional_lllite": {
"tile": "tile"
},
"nodes": {
"second_model": {
"class_type": "UNETLoader",
"like": "$loader",
"inputs": {}
},
"tile": {
"class_type": "AnimaLLLiteApply_sdscripts",
"optional": true,
"inputs": {
"model": ["second_model", 0],
"lllite_name": "$tile",
"image": ["$save.images", "*"],
"strength": 0.7,
"start_percent": 0.0,
"end_percent": 0.8,
"preserve_wrapper": true
}
},
"cut": {
"class_type": "InspyrenetRembg",
"inputs": {
"image": ["$save.images", "*"],
"torchscript_jit": "default"
}
},
"solid": {
"class_type": "ThresholdMask",
"inputs": {
"mask": ["cut", 1],
"value": 0.5
}
},
"edge": {
"class_type": "GrowMask",
"inputs": {
"mask": ["solid", 0],
"expand": 12,
"tapered_corners": true
}
},
"background": {
"class_type": "InvertMask",
"inputs": {
"mask": ["edge", 0]
}
},
"encode": {
"class_type": "VAEEncode",
"inputs": {
"pixels": ["$save.images", "*"],
"vae": ["$decode.vae", "*"]
}
},
"area": {
"class_type": "SetLatentNoiseMask",
"inputs": {
"samples": ["encode", 0],
"mask": ["background", 0]
}
},
"redraw": {
"class_type": "KSampler",
"like": "$sampler",
"inputs": {
"model": ["tile|second_model", 0],
"positive": ["$sampler.positive", "*"],
"negative": ["$sampler.negative", "*"],
"latent_image": ["area", 0],
"denoise": 0.7
}
},
"decode": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["redraw", 0],
"vae": ["$decode.vae", "*"]
}
},
"paste": {
"class_type": "ImageCompositeMasked",
"inputs": {
"destination": ["$save.images", "*"],
"source": ["decode", 0],
"x": 0,
"y": 0,
"resize_source": false,
"mask": ["background", 0]
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": ["paste", 0],
"filename_prefix": "refined_background"
}
},
"as_picture": {
"class_type": "MaskToImage",
"inputs": {
"mask": ["background", 0]
}
},
"look": {
"class_type": "PreviewImage",
"inputs": {
"images": ["as_picture", 0]
}
}
},
"after": "On this Anima model the redraw's loader (second_model) is a UNET loader that starts as the canvas's model; it reuses the canvas's text encoder, VAE and prompts. set_model it to the other Anima model the user wants for the background (ask_user, search_models); a LoRA on the first model is not on it: add_lora it onto second_model when the user wants that look kept. Then run_workflow; the picture is saved beside it (prefix refined_background), the character the same to the pixel. The Tile (node tile) holds the picture's lines, so at denoise 0.7 walls, ground and sky take the second model's look and keep their places (windows stay windows, one track stays one); without the tile node (no animaTileRepair file here) set redraw's denoise to 0.45. At this strength colours may drift (grass, sky): say so. The preview shows what is redrawn (white): part of the character in it, say so."
}
}
}
+307
View File
@@ -0,0 +1,307 @@
{
"job": "refine",
"want": "character",
"rank": 8,
"short": "character only",
"when": "another model redraws only the character, the scene kept to the pixel.",
"title": {
"zh": "换模型精修·只改人物",
"en": "Refine only the character"
},
"about": {
"zh": "自动抠出人物,换另一个模型只重画人物,边缘慢慢减弱,场景原样不动;Anima 上有 Tile 时照着原图的线条画,可以改得更多。另存一张。",
"en": "Cuts the character out; another model redraws only it, fading out at its edge, the scene left as it was; on Anima with the Tile it follows the picture's lines and may change more. Saved beside it."
},
"source": {
"zh": "常见的抠图加渐变遮罩局部重画(ComfyUI 自带的 Differential Diffusion),强度按 2026-11-10 的人物重画对照定",
"en": "The common cut-out with a graded-mask repaint (ComfyUI's own Differential Diffusion), its strength from a character redraw comparison of 2026-11-10"
},
"packs": {
"InspyrenetRembg": "comfyui-inspyrenet-rembg"
},
"extras": {
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
},
"nodes": {
"second_model": {
"class_type": "CheckpointLoaderSimple",
"like": "$loader",
"inputs": {}
},
"second_positive": {
"class_type": "CLIPTextEncode",
"like": "$sampler.positive",
"inputs": {
"clip": ["second_model", 1]
}
},
"second_negative": {
"class_type": "CLIPTextEncode",
"like": "$sampler.negative",
"inputs": {
"clip": ["second_model", 1]
}
},
"cut": {
"class_type": "InspyrenetRembg",
"inputs": {
"image": ["$save.images", "*"],
"torchscript_jit": "default"
}
},
"solid": {
"class_type": "ThresholdMask",
"inputs": {
"mask": ["cut", 1],
"value": 0.5
}
},
"edge": {
"class_type": "GrowMask",
"inputs": {
"mask": ["solid", 0],
"expand": 12,
"tapered_corners": true
}
},
"as_picture": {
"class_type": "MaskToImage",
"inputs": {
"mask": ["edge", 0]
}
},
"fade": {
"class_type": "ImageBlur",
"inputs": {
"image": ["as_picture", 0],
"blur_radius": 31,
"sigma": 0.5
}
},
"area_mask": {
"class_type": "ImageToMask",
"inputs": {
"image": ["fade", 0],
"channel": "red"
}
},
"soft": {
"class_type": "DifferentialDiffusion",
"inputs": {
"model": ["second_model", 0]
}
},
"encode": {
"class_type": "VAEEncode",
"inputs": {
"pixels": ["$save.images", "*"],
"vae": ["second_model", 2]
}
},
"area": {
"class_type": "SetLatentNoiseMask",
"inputs": {
"samples": ["encode", 0],
"mask": ["area_mask", 0]
}
},
"redraw": {
"class_type": "KSampler",
"like": "$sampler",
"inputs": {
"model": ["soft", 0],
"positive": ["second_positive", 0],
"negative": ["second_negative", 0],
"latent_image": ["area", 0],
"denoise": 0.45
}
},
"decode": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["redraw", 0],
"vae": ["second_model", 2]
}
},
"paste": {
"class_type": "ImageCompositeMasked",
"inputs": {
"destination": ["$save.images", "*"],
"source": ["decode", 0],
"x": 0,
"y": 0,
"resize_source": false,
"mask": ["area_mask", 0]
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": ["paste", 0],
"filename_prefix": "refined_character"
}
},
"look": {
"class_type": "PreviewImage",
"inputs": {
"images": ["fade", 0]
}
}
},
"after": "The redraw's loader (second_model) starts as the same checkpoint: set_model it to the model the user wants for the character (ask_user, search_models), add_lora a character or style LoRA onto it if they want one, and write its prompts (second_positive, second_negative, copied from the first) in that model's style. Then run_workflow; the picture is saved beside it (prefix refined_character), the scene the same to the pixel. Denoise 0.45: face, hair and clothes take the second model's look and the pose stays; 0.3 changes little; at 0.6 the face changes more and colours fade. The preview shows what is redrawn (white, fading at the edge): part of the character missing, or the background in it, say so.",
"gives": {
"sampler": "redraw",
"save": "save"
},
"own_model": {
"nodes": ["second_model"],
"copies_loras": false
},
"notes": "The character's mask grows 12 px and is blurred about 15 px, so the redraw fades out at its edge; Differential Diffusion makes the mask's grey a weaker redraw, and the paste-back keeps everything outside it as it was. In the 2026-11-10 comparison (an Anima picture, RealSkin redrawing the character) this way at 0.45 changed the character about 5.5% on average and the scene 0.03%; the coat stayed on the bench. A small character in a wide picture gets few pixels for its face: repaint it (inpaint_crop_stitch) instead. The cut-out takes what stands out in front, often with what the character sits on or holds (a bench, an umbrella, a bag): redrawn with it, so where they touch still fits. On Anima an animaTileRepair LLLite (strength 0.7, the first 80% of the steps, as the Tile upscale) holds the picture's lines: in a test of 2026-11-10 at denoise 0.70 without it the background's windows vanished and the bag was redrawn as another, with it they stayed (lines kept 61% to 85% in the background, 77% to 92% on the character), so it redraws at 0.7; without the Tile file the node goes and 0.45 is the strength. How much 0.7 changes follows the sampler: dpmpp_2m_sde (the Anima picture way's) about 8% on the character in that test, euler on a canvas there about 5%, the lines kept either way.",
"models": [],
"tested": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"date": "2026-11-10",
"runs": [
{
"model": "Anima (Harem 2.9B, then RealSkin 1.3 on the character, the Tile, denoise 0.7)",
"size": "1440×1024",
"seconds": 250
}
]
},
"variants": {
"anima": {
"optional_lllite": {
"tile": "tile"
},
"nodes": {
"second_model": {
"class_type": "UNETLoader",
"like": "$loader",
"inputs": {}
},
"tile": {
"class_type": "AnimaLLLiteApply_sdscripts",
"optional": true,
"inputs": {
"model": ["second_model", 0],
"lllite_name": "$tile",
"image": ["$save.images", "*"],
"strength": 0.7,
"start_percent": 0.0,
"end_percent": 0.8,
"preserve_wrapper": true
}
},
"cut": {
"class_type": "InspyrenetRembg",
"inputs": {
"image": ["$save.images", "*"],
"torchscript_jit": "default"
}
},
"solid": {
"class_type": "ThresholdMask",
"inputs": {
"mask": ["cut", 1],
"value": 0.5
}
},
"edge": {
"class_type": "GrowMask",
"inputs": {
"mask": ["solid", 0],
"expand": 12,
"tapered_corners": true
}
},
"as_picture": {
"class_type": "MaskToImage",
"inputs": {
"mask": ["edge", 0]
}
},
"fade": {
"class_type": "ImageBlur",
"inputs": {
"image": ["as_picture", 0],
"blur_radius": 31,
"sigma": 0.5
}
},
"area_mask": {
"class_type": "ImageToMask",
"inputs": {
"image": ["fade", 0],
"channel": "red"
}
},
"soft": {
"class_type": "DifferentialDiffusion",
"inputs": {
"model": ["tile|second_model", 0]
}
},
"encode": {
"class_type": "VAEEncode",
"inputs": {
"pixels": ["$save.images", "*"],
"vae": ["$decode.vae", "*"]
}
},
"area": {
"class_type": "SetLatentNoiseMask",
"inputs": {
"samples": ["encode", 0],
"mask": ["area_mask", 0]
}
},
"redraw": {
"class_type": "KSampler",
"like": "$sampler",
"inputs": {
"model": ["soft", 0],
"positive": ["$sampler.positive", "*"],
"negative": ["$sampler.negative", "*"],
"latent_image": ["area", 0],
"denoise": 0.7
}
},
"decode": {
"class_type": "VAEDecode",
"inputs": {
"samples": ["redraw", 0],
"vae": ["$decode.vae", "*"]
}
},
"paste": {
"class_type": "ImageCompositeMasked",
"inputs": {
"destination": ["$save.images", "*"],
"source": ["decode", 0],
"x": 0,
"y": 0,
"resize_source": false,
"mask": ["area_mask", 0]
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": ["paste", 0],
"filename_prefix": "refined_character"
}
},
"look": {
"class_type": "PreviewImage",
"inputs": {
"images": ["fade", 0]
}
}
},
"after": "On this Anima model the redraw's loader (second_model) is a UNET loader that starts as the canvas's model; it reuses the canvas's text encoder, VAE and prompts. set_model it to the other Anima model the user wants for the character (ask_user, search_models); a LoRA on the first model is not on it: add_lora it onto second_model when the user wants that look kept. Then run_workflow; the picture is saved beside it (prefix refined_character), the scene the same to the pixel. The Tile (node tile) holds the picture's lines, so at denoise 0.7 the character takes the second model's look and nothing in it turns into something else (a bag stays that bag); without the tile node (no animaTileRepair file here) set redraw's denoise to 0.45. At this strength colours may drift (hair, a scarf): say so. The preview shows what is redrawn (white, fading at the edge): part of the character missing, or the background in it, say so."
}
}
}
+15 -4
View File
@@ -2,6 +2,7 @@
"job": "refine",
"rank": 6,
"short": "other model",
"when": "the picture done, another model lightly redraws all of it; with its own checkpoint it may cross families; several in a row are fine.",
"title": {
"zh": "换模型精修",
"en": "Refine with another model"
@@ -45,8 +46,8 @@
"class_type": "VAEEncode",
"inputs": {
"pixels": [
"$decode",
0
"$save.images",
"*"
],
"vae": [
"second_model",
@@ -102,6 +103,16 @@
}
},
"after": "The refine's loader (second_model) starts as the same checkpoint: set_model it to the one the user wants (ask_user, search_models), add_lora its own LoRAs onto it, and write its prompts (second_positive, second_negative, copied from the first) in that model's style. Another family may want other steps, cfg and sampler on redraw. Then run_workflow; the refined one is saved beside it (prefix refined). Denoise 0.4: lower keeps more of the first, higher takes more of the second.",
"gives": {
"sampler": "redraw",
"save": "save"
},
"own_model": {
"nodes": [
"second_model"
],
"copies_loras": false
},
"notes": "The source keeps a separate loader and LoRA stack for refining. Its own CLIP and VAE, so a checkpoint of another family works. On an Anima model (UNET, text encoder and VAE in separate files) its variant loads only another UNET and reuses the canvas's text encoder and VAE: another Anima model.",
"models": [],
"tested": {
@@ -133,8 +144,8 @@
"class_type": "VAEEncode",
"inputs": {
"pixels": [
"$decode",
0
"$save.images",
"*"
],
"vae": [
"$decode.vae",
+3
View File
@@ -48,6 +48,9 @@
}
},
"after": "run_workflow saves it with the background cut away (transparent PNG, prefix nobg). On a canvas that makes pictures those are made too: to cut this one out alone, add the block on a blank tab (new_workflow).",
"gives": {
"save": "save"
},
"models": [],
"tested": {
"gpu": "RTX 4050 Laptop",
+1
View File
@@ -101,6 +101,7 @@
}
},
"after": "Write what is in the picture and the new style in the prompt (e.g. watercolor, flat colors, oil painting), or add_lora a style LoRA, then run_workflow. The size is the picture's. Its outlines are among the previews. To keep more of the old colours: lower the denoise to 0.8 (the style then changes less); the layout drifts: raise the ControlNet strength (0.7).",
"gives": {},
"notes": "Canny outlines through the union ControlNet (its canny/lineart mode); Tried: denoise 0.8 kept the old look (only lighter); 1.0 gave the style, the outlines held the layout.",
"models": [
"union_promax_sdxl"
+10
View File
@@ -3,6 +3,7 @@
"want": "midway",
"rank": 7,
"short": "switch mid-way",
"when": "one picture: early steps on one model settle the composition, later steps on another of its family finish the look; may enlarge at the switch.",
"title": {
"zh": "半途换模型接着画",
"en": "Switch models mid-way"
@@ -68,6 +69,15 @@
}
},
"after": "The canvas's sampler became two: the first runs steps 0 to 21 on the canvas's model, the second (second_model, a new loader that starts as the same model) from step 21 to the end, and the picture goes on to the decode as before. set_model second_model to the other model the user wants, of the same family (it reuses the prompts). A LoRA on the first model (a style) is not on the second: add_lora it onto second_model too when the user wants it kept. 21 is 60% of 35 steps: with other steps set the first sampler's end_at_step and the second's start_at_step both to about 60% (earlier: more of the second model, later: more of the first). er_sde split cleanly on one SDXL model but came out grainy on Anima: on another family's er_sde, dpmpp_2m_sde_gpu or euler is the safe choice for both. To enlarge at the switch too (the composition settled small, the later steps drawing the detail at the larger size, in place of an upscale pass after): add_node LatentUpscaleBy insert_after=first with values {\"upscale_method\": \"nearest-exact\", \"scale_by\": 1.5}, then one set_values: first {\"return_with_leftover_noise\": \"disable\"}, second {\"add_noise\": \"enable\"}. The first part then finishes its picture at the switch and the second noises the enlarged one anew: enlarged with its leftover noise, the picture breaks into coloured blocks. Keep the switch where it was: an earlier one redraws more (colours, clothes). second_model may stay the same model when only the enlargement is wanted. When the workflow already enlarges this picture after it (an upscale pass downstream), ask the user whether to keep that one: two enlargements are rarely wanted, and say the pictures differ in size when comparing. For a picture drawn from an empty latent; then run_workflow.",
"gives": {
"sampler": "second"
},
"own_model": {
"nodes": [
"second_model"
],
"copies_loras": false
},
"notes": "KSampler Advanced: the first part returns its leftover noise, the second adds none and finishes it, so the two parts make one run of the steps. er_sde split, the same model in both parts, one seed each: grainy on two Anima models (Kirazuri all over, Harem 1.5 blotchy), clean on Nova Anime XL (SDXL); dpmpp_2m_sde_gpu and euler split cleanly. Tested on the user's route: Harem 1.5 for steps 0-21, Realskin 1.3 for 21-35, its style LoRA on both, 35 steps dpmpp_2m_sde_gpu beta; on SDXL: Nova Anime XL for steps 0-18, MiaoMiao Harem v2.0 for 18-30, dpmpp_2m_sde_gpu beta. Enlarging 1.5× at the switch (Harem 1.5 both parts, 35 steps, 832×1216 → 1248×1824, about 106 s): with the leftover noise enlarged (nearest-exact, the reference author's wiring) the picture came out covered in coloured blocks at step 21 and blots at step 12, the second part adding noise or not; the first part finishing (return_with_leftover_noise disable) and the second adding noise came out clean, at step 21 keeping the pose and layout, at step 12 changing colours and clothes. GPT's check on the user's Harem 1.5 → Realskin 1.3 card, enlarged at step 21: the AI changed both settings as told, no blocks; pose, hair and colours held, face and hair finer, ornaments and tassels redrawn; about 53 s more than the card's base.",
"models": [],
"tested": {
+1
View File
@@ -43,6 +43,7 @@
}
},
"after": "run_workflow (what the canvas makes is made too): run_status's answer has texts, the picture's tags. Write the prompt from them with set_prompt, keeping the quality words the prompt has, then remove_nodes these two so later runs skip them.",
"gives": {},
"notes": "The source's model and thresholds (wd-eva02-large-tagger-v3, 0.35, 0.85).",
"models": [],
"tested": {
+5
View File
@@ -2,6 +2,7 @@
"job": "upscale",
"rank": 1,
"short": "1.25×",
"when": "an upscale model, then a light redraw; nothing holds the picture, so it stays light.",
"title": {
"zh": "放大",
"en": "Upscale"
@@ -17,6 +18,10 @@
"packs": {},
"upscaler": true,
"after": "run_workflow saves the picture as before and the larger one beside it (prefix upscaled). The redraw sampler has the first one's steps and cfg at denoise 0.35: lower keeps more of the picture, higher adds more (and above 0.5 may change it). For 2× or more use upscale_tiled.",
"gives": {
"sampler": "redraw",
"save": "save"
},
"notes": "1.25× and denoise 0.35: the source used 1.25 and 0.32; its author says more than 1.5× goes wrong, and 1.5× of 832×1216 is too much for a 6 GB card. ColorMatch mkl after the redraw is the source's, optional (KJNodes).",
"upscale_by": 1.25,
"nodes": {
+305
View File
@@ -0,0 +1,305 @@
{
"job": "upscale",
"want": "character",
"rank": 5,
"short": "blurred background: only the character redrawn",
"when": "a background blurred on purpose: all enlarged, only the character redrawn (a whole redraw draws the blur sharp); a sharp background: want='tile'.",
"title": {
"zh": "放大·只重画人物",
"en": "Upscale, only the character redrawn"
},
"about": {
"zh": "背景本来就虚化的图用:整张用放大模型拉大,背景不再动;自动抠出人物,只裁出那一块重画再缝回去。虚化原样保住,人物变清楚;Anima 上有 Tile 时照着原图的线条画。背景清楚的图请用「Tile 锁图放大」。",
"en": "For a picture whose background is blurred on purpose: an upscale model enlarges it all and the background stays as it is; the character is cut out and only its crop is redrawn and stitched back. The blur is kept, the character gets sharper; on Anima with the Tile it follows the picture's lines. A sharp background: Upscale held by Tile."
},
"source": {
"zh": "2026-11-10 的虚化背景放大对照:整张重画把远处的铁轨和树画清楚了,景深没了;整张拉大、只重画人物保住了虚化,只裁人物来重画最快",
"en": "A blurred-background upscale comparison of 2026-11-10: a whole redraw drew the far tracks and trees sharp and the depth was lost; enlarging it all and redrawing only the character kept the blur, a crop of the character the fastest"
},
"packs": {
"InspyrenetRembg": "comfyui-inspyrenet-rembg",
"InpaintCropImproved": "ComfyUI-Inpaint-CropAndStitch",
"InpaintStitchImproved": "ComfyUI-Inpaint-CropAndStitch"
},
"extras": {
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
},
"upscaler": true,
"upscale_by": 1.5,
"nodes": {
"own": {
"class_type": "CheckpointLoaderSimple",
"copy_of": "$sampler.model",
"_meta": {"title": "Upscale model"},
"inputs": {}
},
"upscaler": {
"class_type": "UpscaleModelLoader",
"inputs": {"model_name": "$upscaler"}
},
"enlarge": {
"class_type": "ImageUpscaleWithModel",
"inputs": {"upscale_model": ["upscaler", 0], "image": ["$save.images", "*"]}
},
"fit": {
"class_type": "ImageScaleBy",
"inputs": {"image": ["enlarge", 0], "upscale_method": "lanczos", "scale_by": "$fit"}
},
"cut": {
"class_type": "InspyrenetRembg",
"inputs": {"image": ["fit", 0], "torchscript_jit": "default"}
},
"solid": {
"class_type": "ThresholdMask",
"inputs": {"mask": ["cut", 1], "value": 0.5}
},
"edge": {
"class_type": "GrowMask",
"inputs": {"mask": ["solid", 0], "expand": 12, "tapered_corners": true}
},
"as_picture": {
"class_type": "MaskToImage",
"inputs": {"mask": ["edge", 0]}
},
"fade": {
"class_type": "ImageBlur",
"inputs": {"image": ["as_picture", 0], "blur_radius": 31, "sigma": 0.5}
},
"area_mask": {
"class_type": "ImageToMask",
"inputs": {"image": ["fade", 0], "channel": "red"}
},
"crop": {
"class_type": "InpaintCropImproved",
"inputs": {
"image": ["fit", 0],
"mask": ["area_mask", 0],
"downscale_algorithm": "lanczos",
"upscale_algorithm": "lanczos",
"preresize": false,
"preresize_mode": "ensure minimum resolution",
"preresize_min_width": 1024,
"preresize_min_height": 1024,
"preresize_max_width": 16384,
"preresize_max_height": 16384,
"mask_fill_holes": false,
"mask_expand_pixels": 0,
"mask_invert": false,
"mask_blend_pixels": 0,
"mask_hipass_filter": 0.0,
"extend_for_outpainting": false,
"extend_up_factor": 1.0,
"extend_down_factor": 1.0,
"extend_left_factor": 1.0,
"extend_right_factor": 1.0,
"context_from_mask_extend_factor": 1.2,
"output_resize_to_target_size": false,
"output_target_width": 1024,
"output_target_height": 1024,
"output_padding": "32",
"device_mode": "cpu (compatible)"
}
},
"soft": {
"class_type": "DifferentialDiffusion",
"inputs": {"model": ["own", 0]}
},
"encode": {
"class_type": "VAEEncode",
"inputs": {"pixels": ["crop", "cropped_image"], "vae": ["$decode.vae", "*"]}
},
"area": {
"class_type": "SetLatentNoiseMask",
"inputs": {"samples": ["encode", 0], "mask": ["crop", "cropped_mask"]}
},
"redraw": {
"class_type": "KSampler",
"like": "$sampler",
"inputs": {
"model": ["soft", 0],
"positive": ["$sampler.positive", "*"],
"negative": ["$sampler.negative", "*"],
"latent_image": ["area", 0],
"denoise": 0.45
}
},
"decode": {
"class_type": "VAEDecode",
"inputs": {"samples": ["redraw", 0], "vae": ["$decode.vae", "*"]}
},
"stitch": {
"class_type": "InpaintStitchImproved",
"inputs": {"stitcher": ["crop", "stitcher"], "inpainted_image": ["decode", 0]}
},
"save": {
"class_type": "SaveImage",
"inputs": {"images": ["stitch", 0], "filename_prefix": "upscaled_character"}
},
"look": {
"class_type": "PreviewImage",
"inputs": {"images": ["fade", 0]}
}
},
"after": "run_workflow saves the picture as before and the 1.5x one beside it (prefix upscaled_character): the whole picture enlarged by an upscale model, the background left as it is, only the character (cut out, cropped with a margin) redrawn at denoise 0.45 with its own copy of the canvas's model and LoRAs (the loader titled Upscale model) and stitched back. Only for a background blurred on purpose: on a sharp one the character comes out sharper than the enlarged background; there use want=\"tile\". A second model for the character: set_model that loader to one of the same family. The preview shows what is redrawn (white): part of the character missing, or the background in it, say so.",
"gives": {
"sampler": "redraw",
"save": "save"
},
"own_model": {
"nodes": ["own_1", "own"],
"copies_loras": true
},
"notes": "In the 2026-11-10 comparison (an Anima picture with a blurred background, 1440x1024 to 2160x1536, RealSkin) a whole Tile redraw at 0.85 drew the far tracks and trees sharp, hazed the picture and lost the depth; enlarging it all and redrawing only the character kept the background to the pixel and sharpened the face. The crop (the character's mask, 20% more around it, not resized) took 153 s where redrawing over the whole enlarged picture took 270 s. The mask is the refine_character way's: cut out, made solid, grown 12 px, blurred about 15 px for a fading edge with Differential Diffusion. On Anima an optional animaTileRepair LLLite holds the lines at denoise 0.7; without the Tile file the node goes and 0.45 is the strength.",
"models": ["2x_animesharp_v4", "4x_ultrasharp", "anima_tile_repair"],
"tested": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"date": "2026-11-10",
"runs": [
{
"model": "Anima (Harem 1.5, its own copy redrawing the character, the Tile, denoise 0.7, 2x-AnimeSharpV4)",
"size": "832×1216 → 1.5×",
"seconds": 223
}
]
},
"variants": {
"anima": {
"optional_lllite": {
"tile": "tile"
},
"nodes": {
"own": {
"class_type": "UNETLoader",
"copy_of": "$sampler.model",
"_meta": {"title": "Upscale model"},
"inputs": {}
},
"upscaler": {
"class_type": "UpscaleModelLoader",
"inputs": {"model_name": "$upscaler"}
},
"enlarge": {
"class_type": "ImageUpscaleWithModel",
"inputs": {"upscale_model": ["upscaler", 0], "image": ["$save.images", "*"]}
},
"fit": {
"class_type": "ImageScaleBy",
"inputs": {"image": ["enlarge", 0], "upscale_method": "lanczos", "scale_by": "$fit"}
},
"cut": {
"class_type": "InspyrenetRembg",
"inputs": {"image": ["fit", 0], "torchscript_jit": "default"}
},
"solid": {
"class_type": "ThresholdMask",
"inputs": {"mask": ["cut", 1], "value": 0.5}
},
"edge": {
"class_type": "GrowMask",
"inputs": {"mask": ["solid", 0], "expand": 12, "tapered_corners": true}
},
"as_picture": {
"class_type": "MaskToImage",
"inputs": {"mask": ["edge", 0]}
},
"fade": {
"class_type": "ImageBlur",
"inputs": {"image": ["as_picture", 0], "blur_radius": 31, "sigma": 0.5}
},
"area_mask": {
"class_type": "ImageToMask",
"inputs": {"image": ["fade", 0], "channel": "red"}
},
"crop": {
"class_type": "InpaintCropImproved",
"inputs": {
"image": ["fit", 0],
"mask": ["area_mask", 0],
"downscale_algorithm": "lanczos",
"upscale_algorithm": "lanczos",
"preresize": false,
"preresize_mode": "ensure minimum resolution",
"preresize_min_width": 1024,
"preresize_min_height": 1024,
"preresize_max_width": 16384,
"preresize_max_height": 16384,
"mask_fill_holes": false,
"mask_expand_pixels": 0,
"mask_invert": false,
"mask_blend_pixels": 0,
"mask_hipass_filter": 0.0,
"extend_for_outpainting": false,
"extend_up_factor": 1.0,
"extend_down_factor": 1.0,
"extend_left_factor": 1.0,
"extend_right_factor": 1.0,
"context_from_mask_extend_factor": 1.2,
"output_resize_to_target_size": false,
"output_target_width": 1024,
"output_target_height": 1024,
"output_padding": "32",
"device_mode": "cpu (compatible)"
}
},
"tile": {
"class_type": "AnimaLLLiteApply_sdscripts",
"optional": true,
"inputs": {
"model": ["own", 0],
"lllite_name": "$tile",
"image": ["crop", "cropped_image"],
"strength": 0.7,
"start_percent": 0.0,
"end_percent": 0.8,
"preserve_wrapper": true
}
},
"soft": {
"class_type": "DifferentialDiffusion",
"inputs": {"model": ["tile|own", 0]}
},
"encode": {
"class_type": "VAEEncodeTiled",
"inputs": {"pixels": ["crop", "cropped_image"], "vae": ["$decode.vae", "*"], "tile_size": 512, "overlap": 64, "temporal_size": 64, "temporal_overlap": 8}
},
"area": {
"class_type": "SetLatentNoiseMask",
"inputs": {"samples": ["encode", 0], "mask": ["crop", "cropped_mask"]}
},
"redraw": {
"class_type": "KSampler",
"like": "$sampler",
"inputs": {
"model": ["soft", 0],
"positive": ["$sampler.positive", "*"],
"negative": ["$sampler.negative", "*"],
"latent_image": ["area", 0],
"steps": 30,
"cfg": 4.4,
"sampler_name": "dpmpp_2m_sde_gpu",
"scheduler": "sgm_uniform",
"denoise": 0.7
}
},
"decode": {
"class_type": "VAEDecodeTiled",
"inputs": {"samples": ["redraw", 0], "vae": ["$decode.vae", "*"], "tile_size": 512, "overlap": 64, "temporal_size": 64, "temporal_overlap": 8}
},
"stitch": {
"class_type": "InpaintStitchImproved",
"inputs": {"stitcher": ["crop", "stitcher"], "inpainted_image": ["decode", 0]}
},
"save": {
"class_type": "SaveImage",
"inputs": {"images": ["stitch", 0], "filename_prefix": "upscaled_character"}
},
"look": {
"class_type": "PreviewImage",
"inputs": {"images": ["fade", 0]}
}
},
"after": "On this Anima model run_workflow saves the picture as before and the 1.5x one beside it (prefix upscaled_character): the whole picture enlarged by an upscale model, the background left as it is, only the character (cut out, cropped with a margin) redrawn with its own copy of the canvas's model and LoRAs (the loader titled Upscale model) and stitched back. The Tile (node tile) holds the character's lines, so it redraws at denoise 0.7 (30 steps, dpmpp_2m_sde_gpu, the tested route); without the tile node (no animaTileRepair file here) set redraw's denoise to 0.45. Only for a background blurred on purpose: on a sharp one the character comes out sharper than the enlarged background; there use want=\"tile\". A second model for the character: set_model that loader to another Anima model; the LoRAs stay. The preview shows what is redrawn (white): part of the character missing, or the background in it, say so."
}
}
}
+13 -1
View File
@@ -1,8 +1,9 @@
{
"job": "upscale",
"want": "detail",
"want": "tile",
"rank": 2,
"short": "held by Tile, own model",
"when": "enlarge with a lock: Tile holds the picture under a strong redraw, for much more detail; its own model copy can be a second model of the same family.",
"title": {
"zh": "Tile 锁图放大",
"en": "Upscale held by Tile"
@@ -311,6 +312,17 @@
}
},
"after": "run_workflow saves the picture as before and the 1.25x one beside it (prefix upscaled): a tile ControlNet holds it (strength 0.6, to 80% of the steps) while it is redrawn at denoise 0.5 with its own copy of the canvas's model and LoRAs (the loader titled Upscale model). For a second model (steadier, another look), set_model that loader to one of the same family (it reuses the first model's prompts); the LoRAs stay. Lower the denoise if it changes too much, raise the strength to hold it closer.",
"gives": {
"sampler": "redraw",
"save": "save"
},
"own_model": {
"nodes": [
"own_1",
"own"
],
"copies_loras": true
},
"notes": "The source's tile values; denoise 0.5 because the tile holds the picture. The redraw's own model line replaced the separate second-model way (2026-11-04): same model by default, one swap for another. On Anima the user's tested route: Harem 1.5 base, Realskin 1.3 for the Tile upscale, its style LoRA on both, 832x1216 to 1016x1480, 116 s for the upscale on 6 GB.",
"models": [
"union_promax_sdxl",
+4
View File
@@ -3,6 +3,7 @@
"want": "2x",
"rank": 4,
"short": "2×, in tiles",
"when": "2x redrawn tile by tile, for sizes that do not fit memory in one piece.",
"title": {
"zh": "分块放大",
"en": "Tiled upscale"
@@ -21,6 +22,9 @@
"instead": "upscale",
"upscaler": true,
"after": "run_workflow saves the picture as before and the 2× one beside it (prefix upscaled); each tile is redrawn with the sampler's steps and cfg at denoise 0.3 (higher adds more detail, and may add seams).",
"gives": {
"save": "save"
},
"extras": {
"ColorMatch": "ComfyUI-KJNodes"
},
+119
View File
@@ -0,0 +1,119 @@
"""The model details' "Translate" (web/modules/ui_detail.js): its author's text from Civitai in the window's
language, paragraph by paragraph through the providers prompts use (translation_routes.translate_chain: DeepL
with a saved key, Google, MyMemory), a few at a time. A long paragraph goes in pieces cut at sentences; a piece
none of them takes is cut again into ones MyMemory takes. What came back is kept in user/anomalous/translations,
a file per text and language named by their hash, so the same text opens translated at once and offline, and
nothing is written beside the models. POST /anomalous/translate_description {paragraphs: [text], target}
-> {paragraphs, engine, kept} or {error}."""
import asyncio
import hashlib
import json
import os
import re
import time
from concurrent.futures import ThreadPoolExecutor
from aiohttp import web
from . import ai_tasks, translation_routes
from .path_utils import atomic_write_json
MAX_PARAGRAPHS = 400
MAX_CHARS = 30000
PIECE = 1500 # characters a free provider takes in one go
SMALL = 450 # what MyMemory takes
WORKERS = 4
TARGETS = ("zh-CN", "en")
SENTENCE = re.compile(r"(?<=[.!?。!?])\s+")
def _folder():
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "translations")
def _key(paragraphs, target):
return hashlib.sha256(("\n".join([target, *paragraphs])).encode("utf-8")).hexdigest()[:32]
def _kept(key):
try:
with open(os.path.join(_folder(), f"{key}.json"), encoding="utf-8") as source:
data = json.load(source)
return data if isinstance(data, dict) and isinstance(data.get("paragraphs"), list) else None
except (OSError, ValueError):
return None
def pieces(text, size):
"""`text` cut at sentence ends (or, past that, anywhere) into pieces of at most `size` characters."""
out, now = [], ""
for sentence in SENTENCE.split(text):
while len(sentence) > size:
out += [now] if now else []
out.append(sentence[:size])
sentence, now = sentence[size:], ""
if now and len(now) + 1 + len(sentence) > size:
out.append(now)
now = sentence
else:
now = f"{now} {sentence}".strip()
return out + ([now] if now else [])
def _join(parts, target):
return ("" if target == "zh-CN" else " ").join(part.strip() for part in parts)
def _piece(text, target, engines):
"""One piece in `target`, cut smaller for MyMemory when the providers that take long ones did not answer."""
try:
translated, engine, _why = translation_routes.translate_chain(text, target)
except Exception: # noqa: BLE001 - a piece too long for MyMemory: in small ones
if len(text) <= SMALL:
raise
return _join([_piece(small, target, engines) for small in pieces(text, SMALL)], target)
engines.add(engine)
return translated
def _paragraph(text, target, engines):
return _join([_piece(piece, target, engines) for piece in pieces(text, PIECE)], target)
def translate(paragraphs, target):
"""The paragraphs in `target`, kept for next time; raises when a provider answered none."""
if (not isinstance(paragraphs, list) or not 0 < len(paragraphs) <= MAX_PARAGRAPHS
or any(not isinstance(item, str) for item in paragraphs)):
raise ValueError(f"paragraphs is a list of 1 to {MAX_PARAGRAPHS} texts")
if target not in TARGETS:
raise ValueError(f"target is one of {', '.join(TARGETS)}")
paragraphs = [item.strip() for item in paragraphs]
if sum(len(item) for item in paragraphs) > MAX_CHARS:
raise ValueError(f"The text is over {MAX_CHARS} characters")
key = _key(paragraphs, target)
kept = _kept(key)
if kept:
return {**kept, "kept": True}
engines = set()
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
done = list(pool.map(lambda item: _paragraph(item, target, engines) if item else "", paragraphs))
answer = {"paragraphs": done, "engine": ", ".join(sorted(engines)), "target": target, "at": time.time()}
os.makedirs(_folder(), exist_ok=True)
atomic_write_json(os.path.join(_folder(), f"{key}.json"), answer)
return {**answer, "kept": False}
async def api_translate_description(request):
try:
body = await request.json()
except ValueError:
body = None
if not isinstance(body, dict):
return web.json_response({"error": "Expected {paragraphs, target}"}, status=400)
try:
return web.json_response(await asyncio.to_thread(translate, body.get("paragraphs"), body.get("target")))
except ValueError as error:
return web.json_response({"error": str(error)}, status=400)
except Exception as error: # noqa: BLE001 - every provider failed: the page says why, the original stays
return web.json_response({"error": str(error)[:300] or type(error).__name__}, status=502)
+29 -12
View File
@@ -3,7 +3,10 @@ its own: plans (title), each in a version per model family (version) with a note
ComfyUI's own nodes; a version may need a pack (packs) or a model, and says so. The Workflows page shows
them as one card (web/modules/ui_recipe_examples.js); list_workflows gives those that can run here to an
AI when no saved workflow fits, which opens and runs them like saved ones (file name "example:<id>").
A file names no model and no picture: read() fills in ones that are here. Read only."""
A file names no model and no picture: read() fills in ones that are here, for a version with a Tile a model
the Tile fits (lllite_fit.py). What holds for a whole model family (Anima: several pictures are several
runs) is written once: for an AI in model_profiles.json, added to each of the family's notes; for the
user under the plan's heading (the page's words for `family`). Read only."""
import asyncio
import json
@@ -12,14 +15,15 @@ import os
import folder_paths
from aiohttp import web
from . import workflow_route
from . import lllite_fit, model_guide, workflow_route
FOLDER = os.path.join(os.path.dirname(__file__), "examples")
PREFIX = "example:"
ORDER = ("text_to_picture", "two_pass", "two_pass_anima", "two_pass_anima_tile", "picture_to_picture", "repaint")
ORDER = ("text_to_picture", "two_pass", "two_pass_anima", "two_pass_anima_tile", "two_pass_anima_character", "picture_to_picture", "repaint")
LOADERS = ("CheckpointLoaderSimple", "UNETLoader", "CLIPLoader", "VAELoader", "AnimaLLLiteApply_sdscripts")
FAMILIES = {"sd": ("SDXL", "SD 1.x"), "anima": ("anima",)} # what the examples' settings suit
FOR_MODELS = {"sd": "SDXL, SD 1.5", "anima": "Anima"}
PROFILES = {"anima": "Anima"} # the model_profiles.json family whose notes hold for every example of a family
LOOK_AT = 40 # files looked at for a model of a family
MAX_WITH_SETTINGS = 3 # saved workflows listed in full for an AI: an AI app files a long answer away, unread
SHORT_NOTES = 120 # characters of a card's notes in a longer list
@@ -38,18 +42,20 @@ EXAMPLES = _load()
def _here():
"""The loaders' drop-downs, in the form reference_blocks reads /object_info; a node type not here is left out."""
"""The loaders' drop-downs, in the form reference_blocks reads /object_info, and the nodes of the packs the
examples need (whether they are here); a node type not here is left out."""
import nodes
return {kind: {"input": nodes.NODE_CLASS_MAPPINGS[kind].INPUT_TYPES()} for kind in LOADERS if kind in nodes.NODE_CLASS_MAPPINGS}
kinds = set(LOADERS) | {kind for example in EXAMPLES.values() for kind in example.get("packs") or {}}
return {kind: {"input": nodes.NODE_CLASS_MAPPINGS[kind].INPUT_TYPES()} for kind in kinds if kind in nodes.NODE_CLASS_MAPPINGS}
def _model(info, kind, field, family):
"""The first file loader `kind` offers of `family` (those named for it looked at first), or ""."""
def _model(info, kind, field, family, fits=lambda name: True):
"""The first file loader `kind` offers of `family` (those named for it looked at first) that `fits`, or ""."""
from . import model_advice, reference_blocks
names = sorted(reference_blocks._choices(info, kind, field), key=lambda name: family not in name.lower())
return next((name for name in names[:LOOK_AT] if model_advice.family(model_advice._main_base(name)) in FAMILIES[family]), "")
return next((name for name in names[:LOOK_AT] if model_advice.family(model_advice._main_base(name)) in FAMILIES[family] and fits(name)), "")
def _picture():
@@ -79,7 +85,7 @@ def _fills(info):
def _lacks(example, info, fills):
"""What `example` needs and this ComfyUI lacks: [{pack}] or [{model: the loader's type}]."""
packs = [{"pack": pack} for kind, pack in (example.get("packs") or {}).items() if kind not in info]
packs = [{"pack": pack} for pack in dict.fromkeys(pack for kind, pack in (example.get("packs") or {}).items() if kind not in info)]
kinds = {node["type"] for node in example["workflow"]["nodes"]}
return packs + [{"model": kind} for kind in LOADERS if kind in kinds and kind in info and not fills.get(kind)]
@@ -89,7 +95,12 @@ def read(example_id):
example = EXAMPLES.get(example_id)
if not example:
return None
fills = _fills(_here())
info = _here()
fills = dict(_fills(info))
tile = fills.get("AnimaLLLiteApply_sdscripts")
if tile and any(node["type"] == "AnimaLLLiteApply_sdscripts" for node in example["workflow"]["nodes"]):
# a Tile made for the 2B models stops the run on a larger one: open on one it fits, when there is one
fills["UNETLoader"] = _model(info, "UNETLoader", "unet_name", "anima", lambda name: not lllite_fit.problem(name, tile)) or fills["UNETLoader"]
workflow = json.loads(json.dumps(example["workflow"]))
for node in workflow["nodes"]:
if node["type"] in fills:
@@ -102,7 +113,13 @@ def listing():
info = _here()
fills = _fills(info)
return [{"id": key, "title": item["title"], "version": item["version"], "about": item["about"], "recommended": bool(item.get("recommended")),
"lacks": _lacks(item, info, fills)} for key, item in EXAMPLES.items()]
"family": item["family"], "lacks": _lacks(item, info, fills)} for key, item in EXAMPLES.items()]
def _notes(item):
"""An example's note for an AI, with what model_profiles.json says of its family."""
family = model_guide.profile(PROFILES.get(item["family"])) or {}
return " ".join([item["ai"], *family.get("notes", [])])
def for_ai():
@@ -110,7 +127,7 @@ def for_ai():
info = _here()
fills = _fills(info)
return [{"name": item["name"], "title": item["title"]["zh"], "example": True, "for_models": FOR_MODELS[item["family"]],
"route": workflow_route.line(item["workflow"]), "notes": item["ai"]}
"route": workflow_route.line(item["workflow"]), "notes": _notes(item)}
for item in EXAMPLES.values() if not _lacks(item, info, fills)]
+3 -3
View File
@@ -10,10 +10,10 @@
},
"family": "anima",
"about": {
"zh": "只用官方节点,什么都不用装:放大 1.22 倍,轻轻重绘(0.35)。没有 Tile 锁着,重绘再狠画面就跑了。要多张图就多跑几次,别调批次:Anima 的 VAE 会把一批图当成视频帧,第二段只剩一张。",
"en": "ComfyUI's own nodes, nothing to install: enlarged 1.22x and redrawn gently (0.35). Nothing holds the picture, so a stronger redraw moves it. For several pictures run it several times, not a bigger batch: Anima's VAE takes a batch for video frames, and the second pass is left with one."
"zh": "只用官方节点,什么都不用装:放大 1.22 倍,轻轻重绘(0.35)。没有 Tile 锁着,重绘再狠画面就跑了。",
"en": "ComfyUI's own nodes, nothing to install: enlarged 1.22x and redrawn gently (0.35). Nothing holds the picture, so a stronger redraw moves it."
},
"ai": "A pass on Anima, then enlarged 1.22x and redrawn at denoise 0.35, nothing holding the picture: at 0.5 the shapes begin to move. open_workflow, set_model on its loader, then run_workflow with the prompt. For several pictures run it several times: Anima's VAE takes a batch for video frames, so a batch above 1 ends as one picture at the second pass.",
"ai": "A pass on Anima, then enlarged 1.22x and redrawn at denoise 0.35, nothing holding the picture: at 0.5 the shapes begin to move. open_workflow, set_model on its loader, then run_workflow with the prompt.",
"workflow": {
"id": "00000000-0000-0000-0000-000000000000",
"revision": 0,
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -14,10 +14,10 @@
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
},
"about": {
"zh": "重绘更狠、细节更多,画面也不容易跑:放大 1.22 倍,Tile 0.7 锁住,重绘 0.85。要多张图就多跑几次,别调批次:Anima 的 VAE 会把一批图当成视频帧,第二段只剩一张。",
"en": "A stronger redraw with more detail that keeps the picture: enlarged 1.22x, held by Tile at 0.7, redrawn at 0.85. For several pictures run it several times, not a bigger batch: Anima's VAE takes a batch for video frames, and the second pass is left with one."
"zh": "重绘更狠、细节更多,画面也不容易跑:放大 1.22 倍,Tile 0.7 锁住,重绘 0.85。Tile 只适配 2B 的 Anima 模型,打开时会替你选一个;换成 2.9B 这类更大的会报错。",
"en": "A stronger redraw with more detail that keeps the picture: enlarged 1.22x, held by Tile at 0.7, redrawn at 0.85. The Tile fits the 2B Anima models only; it opens on one of yours, and a larger one (such as 2.9B) stops with an error."
},
"ai": "A pass on Anima, then enlarged 1.22x and redrawn at denoise 0.85, held by Anima's Tile LLLite at 0.7. open_workflow, set_model on its loader, then run_workflow with the prompt. For several pictures run it several times: Anima's VAE takes a batch for video frames, so a batch above 1 ends as one picture at the second pass.",
"ai": "A pass on Anima, then enlarged 1.22x and redrawn at denoise 0.85, held by Anima's Tile LLLite at 0.7. open_workflow, set_model on its loader, then run_workflow with the prompt. The Tile fits the 2B Anima models only: Anomalous refuses a run where it does not fit and names the loader to change.",
"workflow": {
"id": "00000000-0000-0000-0000-000000000000",
"revision": 0,
+413
View File
@@ -0,0 +1,413 @@
"""任务单 (the job sheet): many runs of one workflow one after another. Each row is a prompt, settings by
name, or nothing (the workflow as it is), with how many runs it gets; a pause between pictures if wanted. The
ComfyUI page runs it (web/modules/job_sheet_run.js): it keeps the workflow as it was at the start, queues each
run from a copy off the canvas, and the next one only when the one before has ended and the pause is over (one
ahead with no pause). This file keeps the one sheet of this ComfyUI and where it stands: its rows, the runs'
prompt ids, the pictures made, the page running it and what was asked of that page (pause, stop, go on). A
page that said nothing for STALE seconds is gone: the sheet waits for a page to take it over, the same tab
after a reload by itself. Stop takes the sheet's waiting runs out of ComfyUI's queue here and interrupts its
running one. A sheet an AI gave (run_workflow(sheet=…), mcp_actions.py) is that call's step in the task: its
progress, pictures and end are written on the step (ai_tasks.py), so run_status and the workbench follow it
like a run. Kept in user/anomalous/job_sheet.json and its workflow beside it; every change goes to the pages
as "anomalous.job_sheet"."""
import json
import os
import random
import re
import threading
import time
import uuid
from aiohttp import web
from . import ai_tasks
from .path_utils import atomic_write_json
MAX_ROWS = 200
MAX_COUNT = 50 # runs of one row
MAX_TOTAL = 1000 # runs of a sheet
MAX_PAUSE = 3600 # seconds between pictures
MAX_TEXT = 20000
MAX_WORKFLOW = 20 * 1024 * 1024
STALE = 45 # seconds without word from the page running a sheet: that page is gone
LAST = 8 # newest pictures the card shows
SEED_TOP = 1125899906842624 # ComfyUI's widest seed; web/modules/snapshot_plan.js
ENDED = ("done", "stopped")
WANTS = ("pause", "stop", "go")
ROW_KEYS = ("prompt", "negative", "values", "count")
PAGE = re.compile(r"^[0-9A-Za-z-]{8,64}$")
SHEET, WORKFLOW = "job_sheet.json", "job_sheet_workflow.json"
_lock = threading.Lock()
class SheetError(ValueError):
"""What is wrong with a sheet or a word about it, said to whoever gave it."""
def _path(name):
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), name)
def _load():
try:
with open(_path(SHEET), encoding="utf-8") as source:
sheet = json.load(source)
except (OSError, ValueError):
return None
return sheet if isinstance(sheet, dict) and sheet.get("id") else None
def _stale(sheet, now=None):
return (time.time() if now is None else now) - sheet.get("heard", 0) > STALE
def public(sheet):
"""What the pages and the AI see of `sheet`: all but the runs' ids, with the run going (`now`, its place in
the sheet) and whether its page is gone."""
if not sheet:
return None
now = dict(sheet["ids"]).get(sheet["pending"][0]) if sheet["pending"] else None
return {**{key: value for key, value in sheet.items() if key != "ids"}, "now": now, "stale": _stale(sheet)}
def _tell(sheet):
try:
from server import PromptServer
PromptServer.instance.send_sync("anomalous.job_sheet", {"sheet": public(sheet)})
except Exception: # noqa: BLE001 - tests and early start have no server; the page reads it on open
pass
def _save(sheet):
atomic_write_json(_path(SHEET), sheet)
_tell(sheet)
def _count(value, what):
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= MAX_COUNT:
raise SheetError(f"{what} is a whole number from 1 to {MAX_COUNT}.")
return value
def pause_of(value):
if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value <= MAX_PAUSE:
raise SheetError(f"pause is seconds between pictures, 0 to {MAX_PAUSE}.")
return round(float(value), 1)
def rows_of(rows, count=1):
"""The rows checked: [{prompt?, negative?, values?, count}]. A row given as text is its prompt; a row with
none of them runs the workflow as it is. `count`: a row's runs when it does not say."""
from .mcp_fill import node_values
from .mcp_tools import ToolError
if not isinstance(rows, list) or not 1 <= len(rows) <= MAX_ROWS:
raise SheetError(f"rows is a list of 1 to {MAX_ROWS} rows.")
count = _count(count, "count")
out = []
for at, row in enumerate(rows, 1):
row = {"prompt": row} if isinstance(row, str) else row
if not isinstance(row, dict):
raise SheetError(f"Row {at} is a prompt, or {{prompt, negative, values, count}}.")
unknown = sorted(set(row) - set(ROW_KEYS))
if unknown:
raise SheetError(f"Row {at}: {', '.join(unknown)[:80]} is no part of a row ({', '.join(ROW_KEYS)}).")
item = {}
for key in ("prompt", "negative"):
text = row.get(key)
if text is None:
continue
if not isinstance(text, str) or not text.strip() or len(text) > MAX_TEXT:
raise SheetError(f"Row {at}: {key} is text of up to {MAX_TEXT} characters.")
item[key] = text
if row.get("values") is not None:
try:
item["values"] = node_values(row["values"])
except ToolError as error:
raise SheetError(f"Row {at}: {error}") from error
item["count"] = _count(row.get("count", count), f"Row {at}'s count")
out.append(item)
total = sum(item["count"] for item in out)
if total > MAX_TOTAL:
raise SheetError(f"{total} runs in all; a sheet takes up to {MAX_TOTAL}.")
return out
def current():
return public(_load())
def workflow():
try:
with open(_path(WORKFLOW), encoding="utf-8") as source:
return json.load(source)
except (OSError, ValueError):
return None
def _end_old(sheet):
"""An unfinished sheet a new one replaces: it ends stopped, on its step too."""
if sheet and sheet["state"] not in ENDED:
_drop(sheet["pending"])
sheet.update(state="stopped", want=None, ended=time.time(), note=sheet.get("note") or "replaced")
_sync_step(sheet)
def start(body):
"""A new sheet from the page that runs it: {rows, count?, pause?, same_seeds?, workflow, title?, page, task?}."""
page = body.get("page")
if not isinstance(page, str) or not PAGE.match(page):
raise SheetError("page is the id of the page that runs the sheet.")
rows = rows_of(body.get("rows"), body.get("count", 1))
pause = pause_of(body.get("pause", 0))
flow = body.get("workflow")
if not isinstance(flow, dict) or not flow.get("nodes"):
raise SheetError("The sheet needs the workflow it runs, with its nodes.")
if len(json.dumps(flow)) > MAX_WORKFLOW:
raise SheetError("This workflow is too big for a sheet.")
task = body.get("task") if isinstance(body.get("task"), str) and ai_tasks.get_task(body["task"]) else None
now = time.time()
with _lock:
old = _load()
if old and old["state"] not in ENDED and not _stale(old, now):
raise SheetError(f"A sheet is going ({old['done']} of {old['total']} runs done): stop it first, or wait.")
_end_old(old)
sheet = {"id": uuid.uuid4().hex[:12], "by": "ai" if task else "user", "task": task, "step": None,
"title": str(body.get("title") or "")[:120], "rows": rows, "pause": pause,
"same_seeds": bool(body.get("same_seeds")), "base": random.randrange(SEED_TOP - MAX_COUNT),
"total": sum(row["count"] for row in rows), "state": "running", "want": None, "at": 0, "done": 0,
"failed": 0, "pictures": 0, "last": [], "ids": [], "pending": [], "page": page, "heard": now,
"started": now, "ended": None, "note": ""}
atomic_write_json(_path(WORKFLOW), flow)
_save(sheet)
return public(sheet)
def _pictures(prompt_id):
"""The pictures run `prompt_id` saved, from ComfyUI's history."""
from .ai_task_runs import _queue, outputs_of
try:
record = _queue().get_history(prompt_id=prompt_id).get(prompt_id)
except Exception: # noqa: BLE001 - only the pictures are missed; the page said the run ended
return []
return outputs_of(record) if record else []
def _drop(prompt_ids):
"""`prompt_ids` out of ComfyUI's queue, the one running interrupted (ai_run_control.py: only while it is ours)."""
if not prompt_ids:
return
from .ai_run_control import _interrupt
from .ai_task_runs import _queue
try:
queue = _queue()
for prompt_id in prompt_ids:
queue.delete_queue_item(lambda item, pid=prompt_id: item[1] == pid)
_interrupt(queue, prompt_id)
except Exception: # noqa: BLE001 - the queue may be gone with ComfyUI; nothing is left to stop then
pass
def report(body):
"""Word from the page running the sheet: {id, page, at, queued: [[prompt id, run]], finished: [[prompt id,
outcome]], state?, note?}; outcome "ok", "error", "interrupted" or "gone" (taken out of the queue). Answers the sheet,
whose `want` says what was asked of the page."""
pictures, dropped = [], []
with _lock:
sheet = _load()
if not sheet or sheet["id"] != body.get("id"):
raise SheetError("This sheet is gone.")
if sheet["page"] != body.get("page"):
raise SheetError("Another page runs this sheet now.")
ended = sheet["state"] in ENDED
known = {pid for pid, _ in sheet["ids"]}
for item in body.get("queued") or []:
if (isinstance(item, list) and len(item) == 2 and isinstance(item[0], str) and 0 < len(item[0]) <= 128
and isinstance(item[1], int) and item[0] not in known):
if not ended:
sheet["ids"].append(item)
sheet["pending"].append(item[0])
known.add(item[0])
if sheet["want"] == "stop" or sheet["state"] == "stopped":
dropped.append(item[0]) # queued as stop was asked: it goes too
if ended:
_drop(dropped) # queuePrompt can return after the page's own Stop has already ended the sheet
return public(sheet)
sheet["heard"] = time.time()
for item in body.get("finished") or []:
if not (isinstance(item, list) and len(item) == 2 and item[0] in sheet["pending"]
and item[1] in ("ok", "error", "interrupted", "gone")):
continue
sheet["pending"].remove(item[0])
sheet["done"] += 1
if item[1] != "ok":
sheet["failed"] += 1
made = _pictures(item[0]) if item[1] == "ok" else []
pictures += made
sheet["pictures"] += len(made)
if pictures:
sheet["last"] = (sheet["last"] + pictures)[-LAST:]
at = body.get("at")
if isinstance(at, int) and 0 <= at <= sheet["total"]:
sheet["at"] = at
state = "stopped" if sheet["want"] == "stop" else body.get("state")
if state in ("running", "paused", "stopped", "done"):
if state != sheet["state"]:
sheet["want"] = None
sheet["state"] = state
if state in ENDED:
sheet["ended"] = time.time()
if isinstance(body.get("note"), str):
sheet["note"] = body["note"][:500]
_save(sheet)
_sync_step(sheet, pictures)
_drop(dropped)
return public(sheet)
def ask(sheet_id, want, page=None):
"""The user or the AI asks the sheet to pause, stop or go on. A live page running it hears it and answers by
its report; with that page gone it is done here, and "go" makes the asking page the one that runs it.
Answers {sheet, run}: run means the asking page runs the sheet from now on."""
if want not in WANTS:
raise SheetError(f"want is one of {', '.join(WANTS)}.")
dropped = []
with _lock:
sheet = _load()
if not sheet or sheet["id"] != sheet_id:
raise SheetError("This sheet is gone.")
if sheet["state"] in ENDED:
if want == "go":
raise SheetError("This sheet has ended; start it again.")
return {"sheet": public(sheet), "run": False}
alive = not _stale(sheet)
if want == "stop":
dropped = list(sheet["pending"]) # here, whoever runs it: the GPU is free at once
if want == "go" and (not alive or sheet["page"] == page):
if not isinstance(page, str) or not PAGE.match(page):
raise SheetError("Go on from a ComfyUI page: the sheet runs there.")
sheet.update(state="running", want=None, page=page, heard=time.time(), note="")
run = True
elif alive and sheet["page"] != page:
sheet["want"] = want
run = False
else: # no live page runs it, or the asking page does: it is done here
sheet["want"] = None
sheet["state"] = "stopped" if want == "stop" else "paused" if want == "pause" else sheet["state"]
if want == "stop":
sheet["ended"] = time.time()
run = False
_save(sheet)
_sync_step(sheet)
_drop(dropped)
return {"sheet": public(sheet), "run": run}
def clear(sheet_id):
"""Takes away a sheet that is not going (the card's close)."""
with _lock:
sheet = _load()
if not sheet or sheet["id"] != sheet_id:
return
if sheet["state"] not in ENDED and not _stale(sheet):
raise SheetError("This sheet is going: stop it first.")
_end_old(sheet)
for name in (SHEET, WORKFLOW):
try:
os.remove(_path(name))
except OSError:
pass
_tell(None)
def attach(sheet_id, step_id):
"""The AI's call that started sheet `sheet_id` became run step `step_id` (mcp_server.py)."""
with ai_tasks._lock:
task_id = next((task["id"] for task in ai_tasks._load().values()
if any(step["id"] == step_id for step in task.get("steps") or [])), None)
with _lock:
sheet = _load()
if not task_id or not sheet or sheet["id"] != sheet_id:
return
sheet.update(task=task_id, step=step_id)
atomic_write_json(_path(SHEET), sheet)
_sync_step(sheet, sheet["last"])
def _words(sheet):
return f"任务单 {sheet['done']}/{sheet['total']} / Job sheet: {sheet['done']} of {sheet['total']} runs"
def _sync_step(sheet, pictures=()):
"""Where the sheet stands, on the task step that started it: its progress while it goes, the newest pictures,
and its end as a run's (done, cancelled, error; the line to paste to the AI as ai_task_runs.py gives it)."""
if not sheet.get("task") or not sheet.get("step"):
return
from . import ai_gates
with ai_tasks._lock:
task = ai_tasks._load().get(sheet["task"])
step = next((item for item in (task or {}).get("steps", []) if item["id"] == sheet["step"]), None)
if not step:
return
step["sheet"] = sheet["id"]
step["sheet_total"] = sheet["total"]
if pictures:
kept = [item for item in step["results"] if item["type"] != "image"]
shown = [item for item in step["results"] if item["type"] == "image"]
step["results"] = kept + (shown + [item for item in pictures if item not in shown])[-ai_tasks.MAX_RESULTS:]
if sheet["state"] not in ENDED:
step["state"] = "running"
paused = " · 暂停 / paused" if sheet["state"] == "paused" else ""
step["progress"] = {"state": "running", "prompts": sheet["total"], "prompt": min(sheet["done"] + 1, sheet["total"]),
"note": _words(sheet) + paused + (f" · {sheet['note']}" if sheet["note"] else "")}
elif step.get("state") == "running":
step.pop("progress", None)
failed = sheet["failed"] and not sheet["pictures"]
step["state"] = "cancelled" if sheet["state"] == "stopped" else "error" if failed else "done"
step["prompts"] = sheet["done"]
if sheet["failed"]:
step["error"] = f"{sheet['failed']} of {sheet['done']} runs failed. {sheet['note']}".strip()
step["ok"] = not failed
step["ms"] = int((time.time() - step["at"]) * 1000)
if step["state"] in ("done", "error") and not step.get("told") and ai_gates.run_wait() == "call":
step["call_ai"] = True
task["updated"] = max(task["updated"], time.time())
ai_tasks._save(task)
ai_tasks._notify(sheet["task"])
# ---------------------------------------------------------------- routes
async def _body(request):
try:
body = await request.json()
except ValueError:
body = None
if not isinstance(body, dict):
raise SheetError("Expected a JSON object.")
return body
def _answer(run):
async def handler(request):
import asyncio
try:
body = await _body(request) if request.method == "POST" else {}
return web.json_response(await asyncio.to_thread(run, body))
except SheetError as error:
return web.json_response({"error": str(error)}, status=400)
return handler
api_get = _answer(lambda body: {"sheet": current()})
api_workflow = _answer(lambda body: {"workflow": workflow()})
api_start = _answer(lambda body: {"sheet": start(body)})
api_report = _answer(lambda body: {"sheet": report(body)})
api_ask = _answer(lambda body: ask(body.get("id"), body.get("want"), body.get("page")))
api_clear = _answer(lambda body: clear(body.get("id")) or {"sheet": None})
+75
View File
@@ -0,0 +1,75 @@
"""Whether an Anima ControlNet-LLLite fits the drawing model it is put on, before a run. An LLLite file
holds modules for each block of the model it was trained on (lllite_dit_blocks_<n>_…: 28 blocks for the 2B
Anima models); on a model with more blocks (40 for the 2.9B ones) ComfyUI-Anima-LLLite stops the run with
"depth_embed slices missing". Both counts are read from the files' headers, so nothing is told by a
model's name or size. The page (web/modules/lllite_fit.js) asks with the LLLite nodes a run would send
and the model each is put on; the AI's run is then refused with what to change. An example with a Tile
opens on a model the Tile fits (example_workflows.py). A file whose header cannot tell is not judged.
POST /anomalous/lllite_fit {uses: [{model, lllite}]} -> {problems: [{model, lllite, blocks, made_for}]}."""
import asyncio
import os
import re
import folder_paths
from aiohttp import web
try:
from ..model_kind import read_head
except ImportError: # loaded outside the package (tests)
from model_kind import read_head
MODEL_FOLDERS = ("diffusion_models", "unet", "checkpoints")
MAX_USES = 20
MODEL_BLOCK = re.compile(r"(?:^|\.)blocks\.(\d+)\.")
LLLITE_BLOCK = re.compile(r"^lllite_dit_blocks_(\d+)_")
def _path(folders, name):
for folder in folders:
try:
path = folder_paths.get_full_path(folder, name)
except (KeyError, TypeError):
path = None
if path and os.path.isfile(path):
return path
return None
def _blocks(path, pattern):
"""How many blocks the names in the header of `path` count (by `pattern`); 0 when it cannot tell."""
fmt, header = read_head(path) if path else (None, None)
if fmt != "safetensors" or not header:
return 0
return len({int(found.group(1)) for key in header if (found := pattern.search(key))})
def problem(model, lllite):
"""{model, lllite, blocks, made_for} when LLLite file `lllite` (as its node names it) has no modules for
some blocks of drawing model `model` (as its loader names it), else None."""
made_for = _blocks(_path(("controlnet",), lllite), LLLITE_BLOCK)
blocks = _blocks(_path(MODEL_FOLDERS, model), MODEL_BLOCK)
if not made_for or blocks <= made_for:
return None
short = lambda name: os.path.basename(str(name).replace("\\", "/")) # noqa: E731
return {"model": short(model), "lllite": short(lllite), "blocks": blocks, "made_for": made_for}
def check(uses):
found = []
for use in uses[:MAX_USES]:
if isinstance(use, dict) and isinstance(use.get("model"), str) and isinstance(use.get("lllite"), str):
odd = problem(use["model"], use["lllite"])
if odd:
found.append(odd)
return found
async def api_check(request):
try:
body = await request.json()
except ValueError:
body = None
if not isinstance(body, dict) or not isinstance(body.get("uses"), list):
return web.json_response({"error": "Expected {uses}"}, status=400)
return web.json_response({"problems": await asyncio.to_thread(check, body["uses"])})
+65 -9
View File
@@ -15,7 +15,7 @@ import time
import urllib.error
import urllib.request
from . import ai_bases, ai_gates, mcp_fill, model_advice, run_refusal
from . import ai_bases, ai_gates, ai_plans, job_sheets, mcp_fill, model_advice, run_refusal
from .lora_info import trains_text_encoder
from .mcp_bridge import PageRefused, PageUnavailable, ask_page
from .mcp_tools import MAX_LIMIT, ToolError, _resolve_model, _schema
@@ -121,7 +121,8 @@ def download_model(ctx, type, url="", hash="", folder=None, name=""):
"folder": None if folder is None else str(folder), "name": str(name or "")}, timeout=90)
def download_status(ctx):
def downloads(ctx):
"""How the model downloads of this ComfyUI run are going (run_status(downloads=true))."""
status, data = _local(ctx, "GET", "/anomalous/download/status")
if status != 200:
raise ToolError(f"Download status unavailable ({status}).")
@@ -178,10 +179,53 @@ def _base_words(answer, page, base_only, hold):
"this run draws its own base.")
def run_workflow(ctx, batch=1, wait_seconds=55, workflow=None, prompts=None, base_only=False, **slots):
def _sheet(ctx, sheet, batch, workflow, slots):
"""run_workflow(sheet=...): the job sheet the user's page runs one run after another (job_sheets.py)."""
task = ctx.get("task")
if ai_plans.due(task, ai_plans.PREVIEWS) or (ai_plans.due(task, ai_plans.PICK) and ai_bases.hold_of(task) is None):
raise ToolError("Nothing was run: the plan's bases come first, for the user to pick one (their rule). "
"run_workflow() shows them; the sheet after.")
if not isinstance(sheet, dict) or set(sheet) - {"rows", "count", "pause", "same_seeds"}:
raise ToolError("sheet is {rows: [prompt or {prompt, negative, values, count}], count, pause, same_seeds}.")
prompt = slots.pop("prompt", None) # with a sheet: the prompt of each row that has none
given = sheet.get("rows")
if prompt and isinstance(given, list):
given = [{"prompt": prompt, **row} if isinstance(row, dict) and row.get("prompt") is None else row for row in given]
try:
rows = job_sheets.rows_of(given, sheet.get("count", batch))
pause = job_sheets.pause_of(sheet.get("pause", 0))
except job_sheets.SheetError as error:
raise ToolError(str(error))
filled = mcp_fill.fill(ctx, workflow, **slots)
try:
started = ask_page("job_sheet", {"rows": rows, "pause": pause, "same_seeds": bool(sheet.get("same_seeds")), "task": task})
except (PageUnavailable, PageRefused) as error:
raise ToolError(str(error))
answer = {**(started or {}), "note": "Anomalous runs the sheet on the user's ComfyUI page, one run after another; "
"it shows there and as this step. It takes a while: tell the user and end your turn. run_status says "
"where it stands, stop_run stops it."}
if filled:
answer["filled"] = filled
return answer
def run_workflow(ctx, batch=1, wait_seconds=55, workflow=None, prompts=None, base_only=False, sheet=None, **slots):
from .ai_run_tracking import checked_wait
checked_wait(wait_seconds) # Reject a wait that is no number before anything can be submitted.
batch = max(1, min(8, int(batch)))
if sheet is not None:
if prompts is not None or base_only:
raise ToolError("A sheet's prompts are its rows: give no prompts or base_only with it.")
return _sheet(ctx, sheet, batch, workflow, slots)
task = ctx.get("task")
first_of_plan = not base_only and prompts is None and ai_plans.due(task, ai_plans.PREVIEWS)
if first_of_plan: # the user's rule: a plan of several steps shows its bases to pick from before it all runs
base_only, batch = True, 3
elif not base_only and ai_plans.due(task, ai_plans.PICK) and ai_bases.hold_of(task) is None:
ai_plans.mark(task, ai_plans.PICK, False) # said once: "go on without one" is the user's to say
raise ToolError("Nothing was run: the user picks one of the plan's bases first (their rule). Anomalous shows them "
"on that run when it ends: wait with get_answers, then run_workflow. If they say to go on without "
"one, call run_workflow again.")
if prompts is not None: # several prompts, queued in order as one run, each with the call's other blanks
if slots.get("prompt") or not isinstance(prompts, list) or not 1 <= len(prompts) <= MAX_PROMPTS or any(not isinstance(text, str) or not text.strip() for text in prompts):
raise ToolError(f"prompts is a list of 1 to {MAX_PROMPTS} prompts, given instead of prompt.")
@@ -218,6 +262,14 @@ def run_workflow(ctx, batch=1, wait_seconds=55, workflow=None, prompts=None, bas
"Follow these runs with run_status (wait_seconds up to 55).")
+ " Do not repeat the batch; accepted prompts are already queued."}
_base_words(answer, page, base_only, hold)
if base_only and ids:
ai_plans.mark(task, ai_plans.PREVIEWS, False)
ai_plans.mark(task, ai_plans.PICK, first_of_plan)
elif ids:
ai_plans.mark(task, ai_plans.PICK, False)
if first_of_plan and ids:
answer["plan_note"] = ("Ran the first pass alone, three bases: the user picks one before the whole plan runs (their "
"rule for plans). Show them to pick from as said here, then run_workflow again for the rest.")
if prompts is not None:
answer["by_prompt"] = groups # which receipts are which prompt; the canvas keeps the last prompt
if filled:
@@ -382,16 +434,16 @@ ACTIONS = dict([
"(default: the user's download settings); name the file name (default: the site's). Never replaces a file.",
download_model, {"type": {"type": "string"}, "url": {"type": "string"}, "hash": {"type": "string"},
"folder": {"type": "string"}, "name": {"type": "string"}}, ["type"], open_world=True),
_action("download_status", "Downloads", "How the downloads of this ComfyUI run are going.", download_status,
read_only=True),
_action("run_workflow", "Run the workflow",
_action("run_workflow", "Run the workflow, or a job sheet",
"Queues the workflow (uses the GPU). workflow (a saved one) opens once in the task's "
"tab; prompt, negative, seed (every sampler), width/height (the start latent), image (its LoadImage) and "
"values (any setting by name: {\"steps\": 30}; {\"12\": {\"denoise\": 0.5}} for one node) fill it "
"in, else the open canvas. Then run_status, never the output folder.",
"in, else the open canvas. Then run_status, never the output folder. Many prompts or settings, or a rest "
"between pictures: one call with sheet, Anomalous runs it one by one.",
run_workflow, {"batch": {"type": "integer", "minimum": 1, "maximum": 8, "default": 1,
"description": "Tries, each its own seed."},
"wait_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 55},
"wait_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 55,
"description": "This call's wait for the run; a rest between pictures is sheet.pause."},
"workflow": {"type": "string", "description": "A name or plan from list_workflows."},
"prompt": _PROMPT, "negative": {"type": "string"},
"prompts": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_PROMPTS,
@@ -402,7 +454,11 @@ ACTIONS = dict([
"image": {"type": "string", "description": "subfolder/name.png, output or input folder."},
"image_node": {"type": "integer", "description": "Which LoadImage, of several."},
"values": {"type": "object", "description": "Other settings by name, any node's."},
"base_only": {"type": "boolean", "description": "First pass only: previews to pick a base from."}}),
"base_only": {"type": "boolean", "description": "First pass only: previews to pick a base from."},
"sheet": {"type": "object", "description": (
"Many runs, run one after another by Anomalous: {rows: [prompt or {prompt, negative, "
"values, count}], count: runs of each row (default batch), pause: seconds between "
"pictures, same_seeds}. prompt: a row's when it has none.")}}),
_action("open_in_anomalous", "Show a model",
"Opens Anomalous Model Browser on this model's page, so the user sees its cover and details.",
open_in_anomalous, {"model_id": _MODEL}, ["model_id"]),
+1 -1
View File
@@ -75,7 +75,7 @@ def _questions(step):
panel = step["panel"]
out = []
for block in panel["blocks"]:
if (block["type"] not in ai_panels.INTERACTIVE or block["type"] == "prompts" # prompts are written in Anomalous only
if (block["type"] not in ai_panels.INTERACTIVE or block["type"] in ("prompts", "plan") # answered in Anomalous only
or block["id"] in step.get("answers", {}) or panel.get("closed")):
continue
options = block.get("options") or [{"id": item["id"], "label": item.get("label") or item["id"]} for item in block.get("items", [])]
+16 -5
View File
@@ -11,7 +11,7 @@ import os
import threading
import time
from . import ai_gates, ai_pictures, ai_tasks, ai_toolbox, mcp_fill, mcp_routes, model_advice, model_download, reference_blocks
from . import ai_gates, ai_pictures, ai_tasks, ai_toolbox, mcp_fill, mcp_plans, mcp_routes, model_advice, model_download, reference_blocks
from .mcp_actions import _action, _local, _page
from .media_routes import picture_size
from .mcp_tools import ToolError, _resolve_model
@@ -271,8 +271,13 @@ def _empty_canvas():
raise ToolError("The canvas already makes a picture: run it, or new_workflow (blank) first for a separate one.")
def add_nodes(ctx, nodes=None, near_node_id=None, replace=False, block=None, image=None, scene=None, use=None, want=None, model=None, route=None):
def add_nodes(ctx, nodes=None, near_node_id=None, replace=False, block=None, image=None, scene=None, use=None, want=None, model=None, route=None,
save_node_id=None, plan=None):
if plan is not None:
return mcp_plans.propose(ctx, plan)
if route is not None:
if mcp_plans.is_card(ctx, route):
return mcp_plans.build_answered(ctx, route)
return mcp_routes.build(ctx, str(route), model)
if not isinstance(replace, bool):
raise ToolError("replace must be true or false.")
@@ -312,7 +317,10 @@ def add_nodes(ctx, nodes=None, near_node_id=None, replace=False, block=None, ima
_allowed(ctx, [spec["class_type"] for spec in nodes.values()])
answer = _next(_page("add_nodes", {"nodes": nodes, "replace": replace,
"roles": built["roles"] if block is not None else {},
"near_node_id": None if near_node_id is None else _id(near_node_id, "near_node_id")}, timeout=60))
"near_node_id": None if near_node_id is None else _id(near_node_id, "near_node_id"),
"save_node_id": None if save_node_id is None else _id(save_node_id, "save_node_id")}, timeout=60))
if block is not None and isinstance(answer, dict):
answer["block"] = str(block) # which one this ComfyUI ran for the use (block_chain.py reads what it gives)
if block is not None and isinstance(answer, dict) and not answer.get("unwired") and answer.get("reaches_output") is not False:
answer["next"] = built["after"]
if built["tested"]:
@@ -386,7 +394,8 @@ CANVAS_TOOLS = dict([
"{\"samples\": [\"a\", 0]}, \"wire_to\": 15}}. A link names a key of the group or a canvas node \"#id\", and "
"an output slot or name; wire_to as in add_node. Checked first; one Ctrl+Z step; the answer maps keys "
"to node ids. Or use (and want): a ready-made block for that use (begin_task lists them), the best this "
"ComfyUI can run, spliced into the canvas's sampler by itself; near_node_id picks the sampler when there are several.",
"ComfyUI can run, spliced into the canvas's sampler by itself; near_node_id picks the sampler when there are several. "
"plan: a whole picture in big steps, built on a new tab once the user agrees on its card.",
add_nodes, {"nodes": {"type": "object"}, "use": {"type": "string", "enum": list(reference_blocks.JOBS)},
"want": {"type": "string", "description": "Kind from begin_task (best, 2x, pose…)."},
"block": {"type": "string"},
@@ -394,7 +403,9 @@ CANVAS_TOOLS = dict([
"scene": {"type": "string", "description": "The picture place_in_scene puts it into."},
"near_node_id": _NODE_ID, "replace": {"type": "boolean"},
"model": {"type": "string", "description": "use=picture: a model_id, else the task's picked one."},
"route": {"type": "string", "description": "The route ask_user(routes=true) got: built whole on a new tab."}}),
"route": {"type": "string", "description": "The route ask_user(routes=true) got, or a plan card's id the user said build to: built whole on a new tab."},
"plan": {"type": "array", "maxItems": 6, "items": {"type": "object"},
"description": "Steps, the picture first: [{use: 'picture', model}, {use: 'refine', model}, {use: 'detail'}, {use: 'upscale'}]; use and want as above."}}),
_action("connect_nodes", "Wire two nodes",
"Wires two nodes: every unique matching type, and same-type sockets of the same name (positive to "
"positive). Anything ambiguous (a lone CONDITIONING into positive or negative, wildcards) changes "
+167
View File
@@ -0,0 +1,167 @@
"""read_guide: Anomalous's own manual for the AI, a topic at a time, so the tool list carries the
names and the details come when they are needed. The topics are what is kept for people already,
in English or Chinese, so the two never drift apart: the README between `<!-- guide:name -->`
marks (both languages carry the same names), Model Check's rules (docs/guides/model-check-rules*.md,
its sections), and the panel blocks' fields for show_panel and update_panel. A topic longer than
one answer (mcp_fit.BUDGET) comes in parts, cut between paragraphs. Read from disk at each call:
the files are small and the AI asks rarely."""
import os
import re
from . import mcp_fit
from .mcp_tools import ToolError, _tool
PLUGIN = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
README = os.path.join(PLUGIN, "README.md")
RULES = {"en": os.path.join(PLUGIN, "docs", "guides", "model-check-rules.en.md"),
"zh": os.path.join(PLUGIN, "docs", "guides", "model-check-rules.md")}
HALVES = {"en": ('<h2 id="english">', '<h2 id="中文">'), "zh": ('<h2 id="中文">', "### 🧪")}
MARK = re.compile(r"^<!-- guide:([a-z_]+) -->$")
ENDS = ("### ", "<h2", "<details", "</details>") # a marked part of the README ends there, or at the next mark
PART_BYTES = mcp_fit.BUDGET - 600 # room for the answer's other fields
TITLES = { # the list's words for each topic; a README mark without one is listed by its first line
"idea": "What Anomalous is for", "ai_workbench": "The AI workbench: how it works with you",
"names": "Scanning: real names, covers and trigger words", "model_check": "Model Check: missing models put back",
"workflows": "Workflow cards, their stages and presets", "saves": "Canvas saves (存档)",
"job_sheet": "Job sheet (任务单): many prompts or settings, a rest between pictures",
"picture_workflow": "A picture's own workflow, from the gallery", "combos": "Combos: model, LoRAs and prompt together",
"prompts": "Prompt Studio", "voices": "Character voices (Anomalous_TTS)", "undo": "The activity log and undo",
"light": "What it takes from the computer", "install": "Installing, and finding it after (the cube button)",
"pages": "Every page and tool in detail", "connect_ai": "Connecting an AI app", "open": "Opening, shortcuts, updating",
"network": "Network, cloud servers and backups", "model_check_rules": "Model Check's full rules",
"panel": "show_panel's blocks and update_panel",
}
PANEL = """show_panel's blocks, top to bottom, each {type, ...}:
- heading {text}; text {text, tone: normal|muted|tip|warning}.
- images {items: [{image: 'subfolder/name.png' from search_images, caption, marks: [{x, y, r | w, h, label, color: red|green|blue|yellow|purple}]}], layout: row|grid|compare}. marks are circles or boxes with a label; x/y/w/h/r are fractions 0-1 of the picture, x/y the centre; a click on the label zooms there.
- slider {before: {image}, after: {image}, before_label, after_label}: one picture over the other, a line to drag.
- picks {id, question, items: [{id, image, label, reason, recommended}] (2-12), multiple}: pictures to choose from; the user can also put one's workflow on the canvas.
- slider and picks: zoom {x, y, w, h} shows only that part, enlarged.
- diagram {nodes: [{id, label, detail, node_id, color}], edges: [{from, to, label}], direction: right|down}: boxes and arrows Anomalous lays out; node_id is a canvas node it finds.
- models {items: [{model_id from search_models, note}]}: cards with their covers, a click opens them.
- table {columns: [...], rows: [[...]]}; list {items: [...], ordered}; params {items: [{label, value}]}.
- choices {id, question, options: [{id, label, detail}] (2-8), multiple}: buttons the user presses.
- input {id, question, placeholder}: a line the user types.
Each block may have a stable id; show_panel returns the block ids in display order. Plain text only (no markup, no links); up to 30 blocks, 12 pictures or models a block.
update_panel adds new output at the bottom of a panel and keeps what the user saw, in its order: send only the new blocks (repeated unchanged blocks are kept once). To correct a block or a question in place, send it with its returned id and the same type; blocks left out stay. Never reuse an old block id for a new result: append it, or show_panel for the next phase. Up to 30 blocks a panel; closed=true stops answers, answers given stay."""
def _read(path):
try:
with open(path, encoding="utf-8") as source:
return source.read()
except OSError:
return ""
def _plain(lines):
"""Text people read, without the README's pictures and badges, blank runs folded."""
kept = [line.rstrip() for line in lines if not line.lstrip().startswith(("<p align", "<br", "<img"))]
return re.sub(r"\n{3,}", "\n\n", "\n".join(kept)).strip()
def _title(text):
first = next((line for line in text.splitlines() if line.strip()), "")
return re.sub(r"[*#`|]", "", first).strip()[:90]
def _readme(lang):
"""{name: text} of the README's marked parts in that language."""
start, end = HALVES[lang]
text = _read(README)
half = text.split(start, 1)[-1].split(end, 1)[0] if start in text else ""
parts, name, lines = {}, None, []
for line in half.splitlines():
mark = MARK.match(line.strip())
if mark or (name and line.startswith(ENDS)):
if name:
parts[name] = _plain(lines)
name, lines = (mark.group(1), []) if mark else (None, [])
continue
if name:
lines.append(line)
if name:
parts[name] = _plain(lines)
return parts
def _rules(lang):
"""Model Check's rules as people read them, without the section for those who maintain the code."""
sections = re.split(r"\n(?=## )", _read(RULES[lang]))
return _plain("\n".join(section for section in sections if "(for the AI" not in section and "给维护" not in section).splitlines())
def _pieces(text):
"""Paragraphs, a heading kept with the paragraph under it."""
pieces = []
for paragraph in text.split("\n\n"):
if pieces and pieces[-1].startswith("#") and "\n" not in pieces[-1]:
pieces[-1] += "\n\n" + paragraph
else:
pieces.append(paragraph)
return pieces
def _parts(text):
"""`text` cut between paragraphs (or lines) into parts that fit an answer."""
size = lambda value: len(value.encode("utf-8"))
pieces = []
for piece in _pieces(text):
pieces.extend(piece.splitlines() if size(piece) > PART_BYTES else [piece])
parts = [""]
for piece in pieces:
joined = f"{parts[-1]}\n\n{piece}" if parts[-1] else piece
if size(joined) > PART_BYTES and parts[-1]:
parts.append(piece)
else:
parts[-1] = joined
return parts
def topics(lang):
"""{name: text} of every topic in that language, the README's in its order first."""
found = _readme(lang)
found["model_check_rules"] = _rules(lang)
found["panel"] = PANEL
return found
def _is_tool(name):
from .mcp_server import TOOLS # here, not at the top: the server imports this file
return name in TOOLS
def read_guide(topic="", lang="en", part=1):
lang = "zh" if str(lang or "").lower().startswith("zh") else "en"
found = topics(lang)
if not topic:
return {"topics": [{"topic": name, "title": TITLES.get(name) or _title(text)} for name, text in found.items()],
"note": "read_guide(topic=...) gives one. With lang='zh' the README's parts are in Chinese, with the "
"words the Chinese window shows: use them when you guide a user who writes Chinese."}
if topic not in found:
tool = " It is a tool: its description and its parameters' say how." if _is_tool(topic) else ""
raise ToolError(f"No topic {topic!r}.{tool} The topics: {', '.join(found)}.")
parts = _parts(found[topic])
try:
part = min(max(int(part), 1), len(parts))
except (TypeError, ValueError):
part = 1
answer = {"topic": topic, "text": parts[part - 1]}
if len(parts) > 1:
answer.update(part=part, parts=len(parts))
if part < len(parts):
answer["more"] = f"read_guide(topic={topic!r}, part={part + 1}) goes on."
return answer
GUIDE_TOOLS = dict([
_tool("read_guide", "Read Anomalous's guide",
"Anomalous's own manual, a topic at a time: how its pages and features work, to guide the user with the "
"words on screen (lang='zh': the Chinese ones), Model Check's rules, and the fields of show_panel's blocks "
"(topic 'panel'). No topic: the list. A long topic comes in parts.",
read_guide, {"topic": {"type": "string"}, "lang": {"type": "string", "enum": ["en", "zh"], "default": "en"},
"part": {"type": "integer", "minimum": 1, "default": 1}}),
])
+17 -7
View File
@@ -11,7 +11,7 @@ import threading
import folder_paths
from . import mcp_canvas_edit, mcp_fit, mcp_tools, model_advice, model_placement
from . import mcp_canvas_edit, mcp_fit, mcp_tools, model_advice, model_guide, model_placement
from .mcp_actions import _action
from .mcp_tools import _LIMIT, _QUERY, ToolError
from .model_constants import MODEL_EXTENSIONS
@@ -129,11 +129,14 @@ def search_models(ctx, query="", type="", base_model="", source="", limit=20):
found = mcp_tools.search_models(query, type, base_model, source, limit)
for model in found["models"]:
_about(ctx, model)
if model["type"] == "loras": # the weight its author's pictures use: picking one needs no get_model each
if model["type"] == "loras": # its author's weight and its header's words: picking one needs no get_model each
path = mcp_tools._resolve_model(model["model_id"])[3]
weight = model_advice.author_examples(path, "loras", (mcp_tools.get_metadata(path) or {}).get("hash", "")).get("lora_weight")
meta = mcp_tools.get_metadata(path) or {}
weight = model_advice.author_examples(path, "loras", meta.get("hash", "")).get("lora_weight")
if weight:
model["author_weight"] = weight
if not model.get("trigger_words") and meta.get("headerWords"):
model["header_words"] = meta["headerWords"][:SEARCH_WORDS]
return _compact(found)
@@ -143,6 +146,13 @@ def get_model(ctx, model_id, include_cover=False):
examples = model_advice.author_examples(path, folder_type, detail.get("sha256", ""))
if examples:
detail["author_examples"] = examples
meta = mcp_tools.get_metadata(path) or {}
guessed = model_guide.words(path, folder_type, meta)
if guessed:
detail["header_words"] = guessed
use = model_guide.usage(folder_type, detail.get("base_model") or meta.get("base_guess") or "")
if use:
detail["usage"] = use
return _about(ctx, detail), extra
@@ -188,10 +198,10 @@ MODEL_TOOLS = dict([
"description": "Where the model's information came from."},
"limit": _LIMIT}, read_only=True),
_action("get_model", "Model details",
"Everything known about one model: Civitai name, version and link, trigger words, base model, description, "
"the user's own name and notes, author_examples (the sampler, steps, CFG, size and, for a LoRA, the weight "
"its author's example pictures used: a good start), and loaded_by (or belongs_in and placement) as in "
"search_models. include_cover attaches its cover picture.",
"Everything known about one model: Civitai name, version and link, trigger words (or header_words, guessed "
"from its training tags), base model, description, the user's notes, author_examples (settings its "
"author's pictures used: a good start), usage (files it needs, notes on its family), and loaded_by (or "
"belongs_in and placement) as in search_models. include_cover attaches its cover.",
get_model, {"model_id": {"type": "string", "description": "From search_models."},
"include_cover": {"type": "boolean", "default": False}}, ["model_id"], read_only=True),
_action("move_model", "Move a model to its folder",
+146
View File
@@ -0,0 +1,146 @@
"""add_nodes(plan=…): the big steps an AI lays out for a picture (make it, redraw it with another model, fix
faces, enlarge), each a ready-made block (reference_blocks.py), shown to the user on a plan card
(ai_plans.py) and built in one go on a new tab (block_chain.py) when they say build: the AI wires nothing
and the user sees the route before it is built. A step is {use, want} as for add_nodes, or {block}, and
may name a model (the first step's drawing model, else the task's picked one; a later step's for its
block's own loader). With "build without asking" on, the card shows what was built at once. A card
answered after the AI stopped waiting is built by add_nodes(route=<its panel id>)."""
from . import ai_answer_wait, ai_panels, ai_plans, ai_tasks, ai_toolbox, block_chain, model_advice, reference_blocks
from .mcp_routes import NEXT, ONE_PASS
from .mcp_tools import ToolError, _resolve_model, get_metadata, search_models
MAX_STEPS = 6
MAX_CHOICES = 12 # models of the same family offered for a step
def _model(model_id):
"""(name, folder type, base model) of `model_id`; ToolError when it is gone."""
folder_type, _root, _base, path = _resolve_model(str(model_id))
meta = get_metadata(path) or {}
name = meta.get("custom_name") or meta.get("name") or path.replace("\\", "/").rsplit("/", 1)[-1]
return name, folder_type, model_advice.base_of(path)
def _choices(model_id, folder_type, base):
"""Other models of the folder and family of `model_id`, for the user to swap in."""
found = search_models("", folder_type, "", "", 50)["models"]
return [{"model_id": item["model_id"], "name": item["name"]} for item in found if item["model_id"] != model_id
and model_advice.family(item.get("base_model")) == model_advice.family(base)][:MAX_CHOICES]
def _task_id(ctx):
return ctx.get("task") or ai_tasks.active_task_id(ctx.get("client") or "AI", ctx.get("session") or "")
def _model_id(value, at):
"""`value` as a model_id: one already, else the one main model whose file or name it is."""
value = str(value)
if value.count(":") >= 2:
return value
wanted = value.strip().lower()
found = [item for item in search_models(value, "", "", "", 50)["models"] if item["model_id"].split(":", 1)[0] in model_advice.MAIN_TYPES
and wanted in (item["name"].lower(), item["model_id"].rsplit("/", 1)[-1].rsplit(".", 1)[0].lower())]
if len(found) != 1:
raise ToolError(f"Step {at + 1}: model is a model_id from search_models" + (
f"; {value!r} is the name of {len(found)}: " + ", ".join(item["model_id"] for item in found[:5]) if found else "."))
return found[0]["model_id"]
def _box(ctx):
task_id = _task_id(ctx)
return ai_toolbox.effective(ai_tasks.get_task(task_id) if task_id else None) or {}
def resolve(ctx, plan):
"""The card's steps for the AI's `plan`: each its block (the best of its use this ComfyUI can run, or the
way the user picked), title, model and the models to swap in. ToolError for a plan that cannot be built."""
from .mcp_canvas_edit import object_info
if not isinstance(plan, list) or not 1 < len(plan) <= MAX_STEPS:
raise ToolError(f"plan is 2 to {MAX_STEPS} steps, the picture first: [{{use: 'picture', model: …}}, {{use: 'refine', model: …}}, …]; "
"the picture alone is add_nodes(use='picture', model=…).")
info, ways, steps = object_info(ctx), _box(ctx).get("ways") or (), []
for at, step in enumerate(plan):
step = step if isinstance(step, dict) else {}
try:
name = str(step["block"]) if step.get("block") in reference_blocks.BLOCKS else \
reference_blocks.pick(str(step.get("use")), None if step.get("want") is None else str(step["want"]), info, False, ways)
except (reference_blocks.BlockError, KeyError) as error:
raise ToolError(f"Step {at + 1}: {error}")
if reference_blocks.BLOCKS[name].get("takes") in ("picture", "reference") and not step.get("image") and step.get("use"):
# one for a picture the user hands over (fix_hands): in a plan the step before gives the picture
chained = reference_blocks.pick(str(step["use"]), None, info, False, ways)
if reference_blocks.BLOCKS[chained].get("takes") in ("picture", "reference"):
raise ToolError(f"Step {at + 1}: {name} works on a picture the user hands over, not on the step before's.")
name = chained
block = reference_blocks.BLOCKS[name]
if (at == 0) != bool(block.get("standalone")):
raise ToolError("A plan starts with the picture (use='picture') and has it once.")
model = _model_id(step["model"], at) if step.get("model") else (next((item for item in _box(ctx).get("models") or []
if item.split(":", 1)[0] in model_advice.MAIN_TYPES), None) if at == 0 else None)
if at == 0 and not model:
raise ToolError("Step 1: name the picture's model (model_id from search_models), or the user picks one for the task.")
if model and at and not block.get("own_model"):
raise ToolError(f"Step {at + 1} ({name}) has no loader of its own for a second model; leave model out.")
shown = {"block": name, "title": block["title"], "model": model}
if model:
model_name, folder_type, base = _model(model)
shown.update(model_name=model_name, choices=_choices(model, folder_type, base))
steps.append(shown)
return steps
def _built(ctx, plan, name):
loras = [item for item in _box(ctx).get("models") or [] if item.startswith("loras:")]
chain = block_chain.build(ctx, [{**step, "loras": loras} for step in plan], name)
ai_plans.mark(_task_id(ctx), ai_plans.PREVIEWS, len(chain["steps"]) > 1)
return {"built": [step["block"] for step in chain["steps"]], "nodes": chain["nodes"],
"next": NEXT if len(chain["steps"]) > 1 else ONE_PASS}
def propose(ctx, plan, wait_seconds=ai_panels.MAX_WAIT):
"""Shows the plan card and waits for the user, building it when they say build (at once with "build without asking")."""
steps = resolve(ctx, plan)
client, task = ctx.get("client") or "AI", ctx.get("task")
try:
panel = ai_panels.show(client, "Blueprint", [{"type": "plan", "id": "answer", "steps": steps}],
session=ctx.get("session") or "", task_id=task, tool="add_nodes", title_key="aiPlanTitle")
if ai_plans.auto():
kept = ai_plans.kept(steps, {})
ai_panels.answer(_task_id(ctx), _step(ctx, panel["id"])["id"], "answer", ["build"], extra={"plan": kept, "auto": True})
return {"panel": panel["id"], **_built(ctx, kept, "blueprint"), "note": "Built without asking: the user's setting."}
answer = ai_answer_wait.collect(client, panel["id"], max(30.0, float(wait_seconds or 0)))
except ai_panels.PanelError as error:
raise ToolError(str(error))
given = next((item for item in answer.get("answers") or [] if item["question"] == "answer"), None)
if not given:
return {"answered": False, "panel": panel["id"], **answer,
"note": f"When the user says build, add_nodes(route={panel['id']!r}) builds it as they left it; get_answers waits for them."}
return _given(ctx, panel["id"], given)
def _given(ctx, panel_id, given):
if given.get("plan"):
return {"panel": panel_id, **_built(ctx, given["plan"], "blueprint")}
return {"answered": True, "panel": panel_id, "text": given.get("text") or "",
"note": "Nothing was built: the user wants the plan changed. Change it as they say and call add_nodes(plan=…) again."}
def is_card(ctx, panel_id):
return ai_plans.card_of(_step(ctx, panel_id)) is not None
def _step(ctx, panel_id):
"""The task's panel step showing panel `panel_id`, or None."""
task_id = _task_id(ctx)
task = ai_tasks.get_task(task_id) if task_id else None
return next((item for item in (task or {}).get("steps") or [] if (item.get("panel") or {}).get("id") == str(panel_id)), None)
def build_answered(ctx, panel_id):
"""add_nodes(route=<a plan card's id>): the plan as the user left it, once they said build."""
given = (_step(ctx, panel_id) or {}).get("answers", {}).get("answer")
if not given:
raise ToolError("The user has not answered the plan card yet: get_answers waits for them.")
return _given(ctx, panel_id, given)
+12 -17
View File
@@ -2,13 +2,13 @@
model does not work out that two picked models mean one for the base and one for the enlargement, so
Anomalous prepares the choices (routes.json, those this ComfyUI can build for these models, a saved card
using both first): ask_user(routes=true) shows them, and add_nodes(route=…) builds the one picked on a new
tab from the ways (reference_blocks.py), its LoRAs on each model, so the AI wires nothing. Each picked
tab from the ways (reference_blocks.py) as a chain (block_chain.py), its LoRAs on each model, so the AI wires nothing. Each picked
model's cards are its examples: get_toolbox lists them with their route (workflow_route.py)."""
import json
import os
from . import ai_bases, ai_tasks, ai_toolbox, model_advice, reference_blocks, workflow_route
from . import ai_bases, ai_tasks, ai_toolbox, block_chain, model_advice, reference_blocks, workflow_route
from .mcp_tools import ToolError, _resolve_model
from .recipe_store import _list_recipes, get_recipes_dir
@@ -104,7 +104,7 @@ def waiting(task):
def build(ctx, key, model=None):
"""Route `key` on a new tab: the picture on the first model, the picked LoRAs on it, the way after it with
its own loader on the second model and the LoRAs there too. What was built, and what to do now."""
from . import mcp_actions, mcp_canvas_edit
from . import mcp_canvas_edit
route = ROUTES.get(key)
if not route:
@@ -112,24 +112,19 @@ def build(ctx, key, model=None):
box = ai_toolbox.effective(ai_tasks.get_task(ctx["task"]) if ctx.get("task") else None)
picked = ([model] if model else []) + [item for item in _main(box) if item != model]
if len(picked) < route["models"]:
raise ToolError(f"Route {key} draws with {route['models']} main models: the user picks them for the task, or give model.")
raise ToolError(f"Route {key} draws with {route['models']} main models the user picks for the task. With models of your "
"own choosing lay it out instead: add_nodes(plan=[{use: 'picture', model: <first>}, {use: 'upscale', "
"want: 'tile', model: <second>}, …]).")
ai_bases.forget(ctx.get("task")) # a base picked on another canvas: its sampler is not this route's
mcp_canvas_edit.new_workflow(ctx, key)
first = mcp_canvas_edit.add_nodes(ctx, use="picture", model=picked[0])
node = int(first["nodes"]["model"])
loras = _loras(box)
for lora in loras:
mcp_actions.add_lora(ctx, lora, after_node_id=node)
answer = {"route": key, "built": first["nodes"], "next": NEXT if route.get("way") else ONE_PASS}
steps = [{"use": "picture", "model": picked[0], "loras": loras}]
if route.get("way"):
use, want = route["way"]
added = mcp_canvas_edit.add_nodes(ctx, use=use, want=want)
answer["built"] = {**first["nodes"], **added["nodes"]}
loader = int(next(added["nodes"][name] for name in route["loader"] if name in added["nodes"]))
mcp_actions.set_model(ctx, loader, picked[1])
if "second_model" in route["loader"]: # loaded bare; the Tile's own model copied the LoRAs already
for lora in loras:
mcp_actions.add_lora(ctx, lora, after_node_id=loader)
steps.append({"use": use, "want": want, "model": picked[1], "loras": loras})
chain = block_chain.build(ctx, steps, key)
answer = {"route": key, "built": chain["nodes"], "next": NEXT if route.get("way") else ONE_PASS}
if route.get("way"):
added = chain["steps"][1]
if route.get("enlarge"):
before, after = int(added["nodes"]["first"]), int(added["nodes"]["second"])
mcp_canvas_edit.add_node(ctx, "LatentUpscaleBy", {"upscale_method": "nearest-exact", "scale_by": 1.5}, insert_after=before)
+15 -7
View File
@@ -24,10 +24,11 @@ import urllib.parse
from aiohttp import web
from . import ai_callers, ai_gates, ai_outside, ai_privacy, ai_sealed, ai_panels, ai_run_tracking, ai_task_canvas, ai_task_runs, ai_tasks, mcp_apps, mcp_bridge, mcp_directory, mcp_fit, mcp_prompts
from . import ai_callers, ai_gates, ai_outside, ai_privacy, ai_sealed, ai_sent, ai_panels, ai_run_tracking, ai_task_canvas, ai_task_runs, ai_tasks, job_sheets, mcp_apps, mcp_bridge, mcp_directory, mcp_fit, mcp_prompts
from .local_guard import is_local as _is_local, loopback_host as _loopback_host
from .mcp_actions import ACTIONS
from .mcp_canvas_edit import CANVAS_TOOLS, object_info
from .mcp_guide import GUIDE_TOOLS
from .mcp_model_use import MODEL_TOOLS
from .mcp_look import LOOK_TOOLS
from .mcp_situation import SITUATION_TOOLS
@@ -35,7 +36,8 @@ from .mcp_task_tools import TASK_TOOLS
from .mcp_tools import TOOLS as READ_TOOLS, ToolError, call_tool, retext
from .mcp_arguments import prepare_arguments, renaming_note
TOOLS = {**READ_TOOLS, **MODEL_TOOLS, **LOOK_TOOLS, **ACTIONS, **CANVAS_TOOLS, **TASK_TOOLS, **SITUATION_TOOLS, **mcp_apps.APP_TOOLS}
TOOLS = {**READ_TOOLS, **MODEL_TOOLS, **LOOK_TOOLS, **ACTIONS, **CANVAS_TOOLS, **TASK_TOOLS, **SITUATION_TOOLS, **GUIDE_TOOLS,
**mcp_apps.APP_TOOLS}
CAPABILITIES = {
"tools": {"listChanged": False}, "prompts": {"listChanged": False}, "resources": {"listChanged": False},
"extensions": {mcp_apps.EXTENSION: {"mimeTypes": [mcp_apps.MIME]}}, # the card in the conversation (mcp_apps.py)
@@ -56,9 +58,9 @@ INSTRUCTIONS = (
"the graph. new_workflow opens a tab only when needed. Tools also scan models or have an "
"Anomalous TTS voice speak. Canvas changes "
"are single Ctrl+Z steps and are logged as the AI's; nothing deletes files. Ids returned by "
"one tool (model_id, image, node ids) are what the others take. For what no tool does, tell "
"the user where in Anomalous to do it (the rail on the left: Models, Gallery, Workflows, "
"Combos, Prompts, Voices). begin_task's answer carries a directory of the tools by what you want to do: "
"one tool (model_id, image, node ids) are what the others take. For what no tool does, or when the "
"user asks how something in Anomalous works, read_guide has its manual: tell them the way, with the words "
"on screen. begin_task's answer carries a directory of the tools by what you want to do: "
"look there and go straight to the tool. In a new chat on a task, or when unsure where things stand, call get_situation "
"first: the task, the canvas in brief (what is still unwired) and the next step in one answer. "
"The user watches your work in Anomalous's AI workbench: call begin_task "
@@ -215,7 +217,10 @@ async def _call(params, request):
step_ref=step_ref, session=session, task_id=active_task,
)
ai_task_canvas.tell(result, canvas_call)
if name == "run_workflow" and step and step.get("state") == "running":
sheet = (result.get("structuredContent") or {}).get("sheet") if name == "run_workflow" else None
if sheet and step and step.get("state") == "running": # the page running it says where it stands (job_sheets.py)
await asyncio.to_thread(job_sheets.attach, sheet, step["id"])
elif name == "run_workflow" and step and step.get("state") == "running":
ai_task_runs.watch(step["id"], result.get("structuredContent") or {})
await asyncio.to_thread(ai_run_tracking.wait_for_receipt, result, client, session, active_task,
ai_run_tracking.checked_wait(arguments.get("wait_seconds", 55)), step["id"])
@@ -237,7 +242,10 @@ async def _call(params, request):
if final_task != privacy_task and await asyncio.to_thread(ai_privacy.applies, final_task):
private, privacy_task = True, final_task
info = await asyncio.to_thread(_node_list, ctx) if private else None
return await asyncio.to_thread(ai_privacy.guard, ai_outside.tell(result, outside), private, privacy_task, info, name)
result = await asyncio.to_thread(ai_privacy.guard, ai_outside.tell(result, outside), private, privacy_task, info, name)
if private and privacy_task and name not in mcp_apps.APP_TOOLS: # for the user to read what went out (ai_sent.py)
await asyncio.to_thread(ai_sent.keep, privacy_task, name, arguments, result)
return result
def _node_list(ctx):
+2 -1
View File
@@ -15,7 +15,8 @@ from .mcp_tools import _schema, tool_text
BUDGET = 3600 # characters: Antigravity files away an answer past about 4,000 and has the AI open it
EMPTY_CANVAS = ("The canvas is empty: run_workflow(workflow=<name>) opens a saved workflow and runs it in one call; open_workflow "
"opens one to change first, or a gallery picture's own by its file name; list_workflows names them and the examples.")
"opens one to change first, or a gallery picture's own by its file name; list_workflows names them and the examples. "
"A picture in several steps (the picture, a redraw on another model, details, an enlargement): add_nodes(plan=[…]).")
MAX_TEXT = 200
+33 -44
View File
@@ -5,6 +5,7 @@ reads what the user picked or typed on it, log_outside keeps what the AI did wit
(ai_outside.py)."""
from . import ai_answer_wait, ai_decisions, ai_marks, ai_message_refs, ai_outside, ai_panels, ai_repaint, ai_run_control, ai_run_tracking, ai_task_links, ai_task_runs, ai_tasks, ai_toolbox, ai_privacy, ai_skills, mcp_routes, reference_blocks
from .mcp_actions import downloads as model_downloads
from .mcp_tools import MAX_LIMIT, ToolError, _schema
PACK_TYPES = 150 # node types listed per picked pack
@@ -53,18 +54,26 @@ def begin_task(ctx, title, new_task=False):
answer["about"] = task["about"]
answer["about_note"] = ("The user started this task from these in Anomalous; work with them: model_id goes to "
"set_model / add_lora / get_model, image to get_image_info, a combo's name to place_combo, "
"a workflow's name to open_workflow.")
"a workflow's name to open_workflow; a canvas is the user's tab your work starts from "
"(describe_canvas reads it).")
return answer
def _node_list(ctx):
"""ComfyUI's node list, to say which blocks can run here; None when it cannot be read."""
from .mcp_canvas_edit import object_info
try:
return object_info(ctx) if ctx.get("base_url") else None
except ToolError:
return None
def _blocks(ctx):
"""The ready-made blocks (reference_blocks.py), each a line, for begin_task's answer: what to reach for first."""
from .mcp_canvas_edit import object_info # the node list, to say which blocks need a pack first
try:
info = object_info(ctx) if ctx.get("base_url") else None
except ToolError:
info = None
return {"note": "add_nodes(use=…, want=…) builds the best one this ComfyUI can run, wired in; prefer it.",
info = _node_list(ctx)
return {"note": "add_nodes(use=…, want=…) builds the best one this ComfyUI can run, wired in; prefer it. A picture in several "
"of these steps (the picture, a redraw on another model, details, an enlargement): add_nodes(plan=[{use: "
"'picture', model}, {use: …}, …]) shows the steps to the user and builds them all on a new tab.",
**reference_blocks.directory(info)}
@@ -115,7 +124,7 @@ def show_panel(ctx, title, blocks, task=None):
session=ctx.get("session") or "", task_id=task or ctx.get("task"), extra={"prompt_advice": advice} if advice else None,
)
except ai_panels.PanelError as error:
raise ToolError(str(error))
raise ToolError(f"{error} Every block's fields: read_guide(topic='panel').")
asks = [block["id"] for block in panel["blocks"] if block["type"] in ai_panels.INTERACTIVE]
answer = {"panel": panel["id"], "shown": True,
"blocks": [{"id": block["id"], "type": block["type"]} for block in panel["blocks"]]}
@@ -214,12 +223,14 @@ def update_panel(ctx, panel, title=None, blocks=None, closed=None, task=None):
try:
updated = ai_panels.update(panel, title, blocks, closed)
except ai_panels.PanelError as error:
raise ToolError(str(error))
raise ToolError(f"{error} Every block's fields: read_guide(topic='panel').")
return {"updated": True, "blocks": [{"id": block.get("id"), "type": block["type"]}
for block in updated["blocks"]]}
def run_status(ctx, wait_seconds=0, task=None):
def run_status(ctx, wait_seconds=0, task=None, downloads=False):
if downloads:
return model_downloads(ctx)
try:
return ai_task_runs.status(ctx.get("client") or "AI", ctx.get("session") or "", task or ctx.get("task"), wait_seconds)
except ValueError as error:
@@ -264,7 +275,7 @@ def get_toolbox(ctx, task=None, skill=None):
if skill:
private = ai_privacy.applies(task_id)
try:
answer = ai_skills.get(str(skill), _canvas_model() if ai_skills.by_model(str(skill)) else None, private)
answer = ai_skills.get(str(skill), _canvas_model() if ai_skills.by_model(str(skill)) else None, private, _node_list(ctx))
except ai_skills.SkillError as error:
raise ToolError(str(error))
ai_skills.note_use(ctx.get("client") or "AI", answer, session=ctx.get("session") or "", task_id=task or ctx.get("task"))
@@ -329,25 +340,6 @@ def get_answers(ctx, panel=None, wait_seconds=0, task=None):
raise ToolError(str(error))
BLOCKS_HELP = (
"blocks, top to bottom, each {type, ...}: heading {text}; text {text, tone: normal|muted|tip|warning}; "
"images {items: [{image: 'subfolder/name.png' from search_images, caption, marks: [{x, y, r | w, h, label, "
"color: red|green|blue|yellow|purple}]}], layout: row|grid|compare} (marks: circles or boxes with a label, x/y/w/h/r "
"as fractions 0-1 of the picture, x/y the centre; a click on the label zooms there); "
"slider {before: {image}, after: {image}, before_label, after_label} (one picture over the other, a line to drag); "
"picks {id, question, items: [{id, image, label, reason, recommended}] (2-12), multiple} (pictures to choose from; "
"the user can also put one's workflow on the canvas); slider and picks: zoom {x, y, w, h} shows only that part, enlarged; "
"diagram {nodes: [{id, label, detail, node_id, color}], edges: [{from, to, label}], direction: right|down} (boxes and "
"arrows Anomalous lays out; node_id: a canvas node it finds); "
"models {items: [{model_id from search_models, note}]} (cards with their covers, a click opens them); "
"table {columns: [...], rows: [[...]]}; list {items: [...], ordered}; params {items: [{label, value}]}; "
"choices {id, question, options: [{id, label, detail}] (2-8), multiple} (buttons the user presses); "
"input {id, question, placeholder} (a line the user types); progress {label, value, max} (your own "
"numbers, kept current with update_panel; none for a run: its progress shows already); divider {}. "
"Each block may have a stable id; the tool returns block ids in display order. "
"Plain text only (no markup, no links); up to 30 blocks, 12 pictures or models a block."
)
_TASK_PROP = {"type": "string", "description": "begin_task's id (optional)."}
@@ -370,11 +362,9 @@ def _question_with_images(run):
TASK_TOOLS = dict([
_task_tool("track_run", "Track an externally submitted run",
"Immediately after YOU submit to this ComfyUI server via /prompt or another tool, attach the exact "
"prompt_ids returned by your submissions to your task. This does NOT submit or rerun anything. "
"The workbench then shows real queue/node progress and results. Never use another user's IDs or "
"guess from queue differences. Repeating the same IDs is safe. Waits up to wait_seconds and returns "
"state, summary, pictures and errors; if still running, keep waiting with run_status(wait_seconds=55).",
"Only right after YOU queued a prompt another way (/prompt, a script): attaches the prompt_ids it "
"returned to your task, so the workbench shows its progress and pictures. Runs nothing; only your own "
"ids, never guessed; repeating them is safe. Waits up to wait_seconds, then run_status(wait_seconds=55).",
ai_run_tracking.track_run, {"prompt_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 8},
"task": _TASK_PROP, "wait_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 15}}, ["prompt_ids"]),
_task_tool("begin_task", "Begin a task",
@@ -414,7 +404,9 @@ TASK_TOOLS = dict([
"Draws a panel in the AI workbench, in Anomalous's own style, from blocks: to compare pictures side by "
"side, present models with their covers, lay out settings, a table, and to ask (buttons or a line of "
"text). Use a new panel for each new phase (plan, workflow, then results), in the order they happen. "
"Use it when a choice is the user's, or to show results better than words do. " + BLOCKS_HELP,
"Use it when a choice is the user's, or to show results better than words do. Blocks: heading, text, "
"images, slider, picks, diagram, models, table, list, params, choices, input; "
"read_guide(topic='panel') gives their fields before your first panel.",
show_panel, {"title": {"type": "string"},
"blocks": {"type": "array", "items": {"type": "object"}, "maxItems": ai_panels.MAX_BLOCKS},
"task": _TASK_PROP},
@@ -451,12 +443,9 @@ TASK_TOOLS = dict([
"wait_seconds": {"type": "number", "minimum": 0, "maximum": ai_panels.MAX_WAIT, "default": ai_panels.MAX_WAIT}},
["title", "marks"]),
_task_tool("update_panel", "Change a panel",
"Adds new output at the BOTTOM of an existing panel, preserving earlier content and its order. "
"Send only the new blocks; repeated unchanged blocks are kept once. To deliberately correct an "
"existing block or update a progress bar/question in place, include its returned block id and "
"the same type. Omitted blocks are retained, not deleted. Never reuse an old block id for a new "
"result: append it or use show_panel for the next phase. Up to 30 total blocks per panel. "
"Can also change title, or set closed=true to stop answers; existing answers stay.",
"Adds blocks at the BOTTOM of a panel, keeping what the user saw: send only the new ones. A block "
"sent with its returned id and type is corrected in place (a table, a question). title changes "
"it; closed=true stops answers. read_guide(topic='panel') has the rest.",
update_panel, {"panel": {"type": "string"}, "title": {"type": "string"},
"blocks": {"type": "array", "items": {"type": "object"}, "maxItems": ai_panels.MAX_BLOCKS},
"closed": {"type": "boolean"}, "task": _TASK_PROP}, ["panel"]),
@@ -464,9 +453,9 @@ TASK_TOOLS = dict([
"Your task's latest run: none, queued (runs ahead), running (node, steps, percent), done (its "
"pictures; the first answer telling you it ended attaches up to 4) or failed; summary says it in a "
"sentence. wait_seconds (up to 55) waits for the end. Call it instead of guessing or looking in "
"folders.",
"folders. downloads=true: how the model downloads are going instead.",
run_status, {"wait_seconds": {"type": "number", "minimum": 0, "maximum": ai_task_runs.MAX_STATUS_WAIT, "default": 0},
"task": _TASK_PROP}),
"task": _TASK_PROP, "downloads": {"type": "boolean"}}),
_task_tool("show_run_map", "Show what the run really was",
"Puts the shape of your task's latest run (or `run`, a run step id) on the user's stage: the nodes it "
"really ran as boxes (models, LoRAs with weights, prompts, size, sampler settings, saving), linked, "
+2 -1
View File
@@ -168,6 +168,7 @@ def _model_brief(folder_type, path_idx, base_dir, file_path, meta):
except OSError:
size_mb = None
return {
**({"note_for_ai": meta["ai_note"]} if meta.get("ai_note") else {}), # the user's words on what it is for
"model_id": _model_id(folder_type, path_idx, base_dir, file_path),
"name": meta.get("custom_name") or meta.get("name") or os.path.basename(file_path),
"file": os.path.basename(file_path),
@@ -209,7 +210,7 @@ def search_models(query="", type="", base_model="", source="", limit=20):
if source and SOURCES.get(meta.get("info_source", ""), "not_scanned") != source:
continue
if not _matches(terms, os.path.relpath(file_path, base_dir), meta.get("name"), meta.get("custom_name"),
meta.get("baseModel"), " ".join(meta.get("trainedWords") or [])):
meta.get("baseModel"), meta.get("ai_note"), " ".join((meta.get("trainedWords") or []) + (meta.get("headerWords") or []))):
continue
total += 1
if len(found) < _limit(limit):
+6 -1
View File
@@ -19,7 +19,7 @@ except ImportError:
from .model_heads import inspect as inspect_file # the header, read once per file version
# Fields the model editor sets: <model>.anomalous.json key -> metadata key.
USER_FIELDS = {"custom_name": "custom_name", "custom_notes": "custom_notes", "source_url": "source_url"}
USER_FIELDS = {"custom_name": "custom_name", "custom_notes": "custom_notes", "source_url": "source_url", "ai_note": "ai_note"}
def _select_info_file(data, file_path):
"""Select the Civitai file entry that actually describes ``file_path``.
@@ -105,6 +105,7 @@ def _read_metadata(file_path):
"description": "",
"notes": "",
"trainedWords": [],
"headerWords": [], # no trigger words from Civitai: what the LoRA's header suggests (header_words.py), a guess
"baseModel": "",
"civitai_url": "",
"source_url": "",
@@ -114,6 +115,7 @@ def _read_metadata(file_path):
"hash_source": "",
"custom_name": "",
"custom_notes": "",
"ai_note": "", # the user's words for an AI on what it is for; a private task hiding model names gives it too
"version_name": "", # the Civitai version ("v2.0", "Illustrious"…), told apart on the card
"base_guess": "", # no base model known yet (not scanned): what the file's header tells
# "civitai" (matched), "local" (only what the file tells: no match, or offline) or "" (not scanned)
@@ -169,6 +171,9 @@ def _read_metadata(file_path):
if description: metadata["description"] = description
if notes: metadata["notes"] = notes
if trained_words: metadata["trainedWords"] = trained_words
kept_words = data.get("anomalous_header_words")
if not trained_words and isinstance(kept_words, list):
metadata["headerWords"] = [str(word) for word in kept_words if str(word).strip()]
if base_model: metadata["baseModel"] = base_model
if civitai_url: metadata["civitai_url"] = civitai_url
if model_id is not None: metadata["model_id"] = model_id
+22 -12
View File
@@ -1,7 +1,8 @@
"""What Anomalous knows about using a model well, told to the AI so it need not guess:
- the settings its author's example pictures were made with (the Civitai record a scan keeps
beside the file: images[].meta), summed up for get_model: how many pictures, the sampler and
beside the file: images[].meta), summed up for get_model and the model details
(example_settings; author_examples says it in words): how many pictures, the sampler and
scheduler most used, the range of steps and CFG, the most used size and, for a LoRA, the
weight it was used at: by its file hash, name or Civitai version, else the LoRA most of the
pictures name (their names are the author's own files, and often an older version's);
@@ -29,6 +30,7 @@ except ImportError: # loaded outside the package (tests)
FAMILIES = (
("SDXL", ("sdxl", "pony", "illustrious", "noob")), ("SD 1.x", ("sd 1",)), ("SD 2.x", ("sd 2",)),
("SD 3", ("sd 3", "sd3")), ("Flux.1", ("flux.1", "flux 1", "chroma")), ("Flux.2", ("flux.2", "flux 2")),
("Z-Image", ("z-image", "zimage", "z image")),
)
# Bases trained far from their family's: a LoRA of one on another of the family loads but may look off.
OWN_WAYS = ("pony", "illustrious", "noob", "chroma")
@@ -124,13 +126,10 @@ def _span(numbers):
return say(low) if low == high else f"{say(low)}–{say(high)}"
def _common(values, total):
"""'euler (6 of 10)': the most used value and in how many of the `total` pictures."""
def _most(values):
"""[the most used value, how many times], or None when there is none."""
counted = Counter(value for value in values if value)
if not counted:
return ""
value, times = counted.most_common(1)[0]
return str(value) if times == total else f"{value} ({times} of {total})"
return list(counted.most_common(1)[0]) if counted else None
def _loras(meta):
@@ -177,8 +176,10 @@ def _record(file_path):
return None
def author_examples(file_path, folder_type, sha256=""):
"""What the author's example pictures were made with, summed up; {} when nothing is kept."""
def example_settings(file_path, folder_type, sha256=""):
"""What the author's example pictures were made with: {"pictures": n, "sampler" / "scheduler" /
"size": [the most used, in how many], "steps" / "cfg" / "clip_skip" / "lora_weight": "25" or
"20–30"}, unknown ones left out; {} when nothing is kept."""
record = _record(file_path)
metas = [image.get("meta") for image in (record or {}).get("images") or []
if isinstance(image, dict) and isinstance(image.get("meta"), dict)][:MAX_EXAMPLES]
@@ -188,14 +189,23 @@ def author_examples(file_path, folder_type, sha256=""):
for meta in metas]
found = {
"pictures": len(metas),
"sampler": _common([str(meta.get("sampler") or "") for meta in metas if str(meta.get("sampler")).lower() != "undefined"], len(metas)),
"scheduler": _common([str(meta.get("scheduler") or meta.get("Schedule type") or "") for meta in metas], len(metas)),
"sampler": _most([str(meta.get("sampler") or "") for meta in metas if str(meta.get("sampler")).lower() != "undefined"]),
"scheduler": _most([str(meta.get("scheduler") or meta.get("Schedule type") or "") for meta in metas]),
"steps": _span([n for n in (_number(meta.get("steps")) for meta in metas) if n is not None]),
"cfg": _span([n for n in (_number(meta.get("cfgScale")) for meta in metas) if n is not None]),
"size": _common([str(size) for size in sizes], len(metas)),
"size": _most([str(size) for size in sizes]),
"clip_skip": _span([n for n in (_number(meta.get("clipSkip") or meta.get("Clip skip")) for meta in metas) if n is not None]),
}
if folder_type == "loras":
own = {(sha256 or "")[:10].lower(), os.path.splitext(os.path.basename(file_path))[0].lower(), f"v{record.get('id')}"} - {""}
found["lora_weight"] = _span(_lora_weights(metas, own))
return {key: value for key, value in found.items() if value}
def author_examples(file_path, folder_type, sha256=""):
"""example_settings in words for the AI: "euler (6 of 10)" for a value not all pictures share."""
found = example_settings(file_path, folder_type, sha256)
for key, value in found.items():
if isinstance(value, list):
found[key] = str(value[0]) if value[1] == found["pictures"] else f"{value[0]} ({value[1]} of {found['pictures']})"
return found
+101
View File
@@ -0,0 +1,101 @@
"""What using a model takes besides its file, for the model details (GET /anomalous/model_guide)
and the AI's get_model: the settings of its author's example pictures (model_advice), the words
its own header suggests when Civitai gave no trigger words (header_words.py: kept by the scan,
else read now), and, by its base model family (model_profiles.json), the files a drawing model
loaded in parts needs beside it, with the one this ComfyUI has by that name, and notes for the AI.
The page names a model by type, folder index, subfolder and file name, never by a path."""
import asyncio
import json
import os
import re
import folder_paths
from aiohttp import web
from . import model_advice
from .metadata import get_metadata
from .model_constants import MODEL_EXTENSIONS
from .path_utils import model_file_within, resolve_folder_subdir
try:
from ..header_words import of_file as header_words_of
except ImportError: # loaded outside the package (tests)
from header_words import of_file as header_words_of
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "model_profiles.json"), encoding="utf-8") as _source:
FAMILIES = json.load(_source)["families"]
PART_FOLDERS = ("diffusion_models", "unet") # a checkpoint carries its text encoders and VAE
def profile(base):
"""The family whose marks start `base` (a base model name), else None."""
lowered = str(base or "").strip().lower()
return next((family for family in FAMILIES if lowered and lowered.startswith(tuple(family["marks"]))), None)
def _plain(name):
return re.sub(r"[^a-z0-9]", "", os.path.splitext(os.path.basename(name))[0].lower())
def _found(part):
"""The first file in the part's folder with one of its names in its own, "" when none."""
try:
listed = folder_paths.get_filename_list(part["folder"])
except KeyError:
return ""
for name in listed:
plain = _plain(name)
if any(plain == wanted or (len(wanted) > 3 and wanted in plain) for wanted in part["names"]):
return name
return ""
def parts(folder_type, base):
"""[{"folder", "file", "found"}]: what a drawing model loaded in parts needs beside it; [] for others."""
family = profile(base)
if folder_type not in PART_FOLDERS or not family:
return []
return [{"folder": part["folder"], "file": part["file"], "found": _found(part)} for part in family.get("parts", [])]
def words(path, folder_type, meta):
"""What a LoRA's header suggests when Civitai gave no trigger words: kept by the scan, else read now."""
if folder_type != "loras" or meta.get("trainedWords"):
return []
return list(meta.get("headerWords") or []) or header_words_of(path)
def usage(folder_type, base):
"""For the AI: the family's notes and the files it needs here, {} when nothing is known."""
family = profile(base)
if not family:
return {}
found = {"family": family["family"]}
needed = parts(folder_type, base)
if needed:
found["needs"] = [f"{part['folder']}: {part['file']}" + (f" (here: {part['found']})" if part["found"] else " (none here by that name)")
for part in needed]
if family.get("notes"):
found["notes"] = family["notes"]
return found
def guide(path, folder_type):
meta = get_metadata(path)
base = meta.get("baseModel") or meta.get("base_guess") or ""
return {"examples": model_advice.example_settings(path, folder_type, meta.get("hash", "")),
"words": words(path, folder_type, meta), "parts": parts(folder_type, base)}
async def api_model_guide(request):
query = request.query
folder_type = query.get("type", "")
try:
_base, folder = resolve_folder_subdir(folder_type, int(query.get("path_idx", 0)), query.get("subfolder", ""))
path = model_file_within(folder, query.get("filename", ""))
except (ValueError, KeyError):
return web.json_response({"error": "bad request"}, status=400)
if not path.lower().endswith(MODEL_EXTENSIONS) or not os.path.isfile(path):
return web.json_response({"error": "not found"}, status=404)
return web.json_response(await asyncio.to_thread(guide, path, folder_type))
+3 -1
View File
@@ -221,6 +221,8 @@ async def api_update_metadata(request):
user_data["custom_notes"] = str(custom_notes)
if custom_source_url is not None:
user_data["source_url"] = str(custom_source_url).strip()
if 'ai_note' in data:
user_data["ai_note"] = str(data['ai_note']).strip()[:300]
reset_cover = data.get('reset_cover', False)
cover_reset = None
@@ -250,7 +252,7 @@ async def api_update_metadata(request):
elif os.path.exists(new_file_path) and new_file_path != file_path:
return web.json_response({"status": "error", "message": "A file with the target physical name already exists."})
sent = [key for key in ("custom_name", "custom_notes") if key in data]
sent = [key for key in ("custom_name", "custom_notes", "ai_note") if key in data]
sent += ["source_url"] if custom_source_url is not None else []
fields = [{"field": key, "before": before.get(key) or "", "after": user_data[key]}
for key in sent if user_data[key] != (before.get(key) or "")]
+255
View File
@@ -0,0 +1,255 @@
{
"about": "How models of a base model family are used, told to the AI (get_model's usage), and for a drawing model loaded in parts (a diffusion models file) the files it needs beside it, which the model details also show with the one this ComfyUI has by that name (api/model_guide.py). The first family with a mark that starts the model's base model (Civitai's, else what its header tells) is used. Notes say only what was tested here or what ComfyUI's own templates do. A part's names are looked for in its folder's file names, letters and digits only; a name of three letters or fewer must be the whole name. \"reads\": English for a family whose text encoders (CLIP, T5) read English words and tags only: a prompt in Chinese or Japanese is refused once before a run (api/prompt_language.py).",
"families": [
{
"family": "Anima",
"marks": [
"anima"
],
"parts": [
{
"folder": "text_encoders",
"file": "qwen_3_06b",
"names": [
"qwen306b"
]
},
{
"folder": "vae",
"file": "qwen_image_vae",
"names": [
"qwenimage"
]
}
],
"notes": [
"For several pictures run several times: Anima's VAE takes a batch above 1 for video frames, and a second pass is left with one picture."
]
},
{
"family": "Flux.1 Schnell",
"reads": "English",
"marks": [
"flux.1 s"
],
"parts": [
{
"folder": "text_encoders",
"file": "clip_l",
"names": [
"clipl"
]
},
{
"folder": "text_encoders",
"file": "t5xxl",
"names": [
"t5xxl",
"t5v11xxl"
]
},
{
"folder": "vae",
"file": "ae (Flux VAE)",
"names": [
"ae",
"fluxvae",
"flux1vae",
"fluxae"
]
}
],
"notes": [
"Schnell is distilled: about 4 steps at cfg 1, no FluxGuidance; at cfg 1 the negative prompt does nothing."
]
},
{
"family": "Flux.1",
"reads": "English",
"marks": [
"flux.1"
],
"parts": [
{
"folder": "text_encoders",
"file": "clip_l",
"names": [
"clipl"
]
},
{
"folder": "text_encoders",
"file": "t5xxl",
"names": [
"t5xxl",
"t5v11xxl"
]
},
{
"folder": "vae",
"file": "ae (Flux VAE)",
"names": [
"ae",
"fluxvae",
"flux1vae",
"fluxae"
]
}
],
"notes": [
"Dev and its kin (Krea, Kontext): KSampler cfg 1, the guidance in a FluxGuidance node (3.5 in ComfyUI's templates); at cfg 1 the negative prompt does nothing."
]
},
{
"family": "Chroma",
"reads": "English",
"marks": [
"chroma"
],
"parts": [
{
"folder": "text_encoders",
"file": "t5xxl",
"names": [
"t5xxl",
"t5v11xxl"
]
},
{
"folder": "vae",
"file": "ae (Flux VAE)",
"names": [
"ae",
"fluxvae",
"flux1vae",
"fluxae"
]
}
],
"notes": [
"Unlike Flux Dev, Chroma uses a real cfg (about 4) and the negative prompt works."
]
},
{
"family": "Qwen-Image",
"marks": [
"qwen"
],
"parts": [
{
"folder": "text_encoders",
"file": "qwen_2.5_vl_7b",
"names": [
"qwen25vl"
]
},
{
"folder": "vae",
"file": "qwen_image_vae",
"names": [
"qwenimage"
]
}
],
"notes": [
"ComfyUI's template puts ModelSamplingAuraFlow (shift 3.1) after the loader and uses cfg 2.5. It writes Chinese and English text into pictures."
]
},
{
"family": "Z-Image",
"marks": [
"z-image",
"zimage",
"z image"
],
"parts": [
{
"folder": "text_encoders",
"file": "qwen_3_4b",
"names": [
"qwen34b"
]
},
{
"folder": "vae",
"file": "ae (Flux VAE)",
"names": [
"ae",
"fluxvae",
"flux1vae",
"fluxae"
]
}
],
"notes": [
"The Turbo version is distilled: about 8 steps at cfg 1."
]
},
{
"family": "Lumina 2",
"marks": [
"lumina"
],
"parts": [
{
"folder": "text_encoders",
"file": "gemma_2_2b",
"names": [
"gemma22b"
]
},
{
"folder": "vae",
"file": "ae (Flux VAE)",
"names": [
"ae",
"fluxvae",
"flux1vae",
"fluxae"
]
}
]
},
{
"family": "Pony",
"reads": "English",
"marks": [
"pony"
],
"notes": [
"Prompts start with score_9, score_8_up, score_7_up; CLIP skip 2 (CLIPSetLastLayer -2) is usual."
]
},
{
"family": "Illustrious / NoobAI",
"reads": "English",
"marks": [
"illustrious",
"noob"
],
"notes": [
"Danbooru tags; Pony's score_ tags do nothing here. A v-prediction checkpoint that carries the v_pred key is switched by ComfyUI itself; only one without it needs ModelSamplingDiscrete (v_prediction)."
]
},
{
"family": "SDXL",
"reads": "English",
"marks": [
"sdxl"
],
"notes": [
"Pictures of about one megapixel (832x1216, 1024x1024); much smaller ones come out poor."
]
},
{
"family": "SD 1.5",
"reads": "English",
"marks": [
"sd 1"
],
"notes": [
"Native size about 512x768; a larger first picture repeats heads and limbs: enlarge in a second pass."
]
}
]
}
+64
View File
@@ -0,0 +1,64 @@
"""Whether a prompt is in a language the canvas's drawing model can read, before a run. A family whose text
encoders read English words and tags only ("reads": "English" in model_profiles.json: SD 1.5, SDXL, Pony,
Illustrious, Flux, Chroma) draws a Chinese or Japanese prompt as if it were nearly empty, so the picture has
nothing to do with it and is whatever the model leans to. The page (web/modules/prompt_language.js) asks with
the model files its loaders name and the prompts a run or a job sheet would send; the AI's run is then refused
once, the user's job sheet asks once. A model whose base is not known is not judged.
POST /anomalous/prompt_language {models: [file name], texts: [prompt]} -> {family, model} or {}."""
import asyncio
import os
import re
import folder_paths
from aiohttp import web
from . import model_guide
from .metadata import get_metadata
FOLDERS = ("checkpoints", "diffusion_models", "unet")
MAX_ITEMS = 40
EAST_ASIAN = re.compile(r"[぀-ヿ㐀-䶿一-鿿가-힯豈-﫿]")
LATIN = re.compile(r"[A-Za-z]")
def foreign(text):
"""Whether `text` is mostly Chinese, Japanese or Korean: two such characters or more, and at least a third of
its letters (a word in English is about five letters to a character's one)."""
east = len(EAST_ASIAN.findall(str(text or "")))
latin = len(LATIN.findall(str(text or "")))
return east >= 2 and east * 5 >= (east * 5 + latin) / 3
def _family(name):
"""The profile of drawing model file `name` (as a loader names it), when its base is known."""
for folder in FOLDERS:
try:
path = folder_paths.get_full_path(folder, name)
except Exception: # noqa: BLE001 - a folder type this ComfyUI does not have
path = None
if path and os.path.isfile(path):
meta = get_metadata(path)
return model_guide.profile(meta.get("baseModel") or meta.get("base_guess") or "")
return None
def check(models, texts):
"""{family, model} when a prompt of `texts` is in a language a model of `models` cannot read, else {}."""
if not any(foreign(text) for text in texts[:MAX_ITEMS] if isinstance(text, str)):
return {}
for name in models[:MAX_ITEMS]:
family = _family(name) if isinstance(name, str) and name else None
if family and family.get("reads") == "English":
return {"family": family["family"], "model": os.path.basename(name.replace("\\", "/"))}
return {}
async def api_check(request):
try:
body = await request.json()
except ValueError:
body = None
if not isinstance(body, dict) or not isinstance(body.get("models"), list) or not isinstance(body.get("texts"), list):
return web.json_response({"error": "Expected {models, texts}"}, status=400)
return web.json_response(await asyncio.to_thread(check, body["models"], body["texts"]))
+25 -3
View File
@@ -12,7 +12,10 @@ latent), "$save" (the output node its picture reaches), "$loader" (the sampler's
("$sampler.positive.clip") followed upstream. A node "like" a role takes its settings of the same
name; one that "replaces" a role takes its place, links and settings; one "copy_of" a path
("$sampler.model") is a copy of the canvas's model line there, loader and LoRAs (a redraw's own model). A block may have a variant for
a model family (on an Anima model an LLLite, or the Turbo LoRA for fewer steps). The directory
a model family (on an Anima model an LLLite, or the Turbo LoRA for fewer steps). Each block says what it
gives a block after it ("gives": its sampler and its output node, by key; {} for one that changes the step it
is put on) and names its own model loader when it has one ("own_model"), so blocks chain (block_chain.py); a
block going on from a step takes that step's picture from "$save.images". The directory
(directory()) is what the AI is handed when it begins a task: the blocks this ComfyUI can run
and the user has not turned off (user/anomalous/ai_blocks.json), a few words each, by job.
"""
@@ -210,6 +213,21 @@ def directory(info=None):
return out
def _runs_here(block, info):
"""Whether this ComfyUI (its node list `info`; None: not known, so yes) has the packs and models `block` needs."""
return info is None or (all(kind in info for kind in block["packs"]) and not _missing_models(block, info))
def whens(uses, info=None):
"""For a skill (ai_skills.py): a line for each block of `uses` that is on, can run here and says when it
is the one to take ("when"): add_nodes' use and want for it, then when. The ways are told from the
blocks' own files, so a skill does not list them in its words and need not change when one is added."""
off = turned_off()
asked = lambda block: f"use={block['job']!r}" + (f", want={block['want']!r}" if block.get("want") else "") # noqa: E731
return [f"{asked(block)}: {block['when']}" for name, block in BLOCKS.items()
if block["job"] in uses and block.get("when") and name not in off and _runs_here(block, info)]
def call(name):
"""The add_nodes call that builds block `name`, in the words the AI is told, or None when there is none."""
block = BLOCKS.get(name)
@@ -244,8 +262,7 @@ def pick(use, want=None, info=None, scene=False, picked=()):
wants = [BLOCKS[name]["want"] for name in names if BLOCKS[name].get("want")]
raise BlockError(f"{use} takes want: {', '.join(wants)}" + ("" if len(wants) == len(names) else ", or none") + ".")
off = turned_off()
runs = [name for name in chosen if name not in off and (info is None or (
all(kind in info for kind in BLOCKS[name]["packs"]) and not _missing_models(BLOCKS[name], info)))]
runs = [name for name in chosen if name not in off and _runs_here(BLOCKS[name], info)]
return (runs or chosen)[0]
@@ -421,6 +438,11 @@ def build(name, info, image=None, main_base=lambda: "", base_of=lambda name: "",
fills[f"${role}"] = _lora(info, prefix)
for role, prefix in (block.get("checkpoints") or {}).items(): # only optional nodes take one (SAM3 before the hand detector)
fills[f"${role}"] = _checkpoint(info, prefix)
for role, kind in (block.get("optional_lllite") or {}).items(): # likewise (a Tile holding the lines of a redraw)
try:
fills[f"${role}"] = _lllite(info, kind)
except MissingModel:
fills[f"${role}"] = None
if block.get("enlarge"):
fills["$enlarge"] = enlargement(block["enlarge"], size_of(fills["$image"]))
if block.get("upscaler"):
+1 -4
View File
@@ -3,15 +3,13 @@
"label": "{a} draws the base, {b} redraws it enlarged under Tile",
"detail": "The first settles the composition, the second adds detail and texture; a larger picture. Faces, hands and small ornaments get redrawn.",
"models": 2,
"way": ["upscale", "detail"],
"loader": ["own_1", "own"]
"way": ["upscale", "tile"]
},
"midway_enlarge": {
"label": "{a} draws the early steps, {b} finishes, enlarged 1.5x at the switch",
"detail": "The second model's look comes through more, on a larger picture; colours and ornaments may change.",
"models": 2,
"way": ["refine", "midway"],
"loader": ["second_model"],
"enlarge": true,
"only": ["anima"]
},
@@ -20,7 +18,6 @@
"detail": "The second model's look comes through more, at the same size.",
"models": 2,
"way": ["refine", "midway"],
"loader": ["second_model"],
"not": ["anima"]
},
"one_pass": {
+9 -7
View File
@@ -6,14 +6,16 @@
"en": "Privacy mode: how to work"
},
"about": {
"zh": "只给隐私任务的 AI:用 AMB 的提示词框请你写提示词、加参考图,它只拿到 [P1]、[img1] 这样的代号,原样传给跑图;图由你来看和挑。",
"en": "Only for an AI on a private task: it asks you for prompts and reference pictures with Anomalous's prompt box, gets only tokens like [P1] and [img1] to pass on to the run, and you look at the pictures and pick."
"zh": "只给隐私任务的 AI:用 AMB 的提示词框请你写提示词、加参考图。你写在“画面大意”里的话它看得到,会照着补光影、镜头和画风;私密提示词它只拿到 [P1]、[img1] 这样的代号,原样传给跑图。模型名藏起来时它拿到 [M1] 这样的代号,凭类型、底模和你写的用途说明来挑。图由你来看和挑。",
"en": "Only for an AI on a private task: it asks you for prompts and reference pictures with Anomalous's prompt box. It reads what you write as the gist and adds light, lens and style to match; of your private prompts it gets only tokens like [P1] and [img1] to pass on to the run. With model names hidden it gets tokens like [M1] and picks by type, base model and your note for it. You look at the pictures and pick."
},
"text": [
"This task is private: Anomalous sends you no pictures and no prompt text, only tokens. You still build, change and run workflows and know the models.",
"Ask the user for prompts with a prompt box: show_panel(blocks=[{\"type\": \"prompts\", \"id\": \"p\", \"question\": \"what to write\", \"count\": 1 to 8, \"negative\": true, \"pictures\": true}]); pictures lets them add reference pictures. Then get_answers (wait_seconds 55): you get prompts [\"[P1]\", …], negative \"[P2]\" and pictures [\"[img1]\"], never the text. Never ask for prompt text in the chat.",
"Write a token where its text goes and Anomalous puts the text back: run_workflow(prompts=[\"[P1]\", \"[P3]\"], negative=\"[P2]\"), set_prompt(prompt=\"[P1]\"), image=\"[img1]\". You may add words of your own around a token (\"[P1], masterpiece\").",
"Other prompt text and picture names come as tokens too (the canvas, picture info, saved prompts, the user's changes): pass them on as they are; never guess what they say.",
"You cannot see the pictures, but the user can: show them with tokens (a picks panel, items [{\"image\": \"[img4]\"}]) and ask which to keep or what to change."
"This task is private: Anomalous sends you no pictures and no prompt text, only tokens. You still build, change and run workflows.",
"Ask the user for prompts with a prompt box: show_panel(blocks=[{\"type\": \"prompts\", \"id\": \"p\", \"question\": \"what to write\", \"count\": 1 to 8, \"negative\": true, \"pictures\": true}]); pictures lets them add reference pictures. Then get_answers (wait_seconds 55): you get prompts [\"[P1]\", …], negative \"[P2]\" and pictures [\"[img1]\"], never the text, and gist when the user wrote one. Never ask for prompt text in the chat.",
"gist is the picture in words the user wrote for you to read (who, where, the shot, the mood). With a gist, write the rest of the prompt yourself to match it: light, lens, composition, style and quality words as the canvas's model reads them, around the tokens (\"[P1], rainy night, car window, side close-up, cinematic lighting\"); with a gist and no prompts, write the whole prompt from it. With no gist, pass the tokens on and add little: never guess what a token says. A gist, or a message of the user, may hold a token where they hid a few words: keep it where it stands.",
"Write a token where its text goes and Anomalous puts the text back: run_workflow(prompts=[\"[P1]\", \"[P3]\"], negative=\"[P2]\"), set_prompt(prompt=\"[P1]\"), image=\"[img1]\".",
"Other prompt text and picture names come as tokens too (the canvas, picture info, saved prompts, the user's changes): pass them on as they are.",
"Models come as tokens when the user hides their names: [M1] is one model wherever it shows (search_models, the canvas, a combo). Pass it where a model_id goes (set_model, add_lora, a plan step's model, get_model) and as a loader's value. Pick by what stays readable: type, base_model, author_weight and author_examples, loaded_by, and note_for_ai (the user's line on what it is for); its trigger words are [P] tokens: put them in the prompt. A name the user says in the chat still finds its model with search_models. Unsure which one they mean: show the candidates (a models panel, items [{\"model_id\": \"[M1]\"}]) and ask; they see the real cards. Never ask for a model's name.",
"You cannot see the pictures, but the user can: show them with tokens (a picks panel, items [{\"image\": \"[img4]\"}]) and ask which to keep or what to change. After a run they may say with a click what is off (the hands, too dark, the framing…); it comes as their message: change what causes exactly that."
]
}
+12 -8
View File
@@ -1,20 +1,24 @@
{
"short": "a picture in passes: switch models, lock and enlarge",
"short": "a picture in passes: which way, base first, model families, LoRAs",
"title": {
"zh": "分阶段出图的思路",
"en": "Drawing a picture in stages"
},
"about": {
"zh": "一张图分几步画时有哪几种路子:同一张图前后段换模型(换的时候可以顺手放大)、锁住原图再放大重画(可以顺手换成同类的第二个模型)、画完换模型接着修;什么时候必须是同一类模型,你选的画风 LoRA 要不要一起挂到第二个模型上。只说怎么做,具体数值在做法里;你存过的路线卡和搭配优先原样用,你没选的画风和 LoRA 不加。",
"en": "The ways to make one picture in passes: different models for its early and late steps (which may enlarge at the switch), a locked redraw while enlarging (which may switch to a second model of the same family), and model after model; when the models must be of one family, and whether your style LoRA goes onto the second model too. How, not values: those live in the ways. A route card or combo you saved comes first, as it is; no style or LoRA you did not pick."
"zh": "一张图分几步画时怎么拿主意:先出底图给你挑、什么时候两个模型必须是同一类、你选的画风 LoRA 要不要带到第二个模型上。具体有哪些路子、各自什么时候用,AMB 从上面能用的做法里现列给 AI,数值也在做法里。你存过的路线卡和搭配优先原样用,你没选的画风和 LoRA 不加。",
"en": "The judgment for a picture made in passes: the base shown first for you to pick, when two models must be of one family, whether your style LoRA goes onto the second model too. Which ways there are and when each fits, Anomalous lists for the AI from the ways above that can run here; the values live in them. A route card or combo you saved comes first, as it is; no style or LoRA you did not pick."
},
"ways": [
"refine",
"upscale"
],
"text": [
"Use when the user wants a picture made in passes, or one model for the composition and another for the look (style, skin, texture); not a default for every picture.",
"Use when the user wants a picture made in passes, or one model for the composition and another for the look (style, skin, texture), or one for the character and another for the scenery; not a default for every picture.",
"A route the user saved is the daily run: a workflow card (list_workflows; \"name / preset\" when it has presets) or a combo (list_combos), with their subject and seed; its models, LoRAs and values are theirs, unchanged. Another route is another card. Never add a style, artist or LoRA the user did not pick.",
"Show the first pass before the second, so the user can pick the base: run_workflow with base_only=true (and batch for a few to choose from) runs that pass alone as previews; Anomalous shows them to the user, keeps the one picked and holds its seed, so the next run_workflow runs the whole workflow on that base. Keep the picked base to compare. Without base_only, hold the base's seed yourself (its sampler's control_after_generate \"fixed\", set before that first run): a seed that moves after each run makes the next run redraw a different base under the second pass.",
"Ways to stage, by what the user wants. Switch models within one picture: the early steps settle the composition on one model, the later steps finish it on another (add_nodes use=\"refine\", want=\"midway\"); the switch may also enlarge the picture, so the later steps draw its detail at the larger size in place of an upscale pass after it (the way's next step says how; the second model may then be the same one). Enlarge with a lock: a guide holding the picture (Tile) lets the redraw go much stronger, adding detail while the composition holds; without one a strong redraw moves the pose and ornaments. The locked redraw (use=\"upscale\", want=\"detail\") has its own copy of the model, the base's by default; swap it for a second model of the same family for a steadier enlargement or better skin and texture: one redraw, no extra refine pass after it. Model after model: one finishes, the next lightly redraws the whole picture (use=\"refine\"); a chain of several is fine.",
"Families: a switch that reuses the first stage's prompts and VAE needs a second model of the same family (sharing the text encoder and VAE): the mid-way switch, the second-model upscale, and the refine on a model split into UNET, text encoder and VAE files (Anima). The refine with a checkpoint of its own (its own CLIP and VAE, on the decoded picture) may cross families: rewrite its positive and negative prompts in the new family's way. A combination not tried here: say it is untried.",
"The second model is loaded bare. When the first stage has a style LoRA the user picked and they want that look kept, borrowing only the second model's skin or detail, add_lora it onto the second model too; leave it off only when they want the look changed. Never add one they did not pick.",
"Show the first pass before the second, so the user can pick the base: run_workflow with base_only=true (and batch for a few to choose from) runs that pass alone as previews; Anomalous keeps the one picked and holds its seed, so the next run_workflow runs the whole workflow on that base. Without base_only, set the base sampler's control_after_generate to \"fixed\" before that first run, or the second pass lands on another base.",
"Which way: \"ways\" lists those this ComfyUI can run now, as add_nodes takes them, each with when to take it. Two ideas are behind them. A lock: a guide holding the picture (Tile) lets a redraw go much stronger, adding detail or changing the look while the composition holds; without one a strong redraw moves the pose and ornaments. The whole picture first: a pose and what it rests on are drawn together on one model, then another redraws all or a part of it; a character drawn apart and pasted onto a scene does not sit on its bench.",
"Families: a step that reuses the first stage's prompts and VAE needs a second model of the same family: the mid-way switch, the second-model upscale, and a refine on a model split into UNET, text encoder and VAE files (Anima). A refine with a checkpoint of its own may cross families: rewrite its prompts in the new family's way. Anima's Tile fits its 2B models only (Anomalous refuses a run where it does not). A combination not tried here: say it is untried.",
"The second model is loaded bare. When the first stage has a style LoRA the user picked and they want that look kept, borrowing only the second model's skin or detail, add_lora it onto the second model too; leave it off only when they want the look changed.",
"These ways come from a few single-seed comparisons. A strong redraw changes faces, hands and small ornaments even with a lock: say what ran and what changed; promise no exact reproduction and no repaired hands."
]
}
+32 -32
View File
@@ -1,6 +1,7 @@
"""Prompt translation (POST /anomalous/translate): DeepL when the user saved a key in Settings
-> Translation, else Google's free endpoint, else MyMemory. When DeepL was asked and failed, the
answer says why (`deepl_error`) while another provider still translates."""
-> Translation, else Google's free endpoint, else MyMemory (translate_chain, also the model details'
description_translation.py). When DeepL was asked and failed, the answer says why (`deepl_error`) while
another provider still translates."""
import asyncio
import json
@@ -112,6 +113,33 @@ def _translate_with_mymemory(text, tl):
raise RuntimeError("MyMemory returned no translation")
def translate_chain(text, tl):
"""`text` in language `tl` by the first provider that answers: DeepL with a saved key, Google, MyMemory.
(translated, engine, deepl_error); raises the last provider's error when none does. Blocking: call in a thread."""
deepl_key = _deepl_key()
deepl_error = None
if deepl_key:
try:
translated = _translate_with_deepl(text, tl, deepl_key)
if translated:
return translated, "deepl", None
except Exception as e: # noqa: BLE001 - the free providers are asked instead, and the user told why
deepl_error = _deepl_error(e)
print(f"[Anomalous] DeepL translate failed: {deepl_error}")
try:
translated = _translate_with_google(text, tl)
if translated:
return translated, "google", deepl_error
except Exception as e: # noqa: BLE001 - MyMemory is tried next
print(f"[Anomalous] Google translate failed: {e}")
try:
translated = _translate_with_mymemory(text, tl)
except Exception as e:
print(f"[Anomalous] MyMemory translate failed: {e}")
raise
return translated, "mymemory", deepl_error
async def api_translate(request):
try:
data = await request.json()
@@ -124,37 +152,9 @@ async def api_translate(request):
tl = str(data.get("target_lang", "zh-CN")).strip()
if not text:
return web.json_response({"translated": "", "status": "success"})
# 1. DeepL, when a key is saved
deepl_key = _deepl_key()
deepl_error = None
if deepl_key:
try:
translated = await asyncio.to_thread(_translate_with_deepl, text, tl, deepl_key)
if translated:
return web.json_response({"translated": translated, "status": "success", "engine": "deepl"})
except Exception as e:
deepl_error = _deepl_error(e)
print(f"[Anomalous] DeepL translate failed: {deepl_error}")
translated, engine, deepl_error = await asyncio.to_thread(translate_chain, text, tl)
why = {"deepl_error": deepl_error} if deepl_error else {}
# 2. Try Google Translate (client=dict-chrome-ex)
try:
translated = await asyncio.to_thread(_translate_with_google, text, tl)
if translated:
return web.json_response({"translated": translated, "status": "success", "engine": "google", **why})
except Exception as e:
print(f"[Anomalous] Google translate failed: {e}")
# 3. Fallback to MyMemory
try:
translated = await asyncio.to_thread(_translate_with_mymemory, text, tl)
if translated:
return web.json_response({"translated": translated, "status": "success", "engine": "mymemory", **why})
except Exception as e:
print(f"[Anomalous] MyMemory translate failed: {e}")
raise e
return web.json_response({"translated": translated, "status": "success", "engine": engine, **why})
except Exception as e:
print(f"[Anomalous] All translation providers failed: {e}")
return web.json_response({"translated": text, "error": str(e), "status": "error"})
+155
View File
@@ -0,0 +1,155 @@
"""Covers of the voice characters (the Voices page's cards, web/modules/voice_covers.js): a picture the user picks
for a character, from the gallery or the computer, cropped square and made small by the page and checked and
made again here (no metadata kept). One per character's name: its versions ("角色/日配", "角色/中配") share it.
Kept in user/anomalous/voice_covers, a PNG per name by its hash and index.json naming them, never in
Anomalous_TTS's character folders. A cover taken away goes to the Recycle Bin (trash.py).
GET /anomalous/voice_covers -> {covers: {name: stamp}}; GET /anomalous/voice_cover?name= -> the PNG;
POST /anomalous/voice_cover {name, image: data URL} -> {stamp}; POST /anomalous/voice_cover/remove {name}."""
import asyncio
import base64
import hashlib
import io
import json
import os
import threading
import time
from aiohttp import web
from PIL import Image
from . import ai_tasks
from .path_utils import atomic_write_json
SIZE = 256
MAX_BYTES = 8 * 1024 * 1024
MAX_NAME = 200
INDEX = "index.json"
_lock = threading.Lock()
class CoverError(ValueError):
pass
def _folder():
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "voice_covers")
def base_name(character):
"""The character's name without its version ("角色/日配" -> "角色")."""
name = str(character or "").strip()
return name.rsplit("/", 1)[0].strip() if "/" in name else name
def _file(name):
return os.path.join(_folder(), hashlib.sha1(name.encode("utf-8")).hexdigest()[:20] + ".png")
def _index():
try:
with open(os.path.join(_folder(), INDEX), encoding="utf-8") as source:
data = json.load(source)
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
def covers():
"""{name: stamp} of the covers there are; a stamp changes with the picture."""
return {name: stamp for name, stamp in _index().items() if os.path.isfile(_file(name))}
def _checked(name):
name = base_name(name)
if not name or len(name) > MAX_NAME:
raise CoverError("name is a character's name")
return name
def set_cover(character, data_url):
"""Keeps the picture in `data_url` as `character`'s cover: a square, SIZE across, made again as a PNG."""
name = _checked(character)
if not isinstance(data_url, str) or "," not in data_url or not data_url.startswith("data:image/"):
raise CoverError("image is a picture as a data URL")
try:
raw = base64.b64decode(data_url.split(",", 1)[1], validate=True)
except (ValueError, TypeError) as error:
raise CoverError("The picture could not be read") from error
if len(raw) > MAX_BYTES:
raise CoverError("The picture is too big")
try:
with Image.open(io.BytesIO(raw)) as source:
source.load()
picture = source.convert("RGBA" if "A" in source.getbands() else "RGB")
except Exception as error: # noqa: BLE001 - whatever it is, it is no picture to keep
raise CoverError("The picture could not be read") from error
side = min(picture.size)
left, top = (picture.width - side) // 2, (picture.height - side) // 2
picture = picture.crop((left, top, left + side, top + side)).resize((SIZE, SIZE), Image.LANCZOS)
os.makedirs(_folder(), exist_ok=True)
stamp = str(int(time.time() * 1000))
with _lock:
target = _file(name)
temp = f"{target}.tmp"
picture.save(temp, "PNG")
os.replace(temp, target)
index = _index()
index[name] = stamp
atomic_write_json(os.path.join(_folder(), INDEX), index)
return {"name": name, "stamp": stamp}
def remove_cover(character):
name = _checked(character)
from .trash import move_to_trash
with _lock:
if os.path.isfile(_file(name)):
move_to_trash(_file(name))
index = _index()
index.pop(name, None)
if os.path.isdir(_folder()):
atomic_write_json(os.path.join(_folder(), INDEX), index)
return {"name": name}
async def _body(request):
try:
body = await request.json()
except ValueError:
body = None
if not isinstance(body, dict):
raise CoverError("Expected a JSON object")
return body
async def api_list(request):
return web.json_response({"covers": await asyncio.to_thread(covers)})
async def api_get(request):
name = base_name(request.query.get("name", ""))
path = _file(name) if name else ""
if not name or not os.path.isfile(path):
return web.json_response({"error": "No cover"}, status=404)
return web.FileResponse(path, headers={"Cache-Control": "no-cache"})
async def api_set(request):
try:
body = await _body(request)
return web.json_response(await asyncio.to_thread(set_cover, body.get("name"), body.get("image")))
except CoverError as error:
return web.json_response({"error": str(error)}, status=400)
async def api_remove(request):
try:
body = await _body(request)
return web.json_response(await asyncio.to_thread(remove_cover, body.get("name")))
except CoverError as error:
return web.json_response({"error": str(error)}, status=400)
except Exception as error: # noqa: BLE001 - no Recycle Bin here (trash.py): it stays, and the page says why
return web.json_response({"error": str(error)[:300]}, status=500)
+42 -5
View File
@@ -263,18 +263,40 @@ the record keeps the tokens), and every tools/call result passes `ai_privacy.gua
the run's pictures, the user's messages and the outside check were added: for a private task
(ai_privacy.py) image content items are removed and the answer says why, then `ai_sealed.mask`
turns prompt text and picture names into tokens (a node's STRING inputs by ComfyUI's node list,
prompt fields, picture file names, and any text already sealed, also in the text content).
prompt fields, the names and tags the user gave their combos, workflow cards and saved prompts
(`NAMED`), picture file names, and any text already sealed, also in the text content).
While `ai_privacy.hides_models()` (user/anomalous/ai_privacy.json `models`, true unless the user set false),
`ai_sealed_models.hide` runs between the two: every form of a library model (the materials catalog's
list, read at most every 15 s) becomes that model's "[M#]" token, sealed as its model_id; a
model's own entry (a dict with `model_id`) drops name, file, folder, links and hash; a model file
the library does not have is a token of its own. A card name of one plain ASCII word is replaced
only where a name stands (`NAME_KEYS`), never inside a sentence. `expand` writes a model token as
the model_id, as the loader's name under a node's values (`WIDGET_KEYS`), and as the card's name
inside other words.
A "prompts" panel block is the user's prompt box: answered only through the answer route with
`seal_answer`, never as plain text (the card in the AI app leaves it out); show_panel stores the
`seal_answer`, never as plain text (the card in the AI app leaves it out); its `gist` is the one
field that reaches the AI as written, and may be the whole answer; show_panel stores the
prompt_pose skill's words for the canvas's model on its step (`prompt_advice`). Privacy is per task (`task["privacy"]`, else the
Gates default; turning the default off pins the tasks it covered); a call with no task is
private while the default is on or a private task is unfinished. The card's `app_task_view`
gives a private task no pictures. `tests/test_ai_privacy.py` calls every tool with a picture
in its result and checks none leaves; `tests/test_ai_sealed.py` does the same with canary
prompts and picture names.
prompts, picture names and model names.
What left is kept for the user: after `guard`, a private task's call goes to `ai_sent.keep` with the
arguments as the AI wrote them (tokens) and the answer's text as it left
(`user/anomalous/ai_sent/<task>/calls.jsonl`, the newest 300 calls, a text cut at 20,000 characters,
removed with the task; the card's own calls are not kept, they do not reach the AI).
`GET /anomalous/ai_tasks/{id}/sent` reads it. The boundary of all this is the MCP channel: an AI
app that reads the disk can open the output folder and the task records (their `sealed` holds the
texts) by itself; `ai_callers.reads_disk(client)` names the known ones and `api_get` passes it to the
page as `reads_disk`.
Skills (`api/ai_skills.py`, one data file each in `api/skills/`) are advice an agent fetches
for a kind of job, beside the blocks that build nodes. begin_task lists the skills on (name and
for a kind of job, beside the blocks that build nodes. A skill holds judgment only: which blocks
there are and when each fits comes from the blocks' own files ("when"), listed with the skill's
answer for the uses it names ("ways") and only those that can run here, so adding a block changes
no skill; an answer over `mcp_fit.BUDGET` would be filed away unread by an AI app, and a test keeps
every skill under it. begin_task lists the skills on (name and
a line), `get_toolbox(skill=…)` returns one, and a skill the user turned off in the Ways panel
(`user/anomalous/ai_skills.json`) is neither listed nor given. A skill's `by_model` advice comes in the
form for the canvas's main model (the page's `block_model`): `model_advice.main_params` counts its
@@ -323,7 +345,10 @@ The Gates' custom-node rule (`ai_tools.json` `nodes`, `ai_gates.node_rule`): wit
picks block the user left to the AI (`no_answer` "recommended") takes the item it marked
recommended, as a choice does (`ai_answer_wait.take_recommended`). The user's word on a finished
run (`POST /anomalous/ai_tasks/{id}/runs/{step}/verdict`, `ai_run_control.set_verdict`) stays on
the run's step and reaches the AI as a `user_verdict` step; nothing else keeps it.
the run's step and reaches the AI as a `user_verdict` step; nothing else keeps it. With "bad" go
`issues` (keys of `ai_run_control.ISSUES`: hands, face, body, composition, dark, bright, blurry, style,
subject, extra), said in words in the step's text; while the AI has not fetched the step, a changed
word corrects it in place.
`ai_answer_wait.collect` is shared by `get_answers` and question tools: after the bounded wait,
typing/deadline checks, automatic choices and a final nonblocking collection share the task
@@ -571,6 +596,18 @@ cache. The key includes the model's real path and the physical `size`, `mtime_ns
and `ctime_ns` signatures of the model and relevant sidecars. Return independent
copies; never expose cached mutable dictionaries or lists.
A LoRA Civitai gives no trigger words for gets the words its own header suggests
(`header_words.py`): the scanner keeps them in the `.info` it writes as
`anomalous_header_words`, and drops them when Civitai's record brings `trainedWords`;
metadata gives them as `headerWords`, never inside `trainedWords`, and model search (the
page's and the AI's) finds them. Listing reads no header for them. `GET
/anomalous/model_guide` (`api/model_guide.py`), asked when the model details open, reads the
header of a LoRA scanned before the words were kept, and returns the author's example settings
and, for a diffusion models / unet file of a family in `api/model_profiles.json`, the text
encoder and VAE it needs with the file of that name found here (by name only, never a claim
that it is the right file). The model is named by type, folder index, subfolder and file name;
being a read, other devices may ask too.
Folder listing inventories a directory once with `os.scandir()` and preserves
preview priority: `.preview.*` before bare same-stem media. Avoid an `exists()`
sequence for every candidate when the directory inventory already has the
+5 -1
View File
@@ -100,7 +100,11 @@ The main surfaces are:
- Sidebar shell: navigation and persistent scan controls; focused child modules
own the scan page, folder manager, and help dialog.
- Grid and model detail: model browsing plus coordinated detail display in
`ui_detail.js` (its head with the actions in `model_detail_head.js`), metadata editing in
`ui_detail.js` (its head with the actions in `model_detail_head.js`; above the description
`ui_model_guide.js`: Civitai's trigger words, else the words the LoRA's file suggests, dashed
as a guess, its author's example settings and, for a model loaded in parts, the text encoder
and VAE it needs with the file of that name found here, from `GET /anomalous/model_guide`
once per page, a newer page cancelling it), metadata editing in
`ui_model_editor.js`; `model_widget_path.js` puts a
model path into a loader's widget. The scan page's model picker is `ui_scan_picker.js`, its settings card `ui_scan_settings.js`; opened afresh, the page shows the last counts at once (`?quick=1`) with "showing the last counts while they are updated", then the fresh ones; before any, a count slower than 400 ms shows "counting your models" instead of a blank page. "Found on Civitai" opens a list of those models with a box to find one by name (`/anomalous/scan_matched`, read when it opens).
- Gallery: generated outputs (pictures and videos; All / Pictures / Videos beside the search
+63
View File
@@ -140,3 +140,66 @@ AMB 给 AI 软件的工具,以轻量模型(Gemini Flash)能顺畅用为准
第一次考第 3 题乱的原因:测试用的浏览器里开着两个 ComfyUI 页面,AI 的动作被两个页面各抢一半,一个页面开了任务的标签页,另一个去跑时找不到。连做“卡片出图 → 用这张图重跑”,两个页面开着时 8 次失败 5 次,修好后 6 次全过(一个页面时也全过)。修法:任务的动作先交给上次干活的页面(`api/mcp_bridge.py`),它不在了 3 秒后别的页面接手。顺带:图里的工作流带着生成它的那个标签页的编号,打开时换一个新编号;标签页的标记在加载之前写进工作流。
看到但没改:Gemini 还是会空着参数先试一次工具、发带着“方案1/方案2”占位选项的 `test` 问题(现在也拦下了,不放到用户面前);第 2 题它在换模型之前先跑了一张原图。
## 2026-11-09 1.64 第一步:说明书按需查(read_guide)之后(Antigravity 命令行开的对话,Gemini 3.8 Flash,隔离测试环境,风景题)
改了什么:新工具 `read_guide`,按主题给 AMB 自己的说明书(README 里 `<!-- guide:名字 -->` 标出来的各节,中英两种;模型检查的完整规则;展示面板每种块的写法)。展示面板的工具说明只列块的名字,写法挪进说明书;`download_status` 并进了 `run_status(downloads=true)`。工具清单从 43,957 字减到 42,574 字,还是 53 个工具。
| 题 | 调用 | 失败 | 结果 |
|---|---|---|---|
| 新题:“我是新手,AMB 里的「画布存档」是怎么用的?一步一步教我,用界面上的字” | 7 | 0 | 先查目录,再查“存档”一节(中文),照界面上的字讲了存、锁种跑、点 1/2/3 回去、存档页、收藏为工作流 |
| 新题:“在图库里找三张山湖风景图,做一个面板让我挑一张,告诉我那张的种子” | 14 | 1 | 查了 `panel` 一节,第一次就做出合格的挑图面板;种子答对。唯一的失败是猜了一个不存在的主题名 `answers` |
| 3 问你再改(风景版:图库里最新那张山湖图) | 18 | 2 | 想了两个改法用 `ask_user` 问,照选的(第二个)改提示词出图。失败:先在空画布上跑(提示后改用 `open_workflow` 打开那张图),一次 `set_values` 漏了参数 |
合计 39 次调用,失败 8%,绕路 0(1.63 标准考试是 16%)。看到后改了:主题名猜错时,回答里直接列出全部主题名,不用再查一次目录。
## 2026-11-09 1.64 蓝图(Antigravity 命令行开的对话,Gemini 3.8 Flash,隔离测试环境)
题目:“画一张樱花回廊下回头的少女:先用 MiaoMiao Harem 1.5 出图,再用 Kirazuri v4 锁图放大精修,最后修一下脸和手”。蓝图卡出来时替用户点「照这个搭」,三张小样时挑第 2 张。
| 次 | 调用 | 失败 | 看到什么,改了什么 |
|---|---|---|---|
| 1 | 32 | 7 | 考场没清:画布上留着验证时搭的三个采样器,Flash 以为做了一半,在上面接着加。作废 |
| 2 | 43 | 8 | 空画布:没用蓝图,打开官方示例“Anima 两段·Tile”,再手工加细化,空参数试探多。改:接任务时的做法目录和空画布提示里写明 `add_nodes(plan=[…])`,“几个采样器”的报错提示“接在最后一个” |
| 3 | 40 | — | 先试了几次 route,后来交了蓝图(模型写名字、“修手”对到只修指定图片的 fix_hands);考场脚本没认出卡,没人答,Flash 等 55 秒后自己做了。改:模型写名字也认(只对上一个时);只修指定图片的做法在蓝图里换成能接上一步的那块;route 缺模型时的报错直接给蓝图写法 |
| 4 | — | — | 第 11 次调用就交了蓝图(先交一张只有出图的试探卡,再交真正的三步卡);照搭后它直接跑整条,没出小样。三步在显卡上跑通(底图 → Tile → 脸手细化) |
| 5 | — | — | 蓝图搭好后的第一次出图改成了三张小样(AMB 强制),但 Flash 没等挑就跑整条;跑到脸手细化时 ComfyUI 整个崩了:细化接用了 Tile 那步挂着 LLLite 的模型。改:只有一步的蓝图拒收;没挑小样就跑整条拦一次;接模型时跳过尺寸绑定的补丁 |
| 6 | 27 | 4 | 交蓝图(三步)→ 照搭 → 自动三张小样 → 挑第 2 张 → 用户说“继续” → 从挑中的底图接着跑完 Tile 和脸手细化,没崩。失败都是空参数先试一次 |
AMB 不能叫醒已经结束这一轮的 AI:小样出来后 Flash 按规矩结束了一轮,用户挑完要在 AI 软件里说一句“继续”。
## 2026-11-09 1.64 任务单(Antigravity 命令行开的对话,Gemini 3.8 Flash,隔离测试环境)
两道题都从空画布开始,都用 AMB 的官方示例“文生图”。
| 题 | 调用 | 失败 | 看到什么,改了什么 |
|---|---|---|---|
| 1 “提示词写雪地里的红色谷仓,cfg 分别用 4 和 7,各跑 2 张,同一组种子,每张之间歇 3 秒” | 15 | 1 | 读了 4 次手册后用了 `sheet`:两行(cfg 4 / cfg 7)各 2 张、同种子、歇 3 秒,一次排对。失败的那次是 `prompt` 和 `sheet` 一起给。改:带 `sheet` 时 `prompt` 算作没写提示词的行的提示词 |
| 2 “下面 5 条提示词每条跑 2 张,每张之间歇 2 秒” | 69 | 5 | 没发现 `sheet`:把 `wait_seconds=2` 当成间隔,五次 run_workflow,中间 50 多次 run_status。查手册时把工具名 run_workflow 当题目。改:run_workflow 的标题改成“Run the workflow, or a job sheet”(接任务时的目录里看得到),描述加一句,`wait_seconds` 写明不是间隔;手册里任务单单独一题 job_sheet;把工具名当题目查时提示去看工具说明 |
| 2 重考 | 9 | 0 | 一次 `run_workflow(workflow, sheet={rows: 5 条, count: 2, pause: 2})`,10 张、0 失败;告诉用户在跑,然后结束这一轮 |
另外看到:两道题的中文提示词都被原样写进了 SDXL 模型,这类模型读不懂中文,出的图和提示词无关。任务单本身没出错;要不要在跑之前检查“提示词的语言和模型合不合”,等用户定。
## 2026-11-09 1.64 画布「交给 AI…」(同上)
题目:用户在画布「exam canvas A」(官方示例“文生图”,512 px、12 步、英文风景提示词)上点「交给 AI…」→「照这张画布出 4 张,挑最好的给我看」→ 开新任务;Flash 开始前,前台换成另一个空白标签页。
| 调用 | 失败 | 看到什么 |
|---|---|---|
| 17 | 3 | begin_task 带着画布 → describe_canvas 和两次 run_workflow 都用的是交过来的画布,不是前台的空白页;一次 4 张,用 zoom_image 逐张比过后 report 挑了一张,说明了理由。失败都是空参数或乱填参数的试探(set_values {}、run_workflow workflow=123 / test=1);第一次 run_workflow {} 照画布跑了 1 张 |
同一天的语言检查:中文提示词交给只读英文的模型时,AI 的单次运行和任务单都先被拦一次(实测),任务单卡片点「开始」先提醒一次。
## 2026-11-10 1.64 做法面板瘦身、skill 只留判断、Tile 适配检查之后(Antigravity 命令行开的对话,Gemini 3.8 Flash,隔离测试环境,风景和猫)
这一轮只考两件事:skill 里不再写每条路子、改由 AMB 列出“这里能用的做法 + 什么时候用”以后,Flash 还选不选得对;Tile 配上不适配的 2.9B 模型时,被拦下后它怎么办。
| 题 | 调用 | 失败 | 看到什么,改了什么 |
|---|---|---|---|
| 1:“窗台上晒太阳的橘猫,背景是故意虚化的街景:先用 Kirazuri v4 出图,再放大一遍,放大时不要把虚化的背景画清楚” | 34 | 2 | 一上来就取了 skill,蓝图第二步选的正是 `use='upscale', want='character'`(背景虚化只重画主体)。但「挑一张底图」里有两张:一张底图,一张是后面那步的黑白遮罩预览;替用户盲挑到遮罩,成品是废图,Flash 没看出来,照样报告完成。改:底图只认直接解码出来的那张,遮罩预览算后面的(`base_only.js`) |
| 1 重考 | 21 | 5 | 同样取 skill、同样选对做法;小样里只有底图了。挑中后放大段 58 秒,背景的虚化原样保住,只重画了猫。但它先 `run_workflow({})` 空着提示词试跑,白画了三张没有内容的小样,“先出三张给你挑”也被用掉,之后只出了一张。改:从零开始画、提示词全空时,AI 的运行先拦一次(实测拦住) |
| 2:“雪地里的红色谷仓:先用 Kirazuri v4 出图,再用 Nya-Iris Anima 2.9B 做 Tile 锁图放大” | 29 | 6 | 蓝图:出图 + `want='tile'`(模型 Nya-Iris 2.9B)。运行被拦(40 块对 28 块),Flash 读懂了原因,查模型后把放大那格换成 2B 的 Kirazuri v4,记了决策、在留言里向用户说明,然后三张小样 → 挑一张 → Tile 放大 102 秒出图,没有报错。它是自己换的,没有先问。改:拦下时的话里加一句“模型是用户点名的就先 ask_user” (这句话改完后没有再考) |
三次合计 84 次调用,失败 15%,绕路 0。失败几乎都是空参数先试一次(`add_nodes {}`、`set_prompt {}`、`set_values {}`)。
没解决、留着的:成品明显是废图时(第 1 题第一次),Flash 用 `zoom_image` 看过仍然报告完成。看图判断不归这一轮管。
+59 -4
View File
@@ -46,6 +46,10 @@ node tools/ui_map.mjs build 写出 docs/ui-map.md:按页面列
| 「撤销画布上的改动」 | 按操作记录把 AI 在画布上的改动一步步撤销(`canvas_undo.js`) | 无 |
| ⋯ 里的「改名」 / 「从列表移除」 | 移除只删这条记录,图、画布、工作流都不动 | `POST /anomalous/ai_tasks/{task_id}` |
| ⋯ 里的「打开隐私模式」 | 这个任务的图不再发给 AI;开着时标题栏多一个「隐私模式」,点它关掉(`ui_ai_privacy.js`) | `POST /anomalous/ai_tasks/{task_id}`(privacy) |
| 提示词框“画面大意”旁和隐私任务留言框里的「藏」 | 先选中几个字再点:这几个字 AI 只拿到代号,其余照常看(`ai_hide_mark.js`) | 随回答或留言一起交出,`ai_sealed.seal_marked` |
| ⋯ 里的「发给 AI 了什么」 | 隐私任务才有:列出 AI 每次调用发来的参数和 AMB 回给它的文字,能搜一个词看它出没出去过(`ui_ai_sent.js`) | `GET /anomalous/ai_tasks/{task_id}/sent` |
| 标题栏下的黄色提醒「隐私模式只管 AMB 发给 AI 的内容…」 | 隐私任务的 AI 软件能自己读硬盘时出现(`ui_ai_privacy.js` 的 `privacyReach`) | 任务记录里的 `reads_disk` |
| ⋯ 里的「模型代号对照」 | 隐私任务把模型名藏起来后才有:列出 AI 说的 [M1]、[M2] 各是哪个模型(`ui_ai_privacy.js` 的 `modelTokens`) | 无(任务记录里的 `sealed`) |
| ⋯ 里的「接着哪个任务…」 | 把这个任务接在另一个后面,两边都显示链接(`ui_ai_task_links.js`) | `POST /anomalous/ai_tasks/{task_id}`(from) |
AMB 更新以后,AI 软件还拿着旧的工具列表来调用时,标题栏下面出现一行黄字「AI 软件拿的还是旧的工具列表,会多走弯路。请重新连接:Antigravity 重启一下,或在它的 MCP 设置里把 anomalous 关掉再打开;然后开个新对话。」,AI 那边也会收到同样的提醒(`mcp_directory.stale`,按每个 AI 软件上次取的列表比对)。AI 软件重新取工具列表(`tools/list`)后这行自动消失。
@@ -54,7 +58,11 @@ AMB 更新以后,AI 软件还拿着旧的工具列表来调用时,标题栏
标题栏固定两行:名字和工具,下面是任务信息和前后任务的链接。不常用的都收在 ⋯ 里(`ui_ai_task_menu.js`)。侧栏打开、中间变窄时,状态和「切到副本」只留图标,名字放不下就省略,不把工具挤到下一行。
时间线上面一行:这个任务走过的阶段(点了跳到那一步)和这个任务出了什么。
时间线上面一行:这个任务走过的阶段(点了跳到那一步)和这个任务出了什么。AI 排了任务单时,跑图那一格写“任务单 6/10”。
AI 交过蓝图时,下面多一行:开头一个小标签(「蓝图」;还在等你点头时是「蓝图 · 等你点头」,你要它改时是「蓝图 · 你要改」,点了跳到蓝图卡),后面是蓝图的每一步:第一步、「挑一张小样」、后面几步,带着模型名。做完的点亮,正在做的闪,等你的黄框,还没做的是虚线框(悬停写「还没做」),没挑小样就跑了整条时「挑一张小样」划掉(「跳过了」)。点一步跳到正在做它的那次运行。这一行全靠 AMB 从蓝图卡和之后的运行里看出来,AI 不用多做什么;问不问你都一样显示。
为什么:用户要随时知道 AI 走到哪、后面还要干什么。只多一行,不另开地方;AI 没交蓝图就没有这一行,AMB 不替 AI 猜它的打算。
### 中间:等 AI 接手的任务(`ui_ai_ready.js`)
@@ -77,11 +85,17 @@ AMB 更新以后,AI 软件还拿着旧的工具列表来调用时,标题栏
为什么:看到一个框,人就以为要填;选项和下面的按钮排在一起,人就以为要先选再确认。写到一半的话被清掉、被切走,或者人走开了被替你选掉,都比多等一会儿糟。
### 蓝图卡(`ui_ai_plan.js`)
AI 想用几块现成做法一步步画一张图时,先在展台上放一张「蓝图」卡:上面写着「AI 打算这样画。每一步都是现成的做法,线由 AMB 来连。」,下面按顺序列出每一步(做法的名字、用哪个模型)。第一步以外每步右边有 ×(「去掉这一步」),有模型的步骤可以从同一类模型里换一个(「换模型」);最下面一行虚线写着「想加一步:在下面留言告诉 AI」。勾上「以后直接搭,不用问我」,以后的蓝图直接搭好。按「照这个搭」AMB 在新标签页一次搭好(「搭在新标签页,你的画布不动」);按「我想改一下」写一句话交给 AI,什么都不搭。直接搭好的卡上写「按你的设置直接搭好了」,旁边的「以后先问我」把设置改回来。
为什么:AI 自己一块块连线容易乱,小模型更是;先看到整条路线、能删能换,再一次搭好,你心里有数,AI 也少走弯路。
### 中间:时间线
- 从上往下读,编号 1、2、3……;AI 连着查资料、连着改画布的几步折成一行(`ui_ai_chain.js`),点开看每一步。
- 每一步下面写它做了什么(`ui_ai_step_body.js`),出的东西能直接去:图片进看图器、模型进模型页、画布改动能定位和撤销(`ui_ai_results.js`)。
- 跑完的图下面是「这次效果:」「效果好」「不太行」(`ui_ai_verdict.js`),点了就告诉 AI,再点一下收回。
- 跑完的图下面是「这次效果:」「效果好」「不太行」(`ui_ai_verdict.js`),点了就告诉 AI,再点一下收回。点了「不太行」,下面多一排问题按钮(「手坏了」「脸不对」「构图偏了」「太暗」「太亮」「画风不对」等十个),点哪个告诉 AI 哪个,可以点几个。
- 图片上鼠标移上去有工具(`ui_ai_images.js`):「图片与参数」、「保存为…」、勾两张对比,还有 ✎ 标注。
- 时间线和展台里的图先用缩略图(`/anomalous/output_thumbnail`),显示得比缩略图大时才换原图(`/anomalous/output_preview`:超大图换的是 4096 像素的预览),打开任务不用等大图。
@@ -113,7 +127,7 @@ AMB 更新以后,AI 软件还拿着旧的工具列表来调用时,标题栏
| 「决策」 | 你在面板上的选择、允许和拒绝、AI 记下的关键决定 | `ui_ai_decisions.js` |
| 「素材」 | 「给 AI 的素材」:挑模型、节点、搭配、工作流、提示词交给 AI;「来源」列表里鼠标所在的行会亮,选中的打勾。模型和模型页一样:按类型分、有基础模型的分类条,同一模型的几个版本是一张卡,版本单独写,点它换版本,已选的跟着换 | `ui_ai_toolbox.js`、`ui_ai_facet_menu.js` |
| 「成果」 | 这个任务出的图、工作流、搭配、提示词、音频,按步骤排 | `ui_ai_outputs.js` |
| 「做法」 | AMB 现成的做法和 skill。右边的开关全局关掉一个,AI 就看不到;能用的做法上有「这次用它」,只对这个任务:AI 会收到,这一类活 AMB 直接按它搭,AI 要别的也一样(`POST /anomalous/ai_tasks/{task_id}/way`) | `ui_ai_blocks.js` |
| 「做法」 | AMB 现成的做法和 skill。先列这里能用的做法,每块一行:名字、一句话、「这次用它」(只对这个任务:AI 会收到,这一类活 AMB 直接按它搭,AI 要别的也一样,`POST /anomalous/ai_tasks/{task_id}/way`)和右边的开关(全局关掉,AI 就看不到);点名字前的小三角展开全文、来源、测过的耗时和推荐的模型。接着是 skill。缺节点包或模型的做法收在最下面「还差东西的…」里,默认折起,每块写着缺什么,节点包能点进 Manager | `ui_ai_blocks.js` |
## 存档(`ui_snapshot_bar.js`)
@@ -127,6 +141,31 @@ AMB 更新以后,AI 软件还拿着旧的工具列表来调用时,标题栏
- 「看图」:打开下面的存档页,停在这个画布那一块。
- 运行前逐个存档让 ComfyUI 检查,有问题一次都不排。跑的是副本,画布和上面的种子都不动。
## 画布上的「交给 AI…」(`ui_ai_canvas_hand.js`)
右键画布空白处或一个节点,AMB 菜单里的「交给 AI…」。打开一个小窗:上面写交的是哪张画布(“把画布‘名字’交给 AI。挑一个,或者自己写:”)或哪个节点,下面几个现成的说法,点一下填进下面的框,框里可以改:
- 画布:「照这张画布出 4 张,挑最好的给我看」「看看这张画布哪里有问题(缺模型、连错线、报错),帮我修好」「讲讲这张画布每一步在做什么,哪些参数值得调」「在这张画布后面加上放大和修脸修手」。
- 节点:「讲讲这个节点是做什么的,参数怎么调」「帮我调这个节点的参数,跑几张对比」。节点上有存下来的图时多一个按钮,把这些图发给 AI,和图库里一样。
- AI 正在做一个任务时,底部有两个按钮:“发给‘任务名’”(作为留言发过去,提示“它要是已经停下了,在 AI 软件里说一句“继续””)和「开新任务」;没有时只有「交给 AI」,建一个关于这张画布的任务,再给出要发给 AI 的那句话。新任务的 AI 从这张画布开始:读、跑、复制副本都用它,你后来切到别的标签也一样。
- 在这个页面跑出错时,右下角提示“运行出错了:……”,旁边「让 AI 看看」,点了就是这个小窗,框里写好了报错。
为什么:AMB 不能主动叫醒 AI,所以按钮做的是“把要说的话和画布准备好”,省掉自己描述画布、复制报错这一步。
## 任务单(`ui_job_sheet.js`)
右键节点或画布空白处「任务单(批量换提示词)」打开,在画布右下角,ComfyUI 的画布按钮上面。用同一张画布连着跑很多次:观众要的“一次贴几十条提示词,每条跑几次”和“运行间隔”都是它。
- 填:一行一条提示词,填进正向提示词框;一条也不写,就照画布原样跑(只想要运行间隔时这样用)。提示词要写好几行时勾「一条提示词要写好几行:条与条之间空一行」。「每条跑几次」「每张之间歇几秒」「每条用同一组种子(方便对比)」。下面一行算好共几张;最多 200 条、1000 张。填的内容记在这个浏览器里,下次打开还在。
- 「开始」:先让 ComfyUI 检查(第一条和带设置的几条),有问题一张都不排,写明第几条什么问题。画布的模型只读得懂英文(SD 1.5、SDXL、Pony、Illustrious、Flux 这类)而提示词是中文、日文或韩文时,先提醒一次:出的图会和提示词无关;再点一次「开始」就照样跑。开始时记下这张画布,跑的都是副本,之后改画布、切标签都不影响它,画布上的提示词和种子也不动。
- 跑的时候:进度条、已跑几张、出了几张图、正在跑第几条第几次(或「歇一下:N 秒后跑下一张」),最新的 4 张小图。「暂停」已经排进队列的这一两张跑完就停;「继续」接着跑;「停止」把排着的撤掉,正在跑的这张也中断。⌄ 收成一个小把手。
- 有一张出错或被中断,任务单自己暂停,写明原因,改好画布以外的问题(比如显存)再「继续」。
- 页面要开着。刷新同一个标签页会接着跑;跑它的页面关了,别的页面打开时会看到「在这个页面接着跑」。
- 跑完或停下后:「改一改再跑」把这些提示词放回填写框,用画布现在的样子再跑;「在图库里看」;「关掉」。
- AI 也能排任务单(`run_workflow(sheet=…)`):卡片上写「AI 排的」,工作台里那一步就是它的进度和图,工作台的停止按钮也能停它。
为什么:存档适合几套不同的工作流各跑几次,几十条提示词要存几十个存档(最多 50 个),所以单独做一张单子。一次只排一两张进 ComfyUI 的队列,暂停、停止随时有效,ComfyUI 的队列里也不会堆几百个。
## 存档页(`ui_saves_list.js`)
「工作流」页顶上的「存档」。所有存档按画布分块,画布关了、文件删了的也在;每块能折叠,默认只展开当前画布。
@@ -141,7 +180,7 @@ AMB 更新以后,AI 软件还拿着旧的工具列表来调用时,标题栏
## 工作流页的「官方示例」(`ui_recipe_examples.js`)
工作流页第一张卡片是「官方示例」,放最后的话,卡片一多就找不到了。点开是一个窗口,按方案分组(文生图、两段放大、图生图、局部重绘),方案下面是各个版本,每个版本有备注和「新画布打开」。两段放大有三个版本:SDXL / SD 1.5、Anima、Anima · Tile 锁图(「推荐」)。SDXL 和 Anima 必须分开,模型的加载节点不一样。打开时后端按版本填上你已有的模型和文件(`GET /anomalous/example_workflows?id=…`)。缺节点包或模型的版本照样列出来,写明缺什么,节点包能点进 Manager,这一条没有打开按钮。搜索或筛选时这张卡片不显示。
工作流页第一张卡片是「官方示例」,放最后的话,卡片一多就找不到了。点开是一个窗口,按方案分组(文生图、两段放大、图生图、局部重绘),方案下面是各个版本,每个版本有备注和「新画布打开」。两段放大有四个版本:SDXL / SD 1.5、Anima、Anima · Tile 锁图(「推荐」)、Anima · 背景虚化,只重画人物。SDXL 和 Anima 必须分开,模型的加载节点不一样。打开时后端按版本填上你已有的模型和文件(`GET /anomalous/example_workflows?id=…`)。缺节点包或模型的版本照样列出来,写明缺什么,节点包能点进 Manager,这一条没有打开按钮。整个模型家族都要注意的事只在这个家族第一个版本上面说一次(Anima:「下面是 Anima 的版本。要多张图就多跑几次…」),不在每个版本里重复。带 Tile 的版本打开时选的是 Tile 适配的模型(`lllite_fit.py` 读两个文件的文件头比块数),没有适配的才用第一个。搜索或筛选时这张卡片不显示。
AI 这边:没有合适的已存工作流时,`list_workflows` 给它这里能跑的版本,每条写着适合什么模型,打开和运行都和已存工作流一样;有合适的时也列出示例的名字,免得 AI 不知道。用中文(图生图、两段放大)也搜得到。用户存过的工作流优先,也不会被同名示例盖掉。
@@ -151,6 +190,22 @@ AI 这边:没有合适的已存工作流时,`list_workflows` 给它这里能
换模型时选一个。插入 LoRA 时可以选多个:点卡片加入,再点一下取消,卡片左上角的数字就是接上去的顺序。装了 rgthree 或 Easy-Use 时底部能选用哪种节点(官方的串成一串,第三方的放进一个节点),记住上次的选择。放好后新节点都是选中的,可以一起拖走,落在视野外时画布会移过去;Ctrl+Z 一次全部退回。第三方节点放上去后自己还会变一下高度,所以窗口多留一小会儿,等它们稳定再排位置、记撤销。
## 模型详情的开头(`model_detail_head.js`)
像一页人物档案:返回的小字,下面大字写模型的名字;名字后面带着 C 站版本的(“名字 - v2”,或者扫描改名后的“名字_v2”)拆开,版本放在第二行灰色小字。再下面一行蓝色小字:类型 · 大小 · 底模,C 站的模型后面是 Civitai ↗ 链接(不再另写“来源 Civitai”)。然后一排按钮,只有「插入节点」是实心的,其余是文字按钮。靠字号和留白分层次,不加框、不加阴影,看久了不累。
## 模型详情:触发词和用法(`ui_model_guide.js`)
开头下面是一张细线隔开的清单,每行左边名字、右边内容。有 C 站的训练词就是「训练词」一行;没有的 LoRA 是「常用词」一行(虚线圆角,点一下复制,下面小字说是从文件里读的推测)。每个词点一下复制,「复制」复制全部。扫描时拿到作者例图的,是「作者常用」一行:采样器 · 调度器 · 步数 · CFG · 尺寸,LoRA 还有权重,后面写几张例图。放在 diffusion_models / unet 里、要分开加载的模型(Anima、Flux 等)每个要的文件一行(「文本编码器」「VAE」),写有没有这个名字的文件,只按名字找,不保证就是对的那个。最后是「版本」(C 站上的全部版本,点开在这一行下面列出来)和「文件名」(大字的名字不是文件名时才有)。都来自 `/anomalous/model_guide`;给 AI 的注意事项不在这里显示。清单下面是「作者的介绍」:C 站上作者写的说明,只露开头几行,下面一个蓝色的「展开全文」(展开后是「收起」),鼠标移到折着的正文上底色会变亮,点标题、正文或「展开全文」都能展开;你展开过,下次打开别的模型也是展开的,收起也一样(只记在这个浏览器里)。介绍是英文而界面是中文时(英文界面遇到中文、日文也一样),旁边有「翻译」:一段一段翻成界面的语言,翻完显示「看原文」可以切回去,旁边小字写是谁翻的(DeepL、Google 或 MyMemory)。翻过的存在 AMB 自己的文件夹里,下次直接显示,不再联网,也不往模型文件夹里写东西;翻不出来时写明原因,原文不动。
## 角色语音的角色卡(`ui_audio_studio.js`、`voice_covers.js`)
每张卡像一页小档案:左边是封面,没设封面时是名字的第一个字;右边大字写角色名,版本(日配、中配)在下面一行灰色小字,再下面一行蓝色小字写模型版本、几条声音、“拖标题栏 → GPT-SoVITS 节点”。按钮在名字下面,是没有边框的文字按钮。下面一条条声音用细线隔开:播放、情绪、台词、「复制」标签。卡片是平的,不发光。
点封面(悬停写「换封面」)打开一个小窗:上面「从电脑选一张」「去掉封面」(有封面时才有),下面是图库,点一张就换上。图自动裁成方形,竖着的图保留上面一截。同一个角色的几个版本共用一张封面。封面存在 AMB 自己的文件夹里,不碰 Anomalous_TTS 的角色文件夹;去掉的封面进回收站。
为什么:角色一多,全是同一个图标,只能读名字找;有了封面一眼认出是谁,也和模型卡片一致。
## 设计上的固定做法
- **从上往下读**:时间线、展台都是旧的在上、新的在下;看最新的时候自动跟着往下,往上翻着看时不打断,只出现“去最新”的按钮。
+16 -13
View File
@@ -114,11 +114,13 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
| scan_models | 扫描新模型、重新查找没匹配上的,或扫描指定的模型(会联网访问 Civitai) |
| download_missing_models | 下载当前工作流缺的模型:只下能确定来源的(C 站按指纹找到、或工作流带着文件链接),放在设置里“模型下载”说的位置,核对指纹后自动换上 |
| download_model | 按链接(C 站模型版本、Hugging Face / GitHub 文件)或 C 站指纹下载一个模型到指定类型的文件夹,不覆盖已有文件 |
| download_status | 看下载进度和结果 |
| run_status | 看这次运行到哪了;`downloads=true` 看模型下载的进度和结果 |
| add_nodes 的蓝图(plan) | AI 用几块现成做法一步步画一张图时,先把步骤交给 AMB:AMB 在工作台放一张「蓝图」卡给你看(每步的做法和模型),你可以去掉步骤、给某一步换同类模型,点「照这个搭」后在新标签页一次搭好;有第二步时 AI 接着先出三张小样让你挑。勾「以后直接搭,不用问我」就不再问 |
| read_guide | AMB 自己的说明书,一次一节:各个页面和功能怎么用(AI 照着用屏幕上的字教你,中文界面就用中文的字)、模型检查的完整规则、展示面板每种块的写法。不带主题时列出目录,长的分几段给。内容就是 README 和规则文档里的那几节,不另写一份 |
| move_model | 把放错文件夹的模型挪到它该在的文件夹(封面、备注跟着走),和模型页“整理”里的“挪过去”一样;不会盖掉同名文件。默认先问你 |
| list_voices / speak | 列出 Anomalous TTS 的角色;让某个角色读一段台词并保存(最多等两分钟) |
| begin_task / report | 在 AI 工作台里起一个任务的标题;给你留言、交上它用别的方式做出来的图、结束任务(见第 5 节) |
| run_workflow 的填空 | 日常出图一步完成:带上 `workflow`(存好的工作流名,或者图库里一张图的文件名,就用那张图自带的工作流;也可以写“工作流名 / 预设名”,用你在工作流卡里存的某个预设,AMB 先打开工作流,再像点“应用到当前画布”一样套上那个预设),第一次在任务的副本标签页里打开它,之后接着用这个标签页;`prompt` / `negative` 填正、反向提示词,`seed` 填所有采样器的种子,`width` / `height` 填空白潜空间的尺寸,`image` 填“加载图片”读的图(输出或输入文件夹里的,比如标注后的重绘图;有几个“加载图片”时用 `image_node` 指定);其他任何参数用 `values` 按名字改,比如 `{"steps": 30}`,第三方节点的参数也一样:只有一个节点有这个参数就直接改,几个节点都有(比如高清修复的两个采样器都有 `denoise`)就要写节点号 `{"12": {"denoise": 0.5}}`,否则什么都不改并列出是哪几个。几个正向提示词框接进同一个采样器(分区提示词)时,`prompt` 也会停下来,让 AI 用 `values` 分别写。其余照存好的样子不动。不带 `workflow` 时填到当前画布上。要一次跑好几条提示词,用 `prompts`(最多 8 条,代替 `prompt`):AMB 按顺序一条条填进去排队,别的填空(比如同一个种子)每条都一样,全部算同一次跑图,图都在时间线的同一步里;跑完画布上留的是最后一条。哪一条排不上就停在那里,前面排上的照常跑完,AI 会知道停在第几条。填空和出图一起算一步,改画布那部分也能 Ctrl+Z;画布关卡比显卡关卡严时,按画布关卡问你 |
| run_workflow 的填空 | 日常出图一步完成:带上 `workflow`(存好的工作流名,或者图库里一张图的文件名,就用那张图自带的工作流;也可以写“工作流名 / 预设名”,用你在工作流卡里存的某个预设,AMB 先打开工作流,再像点“应用到当前画布”一样套上那个预设),第一次在任务的副本标签页里打开它,之后接着用这个标签页;`prompt` / `negative` 填正、反向提示词,`seed` 填所有采样器的种子,`width` / `height` 填空白潜空间的尺寸,`image` 填“加载图片”读的图(输出或输入文件夹里的,比如标注后的重绘图;有几个“加载图片”时用 `image_node` 指定);其他任何参数用 `values` 按名字改,比如 `{"steps": 30}`,第三方节点的参数也一样:只有一个节点有这个参数就直接改,几个节点都有(比如高清修复的两个采样器都有 `denoise`)就要写节点号 `{"12": {"denoise": 0.5}}`,否则什么都不改并列出是哪几个。几个正向提示词框接进同一个采样器(分区提示词)时,`prompt` 也会停下来,让 AI 用 `values` 分别写。其余照存好的样子不动。不带 `workflow` 时填到当前画布上。要一次跑好几条提示词,用 `prompts`(最多 8 条,代替 `prompt`):AMB 按顺序一条条填进去排队,别的填空(比如同一个种子)每条都一样,全部算同一次跑图,图都在时间线的同一步里;跑完画布上留的是最后一条。哪一条排不上就停在那里,前面排上的照常跑完,AI 会知道停在第几条。更多的(几十条提示词、几套参数各跑几张、每张之间要歇一会儿)用 `sheet`:`{rows: [提示词,或 {prompt, negative, values, count}], count: 每行几次, pause: 每张之间歇几秒, same_seeds: 每行用同一组种子}`,交给画布上的「任务单」一张张跑(最多 200 行、1000 张),画布不动;这一步就是它的进度和图,`run_status` 看到哪了,`stop_run` 或工作台的停止按钮停下它。填空和出图一起算一步,改画布那部分也能 Ctrl+Z;画布关卡比显卡关卡严时,按画布关卡问你 |
| 工具目录 | 不是一个工具:AI 开始任务时(还没有任务时调现状一览也一样),回复里附带一张按“想做的事”排好的工具目录(出图、画布、模型、提示词、图片……每个工具一行标题),AI 看一眼就直接去用对的工具,不用到处试。目录从 AMB 的工具登记表自动生成,只列当前给 AI 的工具,加了新工具会自己出现 |
| get_situation | 一次拿到任务、流程链、少量节点摘要、缺失输入和可匹配输出、错误及最近运行。每个回复的 `next` 提示下一步。新开对话或拿不准时先调它;简报预算约 3600 字,减少 Antigravity 将大回复转存为文件的情况 |
| log_outside | AI 用了 AMB 以外的工具(命令、脚本、读写文件)后,说清做了什么、为什么、动了哪些文件;显示在时间线上(见第 5 节) |
@@ -149,21 +151,23 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
**让 AI 局部重绘**:标注里选 **▧ 涂重绘区**,用笔刷把要重画的地方涂上(笔刷粗细可调;框、圈、画笔圈出的地方也算),右边 **怎么改** 选 **只改标出的地方**(用笔刷时会自动选上)再发给 AI。另外两种是 **交给 AI 判断**(AI 看说明自己定)和 **整张重新出图**(不做局部重绘);你选的会作为你的要求写在 AI 收到的第一句,时间线里那条标注也写着“改法:…”。AMB 会在 ComfyUI 的 input 文件夹里放一张 `anomalous_inpaint_….png`:原图加上你涂的区域做遮罩。AI 收到时会被告诉怎么用它(LoadImage 读进来就带遮罩,用原图的模型和提示词,重绘强度 0.5~0.8 保留原样子,1.0 完全重画),不确定时用选择题问你强度,画完用前后对比给你看。这张文件删任务时不会删,因为你的工作流可能还在用它。
**参考做法**:有几件常见的活,AMB 带着现成的做法,是从擅长这些活的工作流里提炼出来的:**出图**(空画布起步,用你给任务挑的模型或 AI 指定的模型搭好出图节点;Anima 的文本编码器和 VAE 由 AMB 找)、**局部重绘**(对 AI 只算一项,AMB 按你装的节点包挑:**裁切缝合**把圈出的地方裁出来放大到 1024 重画再缝回去,需要节点包 ComfyUI-Inpaint-CropAndStitch;**LanPaint** 融合最好、任何模型都行,但慢好几倍,要最好的效果时用,需要节点包 LanPaint;都没装就用**官方节点**)、**放大**(1.25 倍再轻轻重画一遍,只用自带节点,另存一张,文件名以 upscaled 开头)、**分块放大**(2 倍,需要节点包 ComfyUI_UltimateSDUpscale)、**照着姿势画**和**照着构图画**(你递给 AI 一张图,AMB 读出姿势或深度,挑一个和底模对得上的控制网,需要节点包 comfyui_controlnet_aux)、**Tile 锁图放大**(放大时用 Tile 钉住画面重画得更狠;放大那一段有自己的一份模型,标题 “Upscale model”,默认和底图同一个、LoRA 一起带上,换第二个模型只要换这一格;Anima 上用 Anima 的 Tile LLLite)、**背景移除**(需要节点包 comfyui-inspyrenet-rembg)、**读图出标签**(需要节点包 ComfyUI-WD14-Tagger,AI 跑完会收到读出的标签)、**部位细化(脸和手)**(需要 Impact Pack、Impact Subpack 和 models/ultralytics/bbox 里的检测模型,细化后的图另存一张,文件名以 detailed 开头)、**修手·先放大再修**(你递给 AI 一张图:整张放大到约 400 万像素轻轻重画,原图越小放得越大,最多 2 倍,让手变大,再逐只修手,另存一张,文件名以 hands_fixed 开头;Anima 模型上有 Anima Turbo LoRA 时只跑 10 步,1280×832 的图在 6GB 显卡上约 100 秒。AI 一次出 4 张,自己先放大看手,把手指数不对、粘连、大小不对的去掉,都不行就再出 4 张,最多两轮,再把好的放进只放大手那块的面板让你挑)。AI 工作台右边的**做法**面板列着全部做法:缺节点包或模型的变灰并写明缺什么,每块有个开关,关掉的 AI 就看不到。AI 开工时只收到开着又能用的那些;它说用哪块,AMB 就把那几个节点搭好、接进画布上那个出图的工作流里(采样器、VAE 解码、保存图片都由 AMB 按插口认出来),一次就好,也是一步 Ctrl+Z。画布上有好几个采样器时,AI 得指明用哪一个;缺节点包时,它会知道缺哪个、该用哪块代替。
**参考做法**:有几件常见的活,AMB 带着现成的做法,是从擅长这些活的工作流里提炼出来的:**出图**(空画布起步,用你给任务挑的模型或 AI 指定的模型搭好出图节点;Anima 的文本编码器和 VAE 由 AMB 找)、**局部重绘**(对 AI 只算一项,AMB 按你装的节点包挑:**裁切缝合**把圈出的地方裁出来放大到 1024 重画再缝回去,需要节点包 ComfyUI-Inpaint-CropAndStitch;**LanPaint** 融合最好、任何模型都行,但慢好几倍,要最好的效果时用,需要节点包 LanPaint;都没装就用**官方节点**)、**放大**(1.25 倍再轻轻重画一遍,只用自带节点,另存一张,文件名以 upscaled 开头)、**分块放大**(2 倍,需要节点包 ComfyUI_UltimateSDUpscale)、**照着姿势画**和**照着构图画**(你递给 AI 一张图,AMB 读出姿势或深度,挑一个和底模对得上的控制网,需要节点包 comfyui_controlnet_aux)、**Tile 锁图放大**(放大时用 Tile 钉住画面重画得更狠;放大那一段有自己的一份模型,标题 “Upscale model”,默认和底图同一个、LoRA 一起带上,换第二个模型只要换这一格;Anima 上用 Anima 的 Tile LLLite)、**放大·只重画人物**(背景本来就虚化的图用:整张用放大模型拉大 1.5 倍,背景不再动,只裁出人物重画再缝回去,虚化保住;需要节点包 comfyui-inspyrenet-rembg 和 ComfyUI-Inpaint-CropAndStitch)、**背景移除**(需要节点包 comfyui-inspyrenet-rembg)、**读图出标签**(需要节点包 ComfyUI-WD14-Tagger,AI 跑完会收到读出的标签)、**部位细化(脸和手)**(需要 Impact Pack、Impact Subpack 和 models/ultralytics/bbox 里的检测模型,细化后的图另存一张,文件名以 detailed 开头)、**修手·先放大再修**(你递给 AI 一张图:整张放大到约 400 万像素轻轻重画,原图越小放得越大,最多 2 倍,让手变大,再逐只修手,另存一张,文件名以 hands_fixed 开头;Anima 模型上有 Anima Turbo LoRA 时只跑 10 步,1280×832 的图在 6GB 显卡上约 100 秒。AI 一次出 4 张,自己先放大看手,把手指数不对、粘连、大小不对的去掉,都不行就再出 4 张,最多两轮,再把好的放进只放大手那块的面板让你挑)。AI 工作台右边的**做法**面板列着全部做法:缺节点包或模型的变灰并写明缺什么,每块有个开关,关掉的 AI 就看不到。AI 开工时只收到开着又能用的那些;它说用哪块,AMB 就把那几个节点搭好、接进画布上那个出图的工作流里(采样器、VAE 解码、保存图片都由 AMB 按插口认出来),一次就好,也是一步 Ctrl+Z。画布上有好几个采样器时,AI 得指明用哪一个;缺节点包时,它会知道缺哪个、该用哪块代替。
**自己选做法**:在侧栏的 **做法** 里点某个做法的 **这次用它**,只对当前任务生效。AI 会收到你的选择,这一类活 AMB 直接按你选的搭,AI 想用同类的别的做法也一样。再点一下取消。
**官方示例**:工作流页第一张卡片。文生图、两段放大、图生图、局部重绘,按方案分组,打开时选好一个你已有的模型。两段放大有 SDXL / SD 1.5、Anima 和推荐的 Anima · Tile 锁图三个版本;缺节点包或模型的版本会写明缺什么。AI 没有合适的已存工作流时也会用它们。
**官方示例**:工作流页第一张卡片。文生图、两段放大、图生图、局部重绘,按方案分组,打开时选好一个你已有的模型。两段放大有 SDXL / SD 1.5、Anima、推荐的 Anima · Tile 锁图,和背景虚化的图用的 Anima · 背景虚化,只重画人物四个版本;缺节点包或模型的版本会写明缺什么。AI 没有合适的已存工作流时也会用它们。
**路线**:你给任务挑了两个主模型时,AI 用 `ask_user(routes=true)` 问你怎么分工,选项由 AMB 准备(你存过同时用这两个模型的卡排第一),你选好后 `add_nodes(route=…)` 在新标签页里搭好整条路线。AI 不用现成的卡和做法、自己放节点时算**新路线**:默认提醒一次:还没验证过,跑完看看效果,好的话存成卡;关卡里选“先问我”,每个任务先等你点头。
**skill**:和做法并列,是 AI 遇到某类活时按需读取的建议;做法负责自动搭节点。AI 开工时只看到每个 skill 的名字和一句话,真要做那类活时才用 `get_toolbox(skill=名字)` 取全文,不增加工具数量。**写提示词·动作适合模型**按画布主模型的大小给动作建议,用户指定的姿势和提示词照原样;**分阶段出图的思路**只讲一张图分几步画的路子:同一张图前后段换模型(`add_nodes(use="refine", want="midway")`)、锁住原图放大重画(`want="detail"`,可以在这一步顺手把放大用的模型换成同类的第二个)、画完换模型接着修;什么时候必须是同一类模型,你选的画风 LoRA 要不要一起挂到第二个模型上。你把路线存成过工作流卡或搭配,AI 就直接用你的;具体模型和数值在做法和你的搭配里,不写进 skill。重画力度大时脸、手和小细节还是会变,不保证复刻原图。做法面板最下面列着 skill,每个有开关,关掉的 AI 就拿不到。AI 每取一个 skill,时间线上多一行 **Skill**;之后那次跑图下面也写着参照了哪个。隐私任务只提供隐私 skill,不提供普通 skill。
**skill**:和做法并列,是 AI 遇到某类活时按需读取的建议;做法负责自动搭节点。AI 开工时只看到每个 skill 的名字和一句话,真要做那类活时才用 `get_toolbox(skill=名字)` 取全文,不增加工具数量。**写提示词·动作适合模型**按画布主模型的大小给动作建议,用户指定的姿势和提示词照原样;**分阶段出图的思路**只讲怎么拿主意:先出底图给你挑、什么时候两个模型必须是同一类、你选的画风 LoRA 要不要一起挂到第二个模型上;具体有哪些路子(半途换模型 `want="midway"`、Tile 锁图放大 `want="tile"`、只改人物或场景、背景虚化只重画人物……)不写在 skill 里,AMB 把这里装得上、开着的做法连同“什么时候用”一起列给 AI,所以加一块做法不用改 skill。你把路线存成过工作流卡或搭配,AI 就直接用你的;具体模型和数值在做法和你的搭配里,不写进 skill。重画力度大时脸、手和小细节还是会变,不保证复刻原图。做法面板最下面列着 skill,每个有开关,关掉的 AI 就拿不到。AI 每取一个 skill,时间线上多一行 **Skill**;之后那次跑图下面也写着参照了哪个。隐私任务只提供隐私 skill,不提供普通 skill。
**先对一下重画的范围**:圈得不准也没关系。AI 准备只重画标出的地方时,会先在展台把 **你圈的** 和 **AI 调整后** 的范围并排给你看(红色是要重画的地方;AI 可以挪位置、改成方框或椭圆、往外扩、把边缘做软,图下写着外扩和羽化了多少像素)。点 **用 AI 调整的**、**用我圈的** 或 **我重新圈**,也可以在下面写一句(比如“再往手腕多圈一点”),AI 会照着再调一次;点 **让 AI 定** 就用 AI 调整的。选好以后 AMB 才把带这个范围的 `anomalous_inpaint_….png` 放进 input 文件夹,节点怎么搭由 AI 自己来。
**AI 改副本**(默认):AI 第一次改画布前,AMB 先把你正开着的工作流复制到一个以任务命名的新标签页(副本),之后 AI 的修改、读画布和运行都只在那一页;你切去别的标签页也没关系,它下次动手前 AMB 会先切回去。你原来的工作流一笔不动。AI 用 `new_workflow` / `open_workflow` 打开的标签页就直接算这个任务的。任务标题栏有 **AI 改副本 ▾**(点开可以换成 **改当前画布**,每种都写着是什么意思)和 **切到副本**(点一下切过去);**关卡** 窗口里的“AI 改画布时”设定新任务的默认做法。
**隐私模式**:有些图和提示词不方便给 AI 看时,点任务标题栏的 **🔒**,这个任务就成了隐私任务。AMB 一张图都不发给 AI(放大看图、图片信息里的图、跑完附带的预览、你递给它的图、AI 软件里任务卡片上的图都一样),它只知道出了几张、成没成功。提示词原文和图片文件名换成 **[P1]**、**[img1]** 这样的代号:画布上节点的文字输入(按 ComfyUI 自己的节点定义认,第三方节点也一样;模型名、数字、选项照常给)、图片信息、提示词库、搭配、工作流的设置、模型的触发词和介绍、你自己改画布的记录都一样,同一段文字永远是同一个代号。AI 用代号去写提示词、跑图、填加载图片、放挑图面板,AMB 在工具执行前换回原文,所以画布和出图都是原样,你看到的也都是原样;时间线上记的是代号。要新提示词时,AI 会放一个 **提示词框**:写几条提示词(可以再加)、反向提示词,可以从图库选或把图拖进来当参考图,点 **交给 AI(只给代号)**;原文只存在 AMB 里,交出后框里显示代号和你写的原文。框上方按画布主模型的大小提醒你(来自 skill“写提示词·动作适合模型”)。AI 怎么在隐私任务里干活写在 skill“隐私模式·怎么干活”里,开工时会被指过去。隐私任务里 AI 只拿得到这一个 skill,做法面板的 skill 一栏也只显示它;普通任务正好反过来,只显示、只给普通的 skill。**关卡** 窗口里的“隐私模式”设新任务默认开不开;默认改成关时,原来按默认开着的任务仍然保持隐私。AI 的某次调用认不出属于哪个任务时,只要默认开着或者还有没做完的隐私任务,就按隐私处理。注意:任务做到一半才打开时,之前已经给过 AI 的收不回来;你自己在留言或 AI 聊天框里写的文字和贴的图,AMB 不拦(那是你主动给的);模型名字 AI 是看得到的。
**隐私模式防的是什么**:它过滤的是 **AMB 发给 AI 的内容**,只会调用工具的聊天软件(比如 Cherry Studio 接 DeepSeek)因此什么都看不到。但 Antigravity、Cursor、Claude Code、Gemini CLI 这类软件能自己读硬盘、跑命令:出图文件夹里的图、AMB 的任务记录(代号对应的原文就存在里面)它都能直接打开,AMB 拦不住。用这类软件开隐私任务时,任务标题栏下面会有一行提醒。想亲眼确认发出去了什么:任务标题栏 **⋯ → 发给 AI 了什么**,里面是隐私模式开着时 AI 每一次调用发来的参数和 AMB 回给它的文字,一字不差;在上面的框里搜一个词(某个模型名、提示词里的词),就知道它有没有出去过。只留最近 300 次调用,任务移除时一起删掉。
**隐私模式**:有些图和提示词不方便给 AI 看时,点任务标题栏的 **🔒**,这个任务就成了隐私任务。AMB 一张图都不发给 AI(放大看图、图片信息里的图、跑完附带的预览、你递给它的图、AI 软件里任务卡片上的图都一样),它只知道出了几张、成没成功。提示词原文和图片文件名换成 **[P1]**、**[img1]** 这样的代号:画布上节点的文字输入(按 ComfyUI 自己的节点定义认,第三方节点也一样;模型名、数字、选项照常给)、图片信息、提示词库、搭配、工作流的设置,连同你给它们起的名字和标签(搭配名、工作流卡片名、提示词库的条目名;没改过名的搭配,名字就是模型名加提示词开头)、模型的触发词和介绍、你自己改画布的记录都一样,同一段文字永远是同一个代号。AI 用代号去写提示词、跑图、填加载图片、放挑图面板,AMB 在工具执行前换回原文,所以画布和出图都是原样,你看到的也都是原样;时间线上记的是代号。要新提示词时,AI 会放一个 **提示词框**:写几条提示词(可以再加)、反向提示词,可以从图库选或把图拖进来当参考图,点 **交给 AI(提示词只给代号)**;原文只存在 AMB 里,交出后框里显示代号和你写的原文。提示词框最上面还有一格 **画面大意**:这一格是原样给 AI 看的,用不介意让它看到的话说说想要什么(谁、在哪、什么镜头、什么气氛),它就能照着补光影、镜头和画风,再把你的私密提示词以代号嵌进去;两格可以只填一格。大意里个别不想让它看到的词,选中后点 **藏**:这几个字变成 ⟦这样⟧,交出去时 AI 只拿到代号,其余的它照常看、照常改。隐私任务的留言框里也有这个 **藏**。框上方按画布主模型的大小提醒你(来自 skill“写提示词·动作适合模型”)。AI 怎么在隐私任务里干活写在 skill“隐私模式·怎么干活”里,开工时会被指过去。隐私任务里 AI 只拿得到这一个 skill,做法面板的 skill 一栏也只显示它;普通任务正好反过来,只显示、只给普通的 skill。**关卡** 窗口里的“隐私模式”设新任务默认开不开;默认改成关时,原来按默认开着的任务仍然保持隐私。AI 的某次调用认不出属于哪个任务时,只要默认开着或者还有没做完的隐私任务,就按隐私处理。注意:任务做到一半才打开时,之前已经给过 AI 的收不回来;你自己在留言或 AI 聊天框里写的文字和贴的图,AMB 不拦(那是你主动给的)。**模型名也藏起来**:隐私任务里,模型和 LoRA 的文件名、卡片上的名字、所在文件夹、C 站链接和指纹都不发给 AI,一个模型不管出现在哪(搜索结果、画布的加载器、搭配、报错)都是同一个 **[M1]** 这样的代号;AI 把代号填到要用模型的地方,AMB 换回真模型。它挑模型靠的是留给它的这些:类型、底模、作者推荐的权重和参数、触发词(代号),以及你在模型的 **编辑** 窗口里写的 **给 AI 的用途说明**(一句不介意让它看到的话,比如“双人互动姿势”;不填就不给)。你在聊天里说出模型名,它照样搜得到,拿到的还是代号;它拿不准时会放一个模型面板让你在真卡片里挑。AI 嘴里的 [M1] 是哪个模型,点任务标题栏 **⋯ → 模型代号对照** 查。不想藏的话,在 **关卡** 窗口把“隐私任务里的模型名”改成“照常给 AI”。有一种情况藏不住:卡片名只是一个普通英文单词的模型(比如就叫 Anima),名字出现在一整句话中间时不替换,免得把正常的说明也替换坏;它的文件名和搜索结果里的名字照样是代号。
**这次跑的节点结构**:每次运行那一步下面写着一行“底模 · 几个 LoRA · 尺寸 · 步数 · 几个节点”,点开有 **简单** 和 **完整** 两种看法(AMB 记得你上次选的)。简单:按每一段采样列出“用哪个模型加哪些 LoRA、提示词是什么、从空白画布还是从上一段的结果(放大多少)开始”,最后保存在哪;后一段和前一段一样的就写“和第 1 段一样”。完整:方块图,哪个底模、LoRA 和权重、正负提示词开头、尺寸、采样器设置、保存,方块之间的线标着类型。两种看法里点任意一项都能定位到画布上的节点。这张图是 AMB 按 ComfyUI 实际收到的工作流画的,不靠 AI 描述。上面的黄色小块是 **比上次改了什么**(比如“K采样器·步数:20 → 30”“新增「加载LoRA」”)。AI 讲解时可以用 `show_run_map` 一次把同一张图放上展台,不用自己拼。
@@ -175,7 +179,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp
**运行进度**:AI 运行工作流后,时间线上那一步下面有进度条(排队时前面还有几个;运行时哪个节点、第几步、节点做完几个)。进度条右边是百分比,旁边是红色的 **■ 停止**:一点就停这一次运行(还在排队的移出队列,正在跑的立刻中断,只动这次运行),AI 也会收到“你停止了运行”,不会自己再跑一遍。任务工具栏的运行状态按钮旁边也有一个小 ■。进度条下面写着当前节点 **约剩多久**、每步几秒、**显存剩多少**,再下面一行是 ComfyUI **控制台**最新的那一行(比如 `Model Initializing ...`、`Requested to load …`),点开能看最近几行;这些 AI 也同样看得到。出图刚排上队,AMB 会读这次的工作流算出每个采样器要在多大的图上采样:如果是放大模型放大后又整张重新采样、尺寸超过这张显卡的舒适范围、要解码特别大的图,或者最后要存出特别大的图(超过约 6400 万像素,比如把大图再放大 4 倍,PNG 好几百 MB),会出现黄色提醒,告诉你可能很慢、爆显存或者看图很卡,以及怎么改(只想放大就用纯模型超分,或者先缩小、减步数、分块解码、放大倍数小一点);AI 收到同样的提醒,会被要求先问你要不要停(它也能用 `stop_run` 停自己的运行)。AI 用 `run_status` 问同样的进度,或者等到跑完再继续,不会凭时间猜;运行期间 AI 调用别的工具时,回复里的 `your_run` 也带着一句当前进度,跑完后告诉它一次结果,所以它不用自己去翻输出文件夹或者看屏幕。跑完后,第一个告诉 AI "跑完了" 的回复里直接带着这次出的图(最多 4 张缩略图),AI 不用再一张张去取,看完就能直接给你留言。
**流程图和关卡**:任务标题下面的一排流程按 AI 做事的先后列出它经过的阶段(模型、搭配、提示词、运行、挑图……),点一下跳到那一步。任务列表上方的 **关卡** 里,改画布、用显卡出图配音、下载模型、联网扫描、挪动模型文件五类事各自可以选 **直接做 / 先问我 / 不许做**(默认下载和挪文件先问)。选了“先问我”,AI 的那一步会停在时间线上列出它要做什么,你点 **允许** 才做;点 **不要** 时可以写原因或想怎么改,AI 会收到。AI 最多等 50 秒,等不及的话会先去问你的回答(`get_answers`),你允许后它再做一次,不会问第二遍。AMB 把所有工具都给 AI 软件,软件自己按需读工具说明(Antigravity 在第一次用某个工具前读它的说明文件)。**等我回答** 是 AI 问你问题以后最多等多久(1 分钟 / 3 分钟 / 10 分钟):你在回答框或留言框里打字时,停手一分钟内都不算超时(只算真按键,光标停在框里不算);时间到了 AI 收工,你答完以后点一下消息框上方那句话复制给它。AI 每轮做完会先问你下一步,跑图、下载、扫描时除外;问题上的 **让 AI 定** 表示交给 AI 自己决定。AI 软件会记住它拿到的工具列表,AMB 更新以后要让它重新连接(Antigravity:重启,或在 MCP 设置里把 anomalous 关掉再打开),再开个新对话;它还拿着旧列表时,任务标题下面会有一行黄字提醒你。**等不到回答时** 默认是 **停下等我**;选 **按推荐的继续**,到了时间你还没答,AI 标了推荐项的选择题就按推荐的选,展台和“决策”里会写明“到时间,按推荐选了”;没有推荐项的问题、你已经动手写了回答的问题、关卡问你的事永远不会替你答。**等跑图** 默认是 **跑完叫我**:AI 提交后最多等 1 分钟,跑得快的当场就拿到结果,还没跑完就告诉你在跑,然后收工,不再每 55 秒查一次进度;跑完后,AI 工作台消息框上方有几句话(“跑完了,看看结果”“再来一次,换个种子”,出错时是“出错了,看看是哪里的问题”),点一下复制,粘到 AI 的对话框里,AI 的下一次调用就拿到结果和图。AI 一知道结果,这几句话就换回平常的。平常 AI 停下来的时候(没在调用工具、没在跑图、没在等你回答),那里也总有一行话可以复制:你在 AMB 里回复了而 AI 还没收到,是“我在 Anomalous 里回复了,看看再接着做”,别的时候是“继续”;最后一句“用 Anomalous 接着做任务:…”给新开的 AI 对话用。想让 AI 自己等到跑完,选 **AI 自己等**。这一项马上生效,不用开新对话。等不到回答时选了按推荐的继续,挑图面板里 AI 标了推荐的那张也会替你选上。**第三方节点** 选“只用做法里的”,AI 自己加节点时就只能用做法里那些搭好的组合,加上你给这个任务挑的;ComfyUI 自带的节点照常能用。跑完的图下面有 **效果好 / 不太行** 两个按钮,点了直接告诉 AI(点“不太行”它就该换做法、设置或提示词再跑),再点一下收回;只告诉 AI,不另外记录。
**流程图和关卡**:任务标题下面的一排流程按 AI 做事的先后列出它经过的阶段(模型、搭配、提示词、运行、挑图……),点一下跳到那一步。任务列表上方的 **关卡** 里,改画布、用显卡出图配音、下载模型、联网扫描、挪动模型文件五类事各自可以选 **直接做 / 先问我 / 不许做**(默认下载和挪文件先问)。选了“先问我”,AI 的那一步会停在时间线上列出它要做什么,你点 **允许** 才做;点 **不要** 时可以写原因或想怎么改,AI 会收到。AI 最多等 50 秒,等不及的话会先去问你的回答(`get_answers`),你允许后它再做一次,不会问第二遍。AMB 把所有工具都给 AI 软件,软件自己按需读工具说明(Antigravity 在第一次用某个工具前读它的说明文件)。**等我回答** 是 AI 问你问题以后最多等多久(1 分钟 / 3 分钟 / 10 分钟):你在回答框或留言框里打字时,停手一分钟内都不算超时(只算真按键,光标停在框里不算);时间到了 AI 收工,你答完以后点一下消息框上方那句话复制给它。AI 每轮做完会先问你下一步,跑图、下载、扫描时除外;问题上的 **让 AI 定** 表示交给 AI 自己决定。AI 软件会记住它拿到的工具列表,AMB 更新以后要让它重新连接(Antigravity:重启,或在 MCP 设置里把 anomalous 关掉再打开),再开个新对话;它还拿着旧列表时,任务标题下面会有一行黄字提醒你。**等不到回答时** 默认是 **停下等我**;选 **按推荐的继续**,到了时间你还没答,AI 标了推荐项的选择题就按推荐的选,展台和“决策”里会写明“到时间,按推荐选了”;没有推荐项的问题、你已经动手写了回答的问题、关卡问你的事永远不会替你答。**等跑图** 默认是 **跑完叫我**:AI 提交后最多等 1 分钟,跑得快的当场就拿到结果,还没跑完就告诉你在跑,然后收工,不再每 55 秒查一次进度;跑完后,AI 工作台消息框上方有几句话(“跑完了,看看结果”“再来一次,换个种子”,出错时是“出错了,看看是哪里的问题”),点一下复制,粘到 AI 的对话框里,AI 的下一次调用就拿到结果和图。AI 一知道结果,这几句话就换回平常的。平常 AI 停下来的时候(没在调用工具、没在跑图、没在等你回答),那里也总有一行话可以复制:你在 AMB 里回复了而 AI 还没收到,是“我在 Anomalous 里回复了,看看再接着做”,别的时候是“继续”;最后一句“用 Anomalous 接着做任务:…”给新开的 AI 对话用。想让 AI 自己等到跑完,选 **AI 自己等**。这一项马上生效,不用开新对话。等不到回答时选了按推荐的继续,挑图面板里 AI 标了推荐的那张也会替你选上。**第三方节点** 选“只用做法里的”,AI 自己加节点时就只能用做法里那些搭好的组合,加上你给这个任务挑的;ComfyUI 自带的节点照常能用。跑完的图下面有 **效果好 / 不太行** 两个按钮,点了直接告诉 AI;点了“不太行”,下面会多一排问题按钮(手坏了、脸不对、构图偏了、太暗……),点哪个就把哪个告诉 AI,可以点几个。隐私模式下 AI 看不到图,就靠这个知道该改什么(点“不太行”它就该换做法、设置或提示词再跑),再点一下收回;只告诉 AI,不另外记录。
**少调用几次就接好线**:以下编号只是例子,以当前画布为准。
@@ -240,7 +244,7 @@ add_nodes(nodes={
**留言框**一直停在任务这一栏的最下面,上方的“现在”栏显示 AI 出图的进度(可以停止)、要你允许的事(直接点 **允许 / 不要**)和 AI 问你的问题(点选项就回答)。图片可以拖到留言框上,或者 Ctrl+V 粘贴截图:存进输出文件夹的 `ai_inbox` 后加到消息上。它上面显示这条消息要带的图(每张可以 **✎ 标注** 或 **×** 拿掉)和 @ 提到的东西;Enter 发送,Shift+Enter 换行,只发图片也行。往上翻着看时 AI 有了新步骤,会出现 **下面有新的步骤**。**留言框里的 @ 和 /**:在留言框里打 **@**,会弹出一张能搜索的列表:这个任务出过的图、图库里最新的图(打字时也按提示词、模型、seed 在图库里搜)、搭配、模型、工作流、提示词、节点包、节点。留言框左边的 **图库** 按钮打开图库:点选几张(一次最多 8 张),点 **加进留言**,它们显示在输入框上方。用 @ 选中的会写成“@名字”,并在输入框上方显示成小标签;发出去时 AI 收到的是你指的那个东西本身(模型的准确 ID、搭配的参数、图片和它的生成参数),不用它去猜。在开头打 **/** 是快捷指令:/停止、/撤销、/展台、/节点结构、/素材、/副本、/当前画布、/新任务 马上生效,不用等 AI;/重跑、/解释、/继续 帮你写好一句话,改一改再发。↑↓ 选,回车或 Tab 确认,Esc 关掉。
**AI 画的面板**(`show_panel`)显示在工作台右边的**展台**上,后面再多步骤也不会被顶走。AI 放了新面板,右栏自己打开并跳到它,你关掉了或者在看别的也一样(AI 只是改了已有的面板时不会);不想被打扰,把展台顶上的 **有新东西就打开** 关掉。窗口窄时它不抢时间线,只在“展台”标签上亮个点。右边这一栏一次只显示一样东西,用窗口右上角的按钮切换:**展台**(有问题等你回答时带个小黄点)、**节点结构**(AI 跑过的节点结构图,大图;上面可以选看哪一次运行,时间线里运行那一步的“在右侧看大图”也会打开它)、**决策**、**素材**、**成果**。再点一下正亮着的那个就收起这一栏;切走再切回来,展台的位置和挑了一半的素材都还在。任务标题和那一排按钮在时间线往下翻时一直停在顶上。时间线上每个面板留一张小卡片:小图标、标题、里面有什么(“1 张图”“问你 · 2 个选项”),和紫色的 **在右侧打开** 按钮;右栏正在显示的那一张写着绿色的“正在右侧显示”。展台默认跟着最新的面板,你翻回旧面板就停在你看的那张;窗口窄的时候,时间线和展台变成两个标签页切换。右边这一栏是一张单独的卡片,它左边的整条空隙都能拖着调宽窄(鼠标靠近时整条边会亮起;点一下后也能按 ← → 调;AMB 记得你调的宽度,双击恢复默认),时间线总会留够地方。窗口左上角的按钮收起 / 展开左边的任务列表;收起以后,鼠标移到这个按钮上,任务列表会临时浮出来,点一个任务或者鼠标移开就自动收回。素材在右边这一栏里时,鼠标移过卡片不会再跳进详细说明,点右边的 ⓘ 才看。小窗里素材是一行一个的列表(图标、名字、来源、勾选),搜索框旁边三个小图标是“全部 / 已选 / 常用”;展开成大窗口就是带预览的卡片。这些地方的滚动条平时是隐藏的,鼠标移到能滚动的那一块上才显出来。AI 可以用插件提供的“积木”拼面板:标题和文字(也可以是提示、警告)、图片(一排、网格,或者两张并排对比)、你的模型(带封面的卡片,点了打开)、表格、列表、参数、进度条。它还能**问你**:几个按钮(单选或多选)、一行输入框。你的回答会交出去,等 AI 用 `get_answers` 来取(它可以等你最多一分钟);每个问题下面有一个状态标签:琥珀色的 **已发送 · 等 AI 来取**,AI 拿到后变成绿色的 **AI 已收到 · 时间**。你的留言、批注、发给它的图片也是一样。想让 AI 自己决定的题点 **让 AI 定**,AI 会自己拿主意,不再问一遍。AMB 没法主动叫醒 AI,所以回答之后要等它下一次来问。
**AI 画的面板**(`show_panel`)显示在工作台右边的**展台**上,后面再多步骤也不会被顶走。AI 放了新面板,右栏自己打开并跳到它,你关掉了或者在看别的也一样(AI 只是改了已有的面板时不会);不想被打扰,把展台顶上的 **有新东西就打开** 关掉。窗口窄时它不抢时间线,只在“展台”标签上亮个点。右边这一栏一次只显示一样东西,用窗口右上角的按钮切换:**展台**(有问题等你回答时带个小黄点)、**节点结构**(AI 跑过的节点结构图,大图;上面可以选看哪一次运行,时间线里运行那一步的“在右侧看大图”也会打开它)、**决策**、**素材**、**成果**。再点一下正亮着的那个就收起这一栏;切走再切回来,展台的位置和挑了一半的素材都还在。任务标题和那一排按钮在时间线往下翻时一直停在顶上。时间线上每个面板留一张小卡片:小图标、标题、里面有什么(“1 张图”“问你 · 2 个选项”),和紫色的 **在右侧打开** 按钮;右栏正在显示的那一张写着绿色的“正在右侧显示”。展台默认跟着最新的面板,你翻回旧面板就停在你看的那张;窗口窄的时候,时间线和展台变成两个标签页切换。右边这一栏是一张单独的卡片,它左边的整条空隙都能拖着调宽窄(鼠标靠近时整条边会亮起;点一下后也能按 ← → 调;AMB 记得你调的宽度,双击恢复默认),时间线总会留够地方。窗口左上角的按钮收起 / 展开左边的任务列表;收起以后,鼠标移到这个按钮上,任务列表会临时浮出来,点一个任务或者鼠标移开就自动收回。素材在右边这一栏里时,鼠标移过卡片不会再跳进详细说明,点右边的 ⓘ 才看。小窗里素材是一行一个的列表(图标、名字、来源、勾选),搜索框旁边三个小图标是“全部 / 已选 / 常用”;展开成大窗口就是带预览的卡片。这些地方的滚动条平时是隐藏的,鼠标移到能滚动的那一块上才显出来。AI 可以用插件提供的“积木”拼面板:标题和文字(也可以是提示、警告)、图片(一排、网格,或者两张并排对比)、你的模型(带封面的卡片,点了打开)、表格、列表、参数。它还能**问你**:几个按钮(单选或多选)、一行输入框。你的回答会交出去,等 AI 用 `get_answers` 来取(它可以等你最多一分钟);每个问题下面有一个状态标签:琥珀色的 **已发送 · 等 AI 来取**,AI 拿到后变成绿色的 **AI 已收到 · 时间**。你的留言、批注、发给它的图片也是一样。想让 AI 自己决定的题点 **让 AI 定**,AI 会自己拿主意,不再问一遍。AMB 没法主动叫醒 AI,所以回答之后要等它下一次来问。
**展台积木**(1.62):AI 还可以在图上**画圈标注**(圈上是编号,标签列在图下面,点标签放大那一块)、摆**拉帘对比**(两张图叠在一起,拖中间的线)、摆**选片盘**(每张图带 AI 的理由和“就选这张”;“放回画布”会在新标签页里打开这张图里存的工作流,也可以直接拖到画布上)、画**示意图**(框和箭头由插件排版;带 ◎ 的框点一下定位到画布上的节点)。AI 只给位置和文字,圈、线、框都是插件画的,所以接哪家 AI 都一样好看,也不会收 AI 写的 SVG 代码。
@@ -248,7 +252,7 @@ add_nodes(nodes={
展台右上角的 **× 关闭展台**只收起这一栏,不删除面板、不结束任务;AI 改已有的面板时不会重开,放新面板时会重开(把 **有新东西就打开** 关掉就不会)。窗口右上角的“展台”,或时间线里的“在右侧打开”可以恢复,未提交的输入会保留。“来自第 N 步”指创建这个面板的那一步;后续修改同一个面板不会改变它的来源编号。
**按过程往下看**:建议 AI 把“搭配方案 → 节点说明 → 成图结果”作为不同阶段依次展示。更新同一个面板时,新内容也只接在底部,已有内容不会因为 AI 重排或省略而消失。`show_panel` / `update_panel` 会返回积木 ID;明确带上已有 ID 才是在原位置修正那块内容,进度条也可以按唯一的标签更新。单面板最多 30 块,满了就用 `show_panel` 展示下一阶段。面板里的进度条是 AI 自己写的数,不会自己动:任务往前走了(那次出图结束、AI 又画了面板或留了言、任务结束)而它没有更新,展台就把它画成灰色,并写明是几点写下的;出图的真实进度在时间线那一步和标题栏的运行状态里。旧记录只保留了最终排版,没有每次更新前的顺序,因此不会自动重排旧记录。
**按过程往下看**:建议 AI 把“搭配方案 → 节点说明 → 成图结果”作为不同阶段依次展示。更新同一个面板时,新内容也只接在底部,已有内容不会因为 AI 重排或省略而消失。`show_panel` / `update_panel` 会返回积木 ID;明确带上已有 ID 才是在原位置修正那块内容。单面板最多 30 块,满了就用 `show_panel` 展示下一阶段。AI 不再自己画进度条:出图的进度在时间线那一步和标题栏的运行状态里,任务单和蓝图的进度也由 AMB 自己显示,都是真实的数;以前的记录里 AI 画过的进度条不再显示。旧记录只保留了最终排版,没有每次更新前的顺序,因此不会自动重排旧记录。
## 6. 注意
@@ -266,10 +270,9 @@ Apps that support MCP Apps show Anomalous's own card (`ui://anomalous/task-card.
The stage reads chronologically: use `show_panel` for successive phases. `update_panel` appends
new blocks below existing content, keeps omitted blocks and deduplicates unchanged resubmissions.
Tools return stable block ids for deliberate in-place corrections (including progress/questions);
an id keeps its type and position. A uniquely labelled progress bar also updates without an id.
A progress bar holds the AI's own numbers: once the task goes past it without an update (a run
ends, a later panel or note, the task ends), the stage greys it and says when it was written.
Tools return stable block ids for deliberate in-place corrections (a table, a question);
an id keeps its type and position. The AI no longer draws a progress bar of its own: a run's, a
job sheet's and a blueprint's progress are shown by Anomalous itself. An old record's bar is not drawn.
The total remains bounded to 30 blocks; use a new panel for the next phase. Old records retain
their saved layout because earlier revisions were not recorded.
+71
View File
@@ -0,0 +1,71 @@
"""Words a LoRA's own file suggests for the prompt, for a LoRA Civitai gives none: the trigger
phrase its maker wrote (modelspec.trigger_phrase), else the tags most of its training pictures
share (kohya's ss_tag_frequency), tags any picture could have left out. A guess, shown as one and
never mixed into Civitai's trainedWords. The scanner keeps them in the .info it writes
(anomalous_header_words); the model details and the AI read a LoRA scanned before that at once.
Reads the header only (model_kind.read_head). No imports from ComfyUI.
"""
import json
try:
from .model_kind import classify, read_head
except ImportError: # the standalone scanner imports this file on its own
from model_kind import classify, read_head
MOST = 5
SHARE = 0.5 # in at least half as many pictures as the most used tag
LONGEST = 60
# Tags that say nothing of what a LoRA adds: how many people, framing, plain backgrounds, quality words.
GENERIC = frozenset((
"1girl", "1boy", "2girls", "2boys", "3girls", "multiple girls", "multiple boys", "solo", "solo focus",
"female focus", "male focus", "looking at viewer", "looking at another", "looking away", "simple background",
"white background", "grey background", "gray background", "black background", "transparent background",
"smile", "open mouth", "closed mouth", "parted lips", "blush", "upper body", "full body", "cowboy shot",
"portrait", "close-up", "standing", "sitting", "long hair", "short hair", "medium hair", "very long hair",
"breasts", "small breasts", "medium breasts", "large breasts", "outdoors", "indoors", "day", "night",
"monochrome", "greyscale", "masterpiece", "best quality", "high quality", "highres", "absurdres",
"lowres", "score_9", "score_8_up", "score_7_up",
))
def _split(text):
return [part.strip() for part in str(text).split(",") if part.strip()]
def _counts(text):
"""{tag: how many training pictures have it} from ss_tag_frequency ({folder: {tag: count}})."""
try:
folders = json.loads(text or "{}")
except ValueError:
return {}
counts = {}
for tags in folders.values() if isinstance(folders, dict) else ():
for tag, count in tags.items() if isinstance(tags, dict) else ():
name = str(tag).strip()
if name and isinstance(count, (int, float)) and not isinstance(count, bool):
counts[name] = counts.get(name, 0) + count
return counts
def from_meta(meta):
"""Up to MOST words from a safetensors header's __metadata__ (its values are strings)."""
phrase = _split(meta.get("modelspec.trigger_phrase") or "")
if phrase:
return [word[:LONGEST] for word in phrase[:MOST]]
counts = _counts(meta.get("ss_tag_frequency"))
if not counts:
return []
least = max(counts.values()) * SHARE
shared = [tag for tag, count in counts.items() if count >= least and len(tag) <= LONGEST
and tag.lower().replace("_", " ") not in GENERIC and tag.lower() not in GENERIC]
return sorted(shared, key=lambda tag: -counts[tag])[:MOST]
def of_file(path):
"""The words of a LoRA file's header; [] for other kinds, other formats and unreadable files."""
fmt, header = read_head(path)
if fmt != "safetensors" or not header or classify(fmt, header)["kind"] != "loras":
return []
meta = header.get("__metadata__")
return from_meta(meta) if isinstance(meta, dict) else []
+32 -3
View File
@@ -18,10 +18,12 @@ import struct
MAX_HEADER = 100 * 1024 * 1024
GGUF_READ = 16 * 1024 * 1024
# Raised whenever a rule changes: answers kept on disk (api/model_heads.py) are then read again.
RULES = 2
RULES = 3
# Cross-attention width (the text encoder's output) -> base model, for the SD family.
CONTEXT_WIDTH = {768: "SD 1.5", 1024: "SD 2.1", 2048: "SDXL"}
# Caption embedder width -> base model, for the Lumina 2 layout (ComfyUI tells them apart the same way).
CAPTION_WIDTH = {2304: "Lumina", 3840: "Z-Image"}
# ComfyUI-GGUF's architecture names.
GGUF_DIFFUSION = {
"flux": "Flux.1 D", "sd1": "SD 1.5", "sdxl": "SDXL", "sd3": "SD3", "aura": "AuraFlow",
@@ -167,6 +169,15 @@ def _context_width(header):
return 0
def _first_dim(header, ending):
"""The first dimension of the tensor whose name ends with `ending`, else 0."""
for key, value in header.items():
if key.endswith(ending) and isinstance(value, dict):
shape = value.get("shape") or []
return shape[0] if shape and isinstance(shape[0], int) else 0
return 0
def _family(meta):
"""An SDXL file's family from the name of the model it was trained on (kohya's metadata)."""
trained_on = " ".join(str(meta.get(key, "")) for key in ("ss_sd_model_name", "ss_base_model", "modelspec.title")).lower()
@@ -179,7 +190,7 @@ def _family(meta):
def _base_from_meta(meta):
arch = str(meta.get("modelspec.architecture", "")).lower()
trained = str(meta.get("ss_base_model_version", "")).lower()
pairs = (("flux", "Flux.1 D"), ("chroma", "Chroma"), ("stable-diffusion-xl", "SDXL"), ("sdxl", "SDXL"),
pairs = (("flux-1-schnell", "Flux.1 S"), ("flux", "Flux.1 D"), ("chroma", "Chroma"), ("stable-diffusion-xl", "SDXL"), ("sdxl", "SDXL"),
("stable-diffusion-v1", "SD 1.5"), ("sd_v1", "SD 1.5"), ("stable-diffusion-v2", "SD 2.1"),
("sd_v2", "SD 2.1"), ("stable-diffusion-3", "SD3"), ("sd3", "SD3"), ("wan", "Wan Video"),
("qwen", "Qwen"), ("hunyuan", "Hunyuan Video"))
@@ -193,6 +204,9 @@ def _base_from_keys(header, text):
"""(base, sure) from tensor names; ("", False) when they do not tell."""
if "individual_token_refiner" in text:
return "Hunyuan Video", True
if "noise_refiner" in text and "cap_embedder_1" in text:
name = CAPTION_WIDTH.get(_first_dim(header, "cap_embedder.1.weight"), "")
return name, bool(name)
# Cosmos-Predict2 (with or without "net."); Anima adds an adapter for its Qwen3 text
# encoder. Before Wan: their blocks' attention names look alike.
if "llm_adapter" in text:
@@ -286,6 +300,18 @@ def _kind(keys, raw, text):
return "", False
def _gguf_base(arch, header):
"""A GGUF drawing model's base model by ComfyUI-GGUF's architecture name; when all its tensors
were read, Schnell is told from Dev (no guidance input) and Z-Image from Lumina 2 (about 6
billion parameters, Lumina 2 under 4)."""
whole = bool(header.get("params"))
if arch == "flux" and whole and not any(key.startswith("guidance_in.") for key in header.get("keys") or []):
return "Flux.1 S"
if arch == "lumina2" and whole:
return "Z-Image" if header["params"] > 4 * 10 ** 9 else "Lumina"
return GGUF_DIFFUSION[arch]
def classify(fmt, header):
"""What a parsed header says: {"kind", "base", "sure"} (see the module text)."""
if not header:
@@ -294,7 +320,7 @@ def classify(fmt, header):
meta, keys = header.get("meta") or {}, header.get("keys") or []
arch = str(meta.get("general.architecture", "")).lower()
if arch in GGUF_DIFFUSION:
return {"kind": "diffusion_models", "base": GGUF_DIFFUSION[arch], "sure": True}
return {"kind": "diffusion_models", "base": _gguf_base(arch, header), "sure": True}
if arch.startswith(GGUF_TEXT):
return {"kind": "text_encoders", "base": "", "sure": True}
header = {key: {} for key in keys}
@@ -314,6 +340,9 @@ def classify(fmt, header):
base, base_sure = _base_from_keys(header, text)
if base == "SDXL":
base = _family(meta) or base
# Schnell is Dev without the guidance input; a LoRA has no such layer either way.
if base == "Flux.1 D" and kind in ("checkpoints", "diffusion_models") and "guidance_in" not in text:
base = "Flux.1 S"
form = "lllite" if kind == "model_patches" and any(key.startswith("lllite_") for key in keys) else ""
return {"kind": kind, "base": base, "sure": bool(kind) and sure and (base_sure or not base), "form": form}
+7
View File
@@ -22,6 +22,7 @@ from recycle_bin import move_to_trash
from model_identity import USER_INFO_SUFFIX, computed_file_identity, file_sha256, infer_base_model_from_header, is_unmatched, safetensors_damage, scan_choice, scan_info_path, sidecar_file_hash, sidecar_info as read_local_info, unmatched_reason
from civitai_client import CIVITAI_API_KEY, CivitaiUnreachable, download_media, fetch_civitai_info, fetch_model_description
import model_sidecars as sidecars
import header_words
# Fixed tuples avoid rebuilding long extension lists for every scanned model.
@@ -222,6 +223,12 @@ def scan_one(args, state, rename_log, root, filename, previous):
info_data = civitai_data
if file_hash:
info_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash)
# A LoRA Civitai gives no trigger words for: what its own header suggests, kept apart.
info_data.pop("anomalous_header_words", None)
if not any(str(word).strip() for word in info_data.get("trainedWords") or []):
words = header_words.of_file(file_path)
if words:
info_data["anomalous_header_words"] = words
# What the file alone tells has no standard name: both names are the file's own, so a
# rename would only repeat it (name_name, then name_name_name_name on the next scan).
inferred = is_unmatched(civitai_data)
+31 -1
View File
@@ -1,7 +1,7 @@
// The AI workbench's rules (web/modules/ai_task_view.js): folding reads, words, counts, addresses.
import assert from 'node:assert/strict';
import {
activityIds, audioResult, chainTools, duration, flowStages, foldLooks, groupSteps, isVideo, panelGist, lookTools, newestPicture, outputCount, outputUrl, outputPicture, pictureShown,
activityIds, audioResult, chainTools, duration, flowStages, planFlow, foldLooks, groupSteps, isVideo, panelGist, lookTools, newestPicture, outputCount, outputUrl, outputPicture, pictureShown,
pictureKey, picturePreview, uniquePictures, skillsBefore, stepDetail, taskCounts, taskFamilies, taskOutputs, taskResults, taskState, toolKey,
} from '../web/modules/ai_task_view.js';
@@ -98,6 +98,36 @@ assert.deepEqual(flow.map(item => [item.stage, item.state, item.first, item.coun
]);
assert.deepEqual(flowStages(null), []);
// A job sheet's run says how far it is.
const sheetRun = flowStages({ steps: [staged('s', 'run', { state: 'running', sheet: 'x', sheet_total: 10, progress: { prompt: 7, prompts: 10 } })] });
assert.deepEqual(sheetRun[0].sheet, { done: 6, total: 10 });
assert.deepEqual(flowStages({ steps: [staged('s', 'run', { sheet: 'x', sheet_total: 10, prompts: 10 })] })[0].sheet, { done: 10, total: 10 });
// The blueprint's line, from what came after the card.
const card = (answers = {}) => ({ id: 'card', kind: 'panel', ok: true, answers, panel: { blocks: [{ type: 'plan', id: 'answer', steps: [
{ block: 'picture', title: 'Picture', model: 'm:a', model_name: 'Harem' },
{ block: 'refine_other_model', title: 'Redraw', model: 'm:b', model_name: 'Kirazuri', choices: [{ model_id: 'm:c', name: 'Other' }] },
{ block: 'detail_face_hands', title: 'Face and hands', model: null },
] }] } });
const lineOf = plan => plan.items.map(item => `${item.title || item.key}${item.model ? `/${item.model}` : ''}:${item.state}`);
assert.equal(planFlow({ steps: [staged('a', 'model')] }), null, 'no card, no line');
const waiting = planFlow({ steps: [card()] });
assert.equal(waiting.state, 'waiting');
assert.deepEqual(lineOf(waiting), ['Picture/Harem:ahead', 'aiFlowPick:ahead', 'Redraw/Kirazuri:ahead', 'Face and hands:ahead']);
const kept = { choice: ['build'], plan: [{ block: 'picture', model: 'm:a' }, { block: 'refine_other_model', model: 'm:c' }] };
const planRun = (id, extra) => ({ id, tool: 'run_workflow', stage: 'run', ok: true, state: 'done', results: [], ...extra });
const picking = planFlow({ steps: [card({ answer: kept }), planRun('b', { bases: { bases: [] } })] });
assert.equal(picking.state, 'built');
assert.deepEqual(lineOf(picking), ['Picture/Harem:done', 'aiFlowPick:asking', 'Redraw/Other:ahead'],
'a dropped step is gone, a swapped model shows; after the bases the pick waits for the user');
const pickStep = answers => ({ id: 'p', tool: 'pick_base', kind: 'panel', ok: true, answers, panel: { blocks: [{ type: 'picks', id: 'base' }] } });
const going = planFlow({ steps: [card({ answer: kept }), planRun('b', { bases: { bases: [] } }), pickStep({ base: { choice: ['b2'] } }), planRun('w', { state: 'running' })] });
assert.deepEqual(lineOf(going), ['Picture/Harem:done', 'aiFlowPick:done', 'Redraw/Other:running']);
assert.equal(going.items[2].first, 'w', 'a step leads to the run doing it');
const without = planFlow({ steps: [card({ answer: kept }), planRun('b', { bases: { bases: [] } }), planRun('w', { ok: false, state: 'error' })] });
assert.deepEqual(lineOf(without), ['Picture/Harem:done', 'aiFlowPick:skipped', 'Redraw/Other:error']);
assert.equal(planFlow({ steps: [card({ answer: { text: 'fix hands first' } })] }).state, 'change');
// The activity entries a task shows: the AI's canvas changes and the user's own.
assert.deepEqual(activityIds({ steps: [{ results: [{ type: 'canvas', activity: 'a1' }, { type: 'model' }] }, { tool: 'user_activity', activity: 'u1', results: [] }] }), ['a1', 'u1']);
+24
View File
@@ -51,6 +51,30 @@ const advanced = (model, latent, start, end, leftover) => ({
assert.equal(JSON.stringify(output), before, 'the queued prompt is changed in a copy');
}
// A later step that previews a mask cut from the base (only the character redrawn): the mask is no base to pick.
{
const output = {
...loaders,
32: advanced('1', '6', 0, 35, 'disable'),
7: { class_type: 'VAEDecode', inputs: { samples: ['32', 0], vae: ['8', 0] } },
9: { class_type: 'SaveImage', inputs: { images: ['7', 0], filename_prefix: 'picture' } },
40: { class_type: 'InspyrenetRembg', inputs: { image: ['7', 0] } },
41: { class_type: 'MaskToImage', inputs: { mask: ['40', 1] } },
42: { class_type: 'PreviewImage', inputs: { images: ['41', 0] } },
43: { class_type: 'VAEEncode', inputs: { pixels: ['7', 0], vae: ['8', 0] } },
44: { class_type: 'SetLatentNoiseMask', inputs: { samples: ['43', 0], mask: ['40', 1] } },
45: { class_type: 'KSampler', inputs: { model: ['1', 0], positive: ['2', 0], negative: ['3', 0], latent_image: ['44', 0], seed: 5, denoise: 0.7 } },
46: { class_type: 'VAEDecode', inputs: { samples: ['45', 0], vae: ['8', 0] } },
47: { class_type: 'SaveImage', inputs: { images: ['46', 0], filename_prefix: 'upscaled_character' } },
};
const got = baseOnly(output);
assert.deepEqual(got.shows, ['9'], 'the base alone, not the mask preview');
assert.deepEqual(Object.keys(got.output).sort(), ['1', '2', '3', '32', '5', '6', '7', '8', '9'], 'nothing is cut out for a base to pick');
const kept = onKeptBase(output, 'kept.png');
assert.deepEqual([kept[40].inputs.image, kept[43].inputs.pixels], [['48', 0], ['48', 0]], 'the rest cuts and redraws the kept picture');
assert.ok(kept[42] && kept[47] && !kept[32] && !kept[9], 'the mask preview is the rest\'s, the base is not drawn again');
}
// The mid-way switch: the first model hands its unfinished latent to the second; the base is finished and decoded to show.
{
const output = {
+3 -3
View File
@@ -21,7 +21,7 @@ settings.placement = 'top';
assert.equal(saveMenuSettings(settings, store), true);
const back = menuSettings(store);
assert.equal(back.placement, 'top');
assert.equal(back.node.at(-3).id, 'saveCombo');
assert.equal(back.node.at(-5).id, 'saveCombo');
assert.equal(back.node.find(entry => entry.id === 'open').on, false);
// Moving past an end changes nothing.
@@ -35,9 +35,9 @@ store.setItem('anomalous_canvas_menu', JSON.stringify({ placement: 'odd', node:
settings = menuSettings(store);
assert.equal(settings.placement, 'submenu');
assert.equal(settings.node.length, NODE_ENTRIES.length);
assert.deepEqual(settings.node.slice(0, 4).map(entry => entry.id), ['saveCombo', 'snapshotMark', 'open', 'replaceModel'],
assert.deepEqual(settings.node.slice(0, 6).map(entry => entry.id), ['saveCombo', 'snapshotMark', 'jobSheet', 'handToAi', 'open', 'replaceModel'],
'a new entry right after the one it follows by default');
assert.equal(settings.node[2].on, false);
assert.equal(settings.node[4].on, false);
// Bad JSON, no storage, reset.
store.setItem('anomalous_canvas_menu', '{');
+26
View File
@@ -56,6 +56,32 @@ function txt2img() {
assert.equal(placeRoles(g, plain), plain, 'a group naming no role is passed on as it is');
}
// A chain's next block goes on from the output node it is given, not the one the sampler's decode reaches.
{
const { g, decode, save } = txt2img();
const bigger = g.node('ImageScaleBy', [['image', 'IMAGE']], [['IMAGE', 'IMAGE']]);
const later = g.node('SaveImage', [['images', 'IMAGE']], []);
g.wire(decode, 0, bigger, 'image'); g.wire(bigger, 0, later, 'images');
assert.equal(findRoles(g).save, save, 'unasked: the sampler decode\'s own output');
assert.equal(findRoles(g, null, later.id).save, later);
const placed = placeRoles(g, { a: { class_type: 'X', inputs: { image: ['$save.images', '*'] } } }, null, later.id);
assert.deepEqual(placed.a.inputs.image, [`#${bigger.id}`, 0], "the step before's picture");
assert.throws(() => findRoles(g, null, 999), /No node #999/);
}
// A block's model comes from before a control patch on the sampler's model line: the Tile is that pass's.
{
const { g, ckpt, sampler } = txt2img();
const tile = g.node('AnimaLLLiteApply_sdscripts', [['model', 'MODEL'], ['image', 'IMAGE']], [['MODEL', 'MODEL']]);
const link = sampler.inputs.find(item => item.name === 'model').link;
delete g.links[link];
ckpt.outputs[0].links = ckpt.outputs[0].links.filter(id => id !== link);
sampler.inputs.find(item => item.name === 'model').link = null;
g.wire(ckpt, 0, tile, 'model'); g.wire(tile, 0, sampler, 'model');
const placed = placeRoles(g, { a: { class_type: 'X', inputs: { model: ['$sampler.model', '*'], clip: ['$sampler.positive.clip', '*'] } } });
assert.deepEqual(placed.a.inputs.model, [`#${ckpt.id}`, 0], 'past the LLLite to the plain model');
}
// The loader is past the LoRAs on the model line.
{
const { g, ckpt, sampler } = txt2img();
+27
View File
@@ -0,0 +1,27 @@
// The model details' author text (web/modules/detail_intro.js): folded as last left, which language to offer, paragraphs.
import assert from 'node:assert/strict';
import { introFolded, introLanguage, keepIntroFolded, paragraphsOf } from '../web/modules/detail_intro.js';
const memory = () => {
const items = new Map();
return { getItem: key => items.get(key) ?? null, setItem: (key, value) => items.set(key, String(value)) };
};
const store = memory();
assert.equal(introFolded(store), true, 'folded at first');
keepIntroFolded(false, store);
assert.equal(introFolded(store), false, 'unfolded once, it opens unfolded');
keepIntroFolded(true, store);
assert.equal(introFolded(store), true);
assert.equal(introFolded(null), true);
const english = 'Kirazuri (Anima) is a full fine-tune of the Anima Base model focused on several goals.';
assert.equal(introLanguage(english, 'zh'), 'zh-CN');
assert.equal(introLanguage('这是一个二次元模型,适合画插画。', 'zh'), '', 'Chinese on the Chinese window reads as it is');
assert.equal(introLanguage('short', 'zh'), '', 'too little to translate');
assert.equal(introLanguage(english, 'en'), '');
assert.equal(introLanguage('これはアニメ調のイラストに向いたモデルです。', 'en'), 'en');
assert.deepEqual(paragraphsOf({ innerText: 'Title\n\n First line \n- a point\n\n' }), ['Title', 'First line', '- a point']);
assert.deepEqual(paragraphsOf({ textContent: 'one only' }), ['one only']);
console.log('detail_intro: ok');
+56
View File
@@ -0,0 +1,56 @@
// The job sheet's rules (web/modules/job_sheet_plan.js): prompts from pasted text, rows, runs, seeds, outcomes.
import assert from 'node:assert/strict';
import { bounded, jobAt, outcomeOf, promptsOf, restLeft, room, rowsOf, seedOf, slotsOf, totalOf } from '../web/modules/job_sheet_plan.js';
// A line each; empty lines and spaces around go.
assert.deepEqual(promptsOf('a cat\r\n\r\n a dog \nbird\n'), ['a cat', 'a dog', 'bird']);
// Blocks: a prompt of several lines, parted by an empty line (spaces on it too).
assert.deepEqual(promptsOf('a cat,\nsitting\n \t\na dog\n\n\n', true), ['a cat,\nsitting', 'a dog']);
assert.deepEqual(promptsOf(''), []);
assert.equal(bounded('7', 1, 50, 1), 7);
assert.equal(bounded('99', 1, 50, 1), 50);
assert.equal(bounded('x', 1, 50, 1), 1);
assert.equal(bounded('2.6', 0, 10, 0), 3);
// No prompt: the canvas as it is, that many times.
assert.deepEqual(rowsOf([], 5), [{ count: 5 }]);
const rows = rowsOf(['a', 'b'], 3);
assert.deepEqual(rows, [{ prompt: 'a', count: 3 }, { prompt: 'b', count: 3 }]);
assert.equal(totalOf(rows), 6);
// Runs go row by row, each row's runs together.
assert.deepEqual(jobAt(rows, 0), { row: 0, round: 0 });
assert.deepEqual(jobAt(rows, 2), { row: 0, round: 2 });
assert.deepEqual(jobAt(rows, 3), { row: 1, round: 0 });
assert.equal(jobAt(rows, 6), null);
assert.deepEqual(jobAt([{ count: 1 }, { prompt: 'x', count: 2 }], 2), { row: 1, round: 1 });
// The same seeds: round r of every row has the same one; else a new one each run.
assert.equal(seedOf({ same_seeds: true, base: 100 }, 2), 102);
assert.equal(seedOf({ same_seeds: false, base: 100 }, 2), null);
assert.deepEqual(slotsOf({ prompt: 'a', count: 2 }), { prompt: 'a' });
assert.deepEqual(slotsOf({ values: { cfg: 5 }, negative: 'blur', count: 1 }), { negative: 'blur', values: { cfg: 5 } });
assert.deepEqual(slotsOf({ count: 4 }), {});
// How a run ended, by its history.
assert.deepEqual(outcomeOf(undefined), { outcome: 'gone', why: '' });
assert.deepEqual(outcomeOf({ status: { status_str: 'success', completed: true } }), { outcome: 'ok', why: '' });
assert.equal(outcomeOf({ status: { status_str: 'error', messages: [['execution_start', {}], ['execution_interrupted', {}]] } }).outcome, 'interrupted');
assert.deepEqual(outcomeOf({ status: { status_str: 'error', messages: [['execution_error', { node_type: 'KSampler', exception_message: 'out of memory' }]] } }),
{ outcome: 'error', why: 'KSampler: out of memory' });
// The pause after a run; none before the first or with no pause.
assert.equal(restLeft({ pause: 10 }, 1000, 4000), 7000);
assert.equal(restLeft({ pause: 10 }, 0, 4000), 0);
assert.equal(restLeft({ pause: 0 }, 1000, 1001), 0);
assert.equal(restLeft({ pause: 2 }, 1000, 9000), 0);
// With a pause one run at a time; with none one waits behind the one going.
assert.equal(room({ pause: 5, pending: [] }), true);
assert.equal(room({ pause: 5, pending: ['a'] }), false);
assert.equal(room({ pause: 0, pending: ['a'] }), true);
assert.equal(room({ pause: 0, pending: ['a', 'b'] }), false);
console.log('job_sheet_plan: ok');
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import { llliteUses, llliteWords, usesLllite } from '../web/modules/lllite_fit.js';
// A Tile on the second model of a two-pass canvas: its model line goes up through a LoRA to that model's loader.
const output = {
1: { class_type: 'UNETLoader', inputs: { unet_name: 'Anima\\base.safetensors', weight_dtype: 'default' } },
30: { class_type: 'UNETLoader', inputs: { unet_name: 'Anima\\second.safetensors', weight_dtype: 'default' } },
31: { class_type: 'LoraLoaderModelOnly', inputs: { lora_name: 'style.safetensors', strength_model: 0.8, model: ['30', 0] } },
33: { class_type: 'AnimaLLLiteApply_sdscripts', inputs: { lllite_name: 'animaTileRepair_v20.safetensors', strength: 0.7, model: ['31', 0], image: ['7', 0] } },
34: { class_type: 'KSampler', inputs: { model: ['33', 0], denoise: 0.85 } },
};
assert.deepEqual(llliteUses(output), [{ node: '33', loader: '30', model: 'Anima\\second.safetensors', lllite: 'animaTileRepair_v20.safetensors' }]);
// A checkpoint's loader counts too; a line that ends nowhere, or in a node naming no model file, is left alone.
assert.deepEqual(llliteUses({
4: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'xl.safetensors' } },
5: { class_type: 'AnimaLLLiteApply_sdscripts', inputs: { lllite_name: 'pose.safetensors', model: ['4', 0] } },
}), [{ node: '5', loader: '4', model: 'xl.safetensors', lllite: 'pose.safetensors' }]);
assert.deepEqual(llliteUses({ 5: { class_type: 'AnimaLLLiteApply_sdscripts', inputs: { lllite_name: 'pose.safetensors', model: ['9', 0] } } }), []);
assert.deepEqual(llliteUses({
4: { class_type: 'SomeModelMaker', inputs: {} },
5: { class_type: 'AnimaLLLiteApply_sdscripts', inputs: { lllite_name: 'pose.safetensors', model: ['4', 0] } },
}), []);
assert.deepEqual(llliteUses({ 5: { class_type: 'AnimaLLLiteApply_sdscripts', inputs: { lllite_name: 'pose.safetensors' } } }), [], 'no model line');
assert.deepEqual(llliteUses({ 1: output[1], 34: { class_type: 'KSampler', inputs: { model: ['1', 0] } } }), []);
assert.deepEqual(llliteUses(null), []);
// The prompt is only read for a canvas that has such a node, in a subgraph too.
assert.equal(usesLllite({ _nodes: [{ type: 'KSampler' }] }), false);
assert.equal(usesLllite({ _nodes: [{ type: 'KSampler' }, { type: 'AnimaLLLiteApply_sdscripts' }] }), true);
assert.equal(usesLllite({ _nodes: [{ type: 'group', subgraph: { _nodes: [{ type: 'AnimaLLLiteApply_sdscripts' }] } }] }), true);
assert.equal(usesLllite(null), false);
// The AI hears which nodes, the two counts, both ways out, and that a model the user named is theirs to change.
const words = llliteWords({ node: '33', loader: '30', model: 'second.safetensors', lllite: 'animaTileRepair_v20.safetensors', blocks: 40, made_for: 28 });
for (const part of ['Nothing was run', 'node #33', 'loader #30', 'second.safetensors', '28 blocks', 'has 40', 'set_model', 'remove_nodes', '0.45', 'ask_user']) {
assert.ok(words.includes(part), part);
}
console.log('lllite_fit: ok');
+13 -1
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import { partnerBox, planPromptFill, promptBoxes, promptNameRole, typeTakesPrompt, wiredRole } from '../web/modules/prompt_boxes.js';
import { partnerBox, planPromptFill, promptBoxes, promptMissing, promptNameRole, typeTakesPrompt, wiredRole } from '../web/modules/prompt_boxes.js';
const multiline = ['STRING', { multiline: true }];
globalThis.LiteGraph = { registered_node_types: {
@@ -97,4 +97,16 @@ const text = value => ({ name: 'text', value });
assert.deepEqual(planPromptFill(loose, both).map(e => e.value), ['a cat']);
}
// A run that would draw from nothing with nothing asked for: an empty latent, and every positive prompt empty.
{
const drawing = [{ type: 'UNETLoader' }, { type: 'EmptyLatentImage' }, { type: 'KSampler' }];
assert.equal(promptMissing(drawing, ['']), true);
assert.equal(promptMissing(drawing, ['', ' ']), true);
assert.equal(promptMissing([{ type: 'EmptySD3LatentImage' }], ['']), true, 'an empty latent of another family too');
assert.equal(promptMissing(drawing, ['', 'a cat']), false, 'one prompt is enough');
assert.equal(promptMissing(drawing, []), false, 'no prompt box to fill');
assert.equal(promptMissing([{ type: 'LoadImage' }, { type: 'VAEEncode' }, { type: 'KSampler' }], ['']), false,
'a redraw of a picture may go without words');
}
console.log('prompt boxes: definition, wiring roles, partners and filling OK');
+2
View File
@@ -21,6 +21,8 @@ class DecisionTests(unittest.TestCase):
self.assertEqual(answer["related"], [run["id"]], "only this task's steps")
step = ai_tasks.get_task(self.task)["steps"][-1]
self.assertEqual((step["kind"], step["decision"]["by"], step["decision"]["rejected"]), ("decision", "together", ["晨光森林"]))
sentence = ai_decisions.log("Gemini", "放大用的模型", "换成 2B 的", rejected="保留 2.9B 的模型")
self.assertEqual(sentence["decision"]["rejected"], ["保留 2.9B 的模型"], "one option given as a sentence stays one")
with self.assertRaises(ToolError):
mcp_task_tools.log_decision(self.ctx, "背景", "")
with self.assertRaises(ToolError):
+27 -22
View File
@@ -46,8 +46,6 @@ class BlockTests(unittest.TestCase):
{"type": "params", "items": [{"label": "种子", "value": 42}]},
CHOICES,
{"type": "input", "question": "还想改什么?"},
{"type": "progress", "label": "放大", "value": 150, "max": 100},
{"type": "divider", "extra": "ignored"},
])
self.assertEqual(blocks[0]["text"], "两张对比")
self.assertEqual(blocks[1], {"type": "text", "text": "<b>不是网页</b>", "tone": "normal"}, "text stays text")
@@ -55,8 +53,6 @@ class BlockTests(unittest.TestCase):
self.assertEqual(blocks[3]["rows"], [["步数", "40"], ["CFG", "7.5"]])
self.assertEqual(blocks[6]["options"][1], {"id": "b", "label": "右边", "detail": ""})
self.assertEqual(blocks[7]["id"], "q8", "a question without an id gets one")
self.assertEqual(blocks[8]["value"], 100)
self.assertEqual(blocks[9], {"type": "divider"})
def test_refused(self):
bad = [
@@ -69,7 +65,8 @@ class BlockTests(unittest.TestCase):
[{"type": "choices", "options": [{"id": "x", "label": "1"}, {"id": "x", "label": "2"}]}],
[CHOICES, CHOICES],
[{"type": "models", "items": ["loras:0:../../x.safetensors"]}],
[{"type": "progress", "value": "half"}],
[{"type": "progress", "value": 1, "max": 4}], # the AI's own progress bar and the divider are no blocks any more
[{"type": "divider"}],
[{"type": "heading", "text": {"a": 1}}],
[{"type": "text", "text": "x"}] * 31,
]
@@ -230,12 +227,12 @@ class AnswerTests(unittest.TestCase):
self.assertEqual([m["text"] for m in got["messages"]], ["先别跑了"])
def test_update_and_close(self):
shown = ai_panels.show("A", "进度", [{"type": "progress", "value": 1, "max": 4}, CHOICES])
ai_panels.update(shown["id"], blocks=[{"type": "progress", "value": 3, "max": 4}, CHOICES])
shown = ai_panels.show("A", "设置", [{"type": "params", "items": [{"label": "步数", "value": 20}]}, CHOICES])
ai_panels.update(shown["id"], blocks=[{"type": "params", "id": shown["blocks"][0]["id"], "items": [{"label": "步数", "value": 30}]}, CHOICES])
task_id = ai_tasks.list_tasks()[0]["id"]
step = ai_tasks.get_task(task_id)["steps"][0]
self.assertEqual(step["panel"]["blocks"][0]["value"], 3)
self.assertGreaterEqual(step["panel"]["changed"], step["at"]) # when its numbers were written
self.assertEqual([block["type"] for block in step["panel"]["blocks"]], ["params", "choices"])
self.assertEqual(str(step["panel"]["blocks"][0]["items"][0]["value"]), "30")
ai_panels.update(shown["id"], closed=True)
with self.assertRaises(ai_panels.PanelError):
ai_panels.answer(task_id, step["id"], "pick", "a")
@@ -267,16 +264,16 @@ class AnswerTests(unittest.TestCase):
self.assertEqual(ai_tasks.get_task(task_id)["steps"][0]["panel"]["blocks"], blocks,
"chronological order survives reload")
def test_partial_update_keeps_earlier_content_answers_and_progress_position(self):
shown = ai_panels.show("A", "过程", [CHOICES, {"type": "progress", "value": 1, "max": 4}])
def test_partial_update_keeps_earlier_content_answers_and_a_corrected_blocks_position(self):
shown = ai_panels.show("A", "过程", [CHOICES, {"type": "params", "items": [{"label": "步数", "value": 20}]}])
task_id = ai_tasks.list_tasks()[0]["id"]
step = ai_tasks.get_task(task_id)["steps"][0]
ai_panels.answer(task_id, step["id"], "pick", "a")
progress_id = shown["blocks"][1]["id"]
params_id = shown["blocks"][1]["id"]
changed = ai_panels.update(shown["id"], blocks=[{"type": "text", "text": "结果好了"},
{"type": "progress", "id": progress_id, "value": 4, "max": 4}])
self.assertEqual([b["type"] for b in changed["blocks"]], ["choices", "progress", "text"])
self.assertEqual(changed["blocks"][1]["value"], 4)
{"type": "params", "id": params_id, "items": [{"label": "步数", "value": 30}]}])
self.assertEqual([b["type"] for b in changed["blocks"]], ["choices", "params", "text"])
self.assertEqual(str(changed["blocks"][1]["items"][0]["value"]), "30")
self.assertEqual(ai_panels.get_answers("A")["answers"][0]["choice"], ["a"])
self.assertEqual(ai_panels.get_answers("A")["answers"], [])
@@ -286,9 +283,9 @@ class AnswerTests(unittest.TestCase):
before = copy.deepcopy(ai_tasks.get_task(task_id))
bad = [
[{"type": "html"}],
[{"type": "progress", "id": shown["blocks"][0]["id"], "value": 1}],
[{"type": "heading", "id": shown["blocks"][0]["id"], "text": "换了种类"}],
[{"type": "text", "text": str(i)} for i in range(ai_panels.MAX_BLOCKS)],
[{"type": "text", "id": "same", "text": "a"}, {"type": "divider", "id": "same"}],
[{"type": "text", "id": "same", "text": "a"}, {"type": "heading", "id": "same", "text": "b"}],
]
for blocks in bad:
with self.subTest(blocks=blocks), self.assertRaises(ai_panels.PanelError):
@@ -314,16 +311,24 @@ class AnswerTests(unittest.TestCase):
self.assertEqual(ai_panels.get_answers("A")["answers"][0]["text"], "海边")
def test_legacy_blocks_keep_order_when_updated(self):
plan = [{"type": "text", "text": "之前的方案"}, {"type": "progress", "value": 1}]
plan = [{"type": "text", "text": "之前的方案"}, {"type": "heading", "text": "设置"}]
shown = ai_panels.show("A", "旧面板", plan)
for block in shown["blocks"]:
block.pop("id") # old records did not give static blocks handles
changed = ai_panels.update(shown["id"], blocks=[{"type": "text", "text": "后来的结果"},
plan[0], {"type": "progress", "value": 100}])
self.assertEqual([b["type"] for b in changed["blocks"]], ["text", "progress", "text"])
self.assertEqual(changed["blocks"][1]["value"], 100)
changed = ai_panels.update(shown["id"], blocks=[{"type": "text", "text": "后来的结果"}, plan[0], plan[1]])
self.assertEqual([b["type"] for b in changed["blocks"]], ["text", "heading", "text"])
self.assertEqual(changed["blocks"][2]["text"], "后来的结果", "what was sent again keeps its place, the new one goes below")
self.assertEqual(len({b["id"] for b in changed["blocks"]}), 3)
def test_a_panel_kept_with_a_block_no_longer_drawn_can_still_be_added_to(self):
shown = ai_panels.show("A", "旧面板", [{"type": "text", "text": "之前"}])
with ai_tasks._lock: # a record of 1.63: the AI's own progress bar
task, step = ai_panels._find(shown["id"])
step["panel"]["blocks"].append({"type": "progress", "id": "old", "label": "放大", "value": 1, "max": 4})
ai_tasks._save(task)
changed = ai_panels.update(shown["id"], blocks=[{"type": "text", "text": "后来"}])
self.assertEqual([b["type"] for b in changed["blocks"]], ["text", "progress", "text"], "the record is kept as it was")
def test_attach_pending_delivers_once_and_skips_errors(self):
shown = ai_panels.show("Claude", "挑一张", [CHOICES])
+2 -1
View File
@@ -14,7 +14,7 @@ from unittest import mock
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1]), str(PLUGIN / "tests")]
from api import ai_decisions, ai_gates, ai_privacy, ai_tasks, mcp_apps, mcp_server, mcp_task_tools # noqa: E402
from api import ai_decisions, ai_gates, ai_privacy, ai_sealed_models, ai_tasks, mcp_apps, mcp_server, mcp_task_tools # noqa: E402
from test_mcp import rpc # noqa: E402 (its module keeps tasks and gates in temporary folders)
SETTING = os.path.join(tempfile.mkdtemp(prefix="amb_privacy_"), "ai_privacy.json")
@@ -34,6 +34,7 @@ class PrivacyTests(unittest.TestCase):
if os.path.exists(SETTING):
os.remove(SETTING)
for patcher in (mock.patch.object(ai_privacy, "_path", lambda: SETTING),
mock.patch.object(ai_sealed_models, "_library", return_value=[]), # no model library here
mock.patch.object(mcp_server, "_node_list", return_value={})): # no ComfyUI here
patcher.start()
self.addCleanup(patcher.stop)
+7
View File
@@ -145,6 +145,13 @@ class StopTests(unittest.TestCase):
told = [item for item in steps if item["tool"] == "user_verdict"]
self.assertEqual(steps[0]["verdict"], "bad")
self.assertTrue(told and not told[0]["delivered"] and "did not turn out well" in told[0]["text"], "the AI is told")
ai_run_control.set_verdict(self.task, self.step["id"], "bad", ["dark", "hands", "nope"])
steps = ai_tasks.get_task(self.task)["steps"]
told = [item for item in steps if item["tool"] == "user_verdict"]
self.assertEqual(len(told), 1, "a word the AI has not fetched yet is corrected, not said twice")
self.assertIn("the hands are wrong; too dark. Change what causes that", told[0]["text"])
self.assertEqual((steps[0]["issues"], told[0]["issues"]), (["hands", "dark"], ["hands", "dark"]), "what is off, in the list's order")
told[0]["delivered"] = True
ai_run_control.set_verdict(self.task, self.step["id"], None)
self.assertNotIn("verdict", ai_tasks.get_task(self.task)["steps"][0], "taken back")
with self.assertRaises(ai_run_control.RunControlError):
+126 -9
View File
@@ -1,7 +1,7 @@
"""Prompt text and picture names of a private task as tokens (api/ai_sealed.py): sealed on the way
out of every tool, put back into a call's arguments, the user's prompt box handing the AI tokens
only, the user's own changes told sealed. Canary words stand for what must never reach the AI.
Temporary folders only."""
"""Prompt text, picture names and model names of a private task as tokens (api/ai_sealed.py,
api/ai_sealed_models.py): sealed on the way out of every tool, put back into a call's arguments, the
user's prompt box handing the AI tokens only (and the gist as written), the user's own changes told
sealed. Canary words stand for what must never reach the AI. Temporary folders only."""
import json
import os
import sys
@@ -14,11 +14,15 @@ from unittest import mock
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1]), str(PLUGIN / "tests")]
from api import ai_panels, ai_privacy, ai_sealed, ai_task_changes, ai_tasks, mcp_apps, mcp_server, mcp_task_tools # noqa: E402
from api import ai_callers, ai_panels, ai_privacy, ai_sealed, ai_sealed_models, ai_sent, ai_task_changes, ai_tasks, mcp_apps, mcp_server, mcp_task_tools # noqa: E402
from test_mcp import rpc # noqa: E402 (its module keeps tasks and gates in temporary folders)
SETTING = os.path.join(tempfile.mkdtemp(prefix="amb_sealed_"), "ai_privacy.json")
CANARIES = ("canarypos", "canaryneg", "canaryfile", "canarytrigger", "canaryecho")
CANARIES = ("canarypos", "canaryneg", "canaryfile", "canarytrigger", "canaryecho", "canarycombo", "canaryflow", "canarytag", "canarysaved")
MODEL_CANARIES = ("canarylora", "Canarycard", "canaryfolder", "canarysha", "civitai.com", "canarymissing")
LORA = "loras:0:canaryfolder/canarylora v2.safetensors"
LIBRARY = [{"id": LORA, "name": "Canarycard Pose", "file": "canarylora v2.safetensors"},
{"id": "checkpoints:0:Anima.safetensors", "name": "Anima", "file": "Anima.safetensors"}]
INFO = {"CLIPTextEncode": {"input": {"required": {"text": ["STRING", {"multiline": True}], "clip": ["CLIP"]}}},
"KSampler": {"input": {"required": {"sampler_name": [["euler", "dpmpp_2m"]], "steps": ["INT", {}]}}},
"SaveImage": {"input": {"required": {"filename_prefix": ["STRING", {}]}}}}
@@ -31,12 +35,31 @@ def canary_result(arguments=None):
{"id": 9, "type": "KSampler", "settings": {"sampler_name": "euler", "steps": 20, "control_after_generate": "fixed"}},
{"id": 11, "class_type": "SaveImage", "inputs": {"filename_prefix": "canaryfile"}},
{"id": 12, "type": "SomePackNode", "settings": {"wildcard": "canarypos again", "model": "flux.safetensors"}}],
"prompts": [{"name": "kept", "positive": "canarypos", "negative": "canaryneg"}],
"prompts": [{"name": "canarysaved words", "positive": "canarypos", "negative": "canaryneg"}],
"workflows": [{"name": "canaryflow card", "tags": ["canarytag"], "route": "2 passes"}],
"pictures": [{"made_with": {"recipe": "canaryflow card", "combo": "Some LoRA · canarycombo, 1girl"}}],
"models": [{"in_cards": ["canaryflow card"]}],
"combos": [{"name": "Some LoRA · canarycombo, 1girl", "base_model": "Anima"}, {"name": "2", "base_model": "Anima"}],
"images": [{"image": "sub/canaryfile_00001_.png"}, {"filename": "canaryfile_00002_.png", "subfolder": ""}],
"trigger_words": ["canarytrigger"], "echo": json.dumps(arguments or {}), "filled": {"prompt": ["6"]}}
return {"content": [{"type": "text", "text": json.dumps(data)}], "structuredContent": data, "isError": False}
def model_result():
"""A tool's answer naming models every way they are written: a model's own entry, a loader's value,
a sentence, a name cut from a list, a file the library does not have."""
data = {"models": [{"model_id": LORA, "name": "Canarycard Pose", "file": "canarylora v2.safetensors", "folder": "canaryfolder",
"type": "loras", "base_model": "Anima", "trigger_words": ["canarytrigger"], "note_for_ai": "two-person poses",
"sha256": "canarysha", "civitai_url": "https://civitai.com/models/1", "author_weight": "0.7"},
{"model_id": "checkpoints:0:Anima.safetensors", "name": "Anima", "type": "checkpoints", "base_model": "Anima"}],
"nodes": [{"id": 4, "type": "LoraLoader", "settings": {"lora_name": "canaryfolder\\canarylora v2.safetensors", "strength_model": 0.7}}],
"note": "Loaded Canarycard Pose from canarylora v2.safetensors; an Anima LoRA.",
"more_names": ["Canarycard Pose"], "needs": ["canarymissing thing.safetensors"],
"error": "not in list: my_canarymissing.safetensors"}
return {"content": [{"type": "text", "text": json.dumps(data)}, {"type": "text", "text": "see canaryfolder/canarylora v2.safetensors"}],
"structuredContent": data, "isError": False}
class SealedTests(unittest.TestCase):
def setUp(self):
ai_tasks._tasks, ai_tasks._current = None, {}
@@ -45,6 +68,7 @@ class SealedTests(unittest.TestCase):
if os.path.exists(SETTING):
os.remove(SETTING)
for patcher in (mock.patch.object(ai_privacy, "_path", lambda: SETTING),
mock.patch.object(ai_sealed_models, "_library", return_value=LIBRARY),
mock.patch.object(mcp_server, "_node_list", return_value=INFO),
mock.patch.object(mcp_task_tools, "_canvas_model", return_value={"name": "Anima/K.safetensors", "params": 2_100_000_000})):
patcher.start()
@@ -73,10 +97,56 @@ class SealedTests(unittest.TestCase):
self.assertRegex(nodes[0]["settings"]["text"], r"^\[P\d+\]$")
self.assertEqual(json.loads(result["content"][0]["text"]), result["structuredContent"], "the text says the same")
self.assertEqual(result["structuredContent"]["filled"], {"prompt": ["6"]}, "a node id is no prompt")
combos = result["structuredContent"]["combos"]
self.assertRegex(combos[0]["name"], r"^\[P\d+\]$", "a combo's name holds its model and prompt")
self.assertEqual(ai_sealed.expand(self.task, {"name": combos[0]["name"]}), {"name": "Some LoRA · canarycombo, 1girl"}, "and is placed by its token")
self.assertEqual(combos[1], {"name": "2", "base_model": "Anima"})
flow = result["structuredContent"]["workflows"][0]
self.assertRegex(flow["name"], r"^\[P\d+\]$", "a card's name is the user's words")
self.assertEqual((flow["route"], result["structuredContent"]["models"][0]["in_cards"]), ("2 passes", [flow["name"]]), "one name, one token")
def test_a_model_is_one_token_however_it_is_written(self):
result = ai_sealed.mask(model_result(), self.task, INFO, "search_models", True)
text = json.dumps(result, ensure_ascii=False)
self.assertFalse([word for word in CANARIES + MODEL_CANARIES if word in text], text)
data = result["structuredContent"]
self.assertEqual(data["models"][0], {"model_id": "[M1]", "type": "loras", "base_model": "Anima", "trigger_words": ["[P1]"],
"note_for_ai": "two-person poses", "author_weight": "0.7"}, "what it is picked by stays")
self.assertEqual(data["models"][1], {"model_id": "[M2]", "type": "checkpoints", "base_model": "Anima"})
self.assertEqual(data["nodes"][0]["settings"], {"lora_name": "[M1]", "strength_model": 0.7})
self.assertEqual(data["note"], "Loaded [M1] from [M1]; an Anima LoRA.", "a one-word name stays a word in a sentence")
self.assertEqual(data["more_names"], ["[M1]"])
self.assertRegex(data["needs"][0], r"^\[M\d+\]$", "a file the library does not have is a token of its own")
self.assertRegex(data["error"], r"^not in list: \[M\d+\]$", "and is not cut where a known name ends in it")
self.assertEqual(result["content"][1]["text"], "see [M1]")
self.assertEqual(json.loads(result["content"][0]["text"]), data)
kept = json.dumps(ai_sealed.mask(model_result(), self.task, INFO, "search_models"))
self.assertIn("canarylora v2.safetensors", kept, "names the user lets through stay")
def test_a_models_token_goes_back_as_the_place_takes_it(self):
ai_sealed.mask(model_result(), self.task, INFO, "search_models", True)
loader = os.path.join("canaryfolder", "canarylora v2.safetensors")
self.assertEqual(ai_sealed.expand(self.task, {"model_id": "[M1]", "plan": [{"model": "[M2]"}], "items": ["[M1]"],
"values": {"lora_name": "[M1]"}, "text": "used [M1] at 0.7"}),
{"model_id": LORA, "plan": [{"model": "checkpoints:0:Anima.safetensors"}], "items": [LORA],
"values": {"lora_name": loader}, "text": "used Canarycard Pose at 0.7"})
def test_the_user_may_let_model_names_through(self):
self.assertTrue(ai_privacy.hides_models(), "hidden unless the user says otherwise")
self.assertNotIn("canarylora", json.dumps(ai_privacy.guard(model_result(), True, self.task, INFO)))
ai_privacy.set_default(True)
ai_privacy.set_hides_models(False)
self.assertTrue(ai_privacy.default_on(), "one setting leaves the other")
self.assertIn("canarylora", json.dumps(ai_privacy.guard(model_result(), True, self.task, INFO)))
ai_privacy.set_default(False)
self.assertFalse(ai_privacy.hides_models())
def test_no_tool_lets_a_prompt_or_a_picture_name_through(self):
def answer(tools, name, arguments, ctx=None):
return canary_result(arguments)
result = canary_result(arguments)
result["structuredContent"]["found"] = model_result()["structuredContent"]
result["content"][0]["text"] = json.dumps(result["structuredContent"])
return result
with mock.patch.object(mcp_server, "call_tool", side_effect=answer), \
mock.patch.object(mcp_server.ai_gates, "check", return_value=None), \
mock.patch.object(mcp_server.ai_task_runs, "watch"), \
@@ -84,7 +154,7 @@ class SealedTests(unittest.TestCase):
for name in mcp_server.TOOLS:
with self.subTest(tool=name):
text = json.dumps(rpc("tools/call", {"name": name, "arguments": {"task": self.task}}, name))
self.assertFalse([word for word in CANARIES if word in text], f"{name}: {text[:300]}")
self.assertFalse([word for word in CANARIES + MODEL_CANARIES if word in text], f"{name}: {text[:300]}")
ai_privacy.set_private(self.task, False)
text = json.dumps(rpc("tools/call", {"name": "describe_canvas", "arguments": {"task": self.task}}, "describe_canvas"))
self.assertIn("canarypos", text, "a task that is not private sees it all")
@@ -114,6 +184,53 @@ class SealedTests(unittest.TestCase):
record = ai_tasks.get_task(self.task)
self.assertNotIn("canarypos", json.dumps([(s.get("summary"), s.get("text")) for s in record["steps"]]), "the record keeps tokens")
def test_what_a_private_tasks_ai_was_sent_is_kept_for_the_user_to_read(self):
def answer(tools, name, arguments, ctx=None):
return canary_result(arguments)
ai_sealed.seal_answer(self.task, ["canarypos, 1girl"])
with mock.patch.object(mcp_server, "call_tool", side_effect=answer), mock.patch.object(mcp_server.ai_gates, "check", return_value=None):
rpc("tools/call", {"name": "set_prompt", "arguments": {"task": self.task, "prompt": "[P1], masterpiece"}}, "set_prompt")
kept = ai_sent.calls(self.task)
self.assertEqual([(call["tool"], call["error"], call["pictures"]) for call in kept], [("set_prompt", False, 0)])
self.assertIn("[P1], masterpiece", kept[0]["arguments"], "what the AI wrote, tokens and all")
self.assertFalse([word for word in CANARIES + MODEL_CANARIES if word in json.dumps(kept, ensure_ascii=False)], "as it left, not as the tool made it")
self.assertIn("[P1]", kept[0]["text"])
ai_privacy.set_private(self.task, False)
rpc("tools/call", {"name": "describe_canvas", "arguments": {"task": self.task}}, "describe_canvas")
self.assertEqual(len(ai_sent.calls(self.task)), 1, "only while the task is private")
with mock.patch.object(ai_sent, "MAX_CALLS", 3), mock.patch.object(ai_sent, "SPARE", 2), mock.patch.object(ai_sent, "MAX_TEXT", 10):
for at in range(8):
ai_sent.keep(self.task, f"tool{at}", {}, {"content": [{"type": "text", "text": "x" * 25}, {"type": "image"}]})
newest = ai_sent.calls(self.task)
self.assertEqual([call["tool"] for call in newest], ["tool5", "tool6", "tool7"], "the newest are kept")
self.assertEqual((newest[0]["text"], newest[0]["pictures"]), ("x" * 10 + "… (+15)", 1))
ai_tasks.remove_task(self.task)
self.assertEqual(ai_sent.calls(self.task), [], "gone with the task")
def test_an_app_that_reads_the_disk_is_told_apart(self):
self.assertEqual([ai_callers.reads_disk(name) for name in ("Claude Code", "antigravity-client", "Cursor", "Gemini", "Cherry Studio", "Claude", "", None)],
[True, True, True, True, False, False, False, False])
def test_the_gist_reaches_the_ai_as_written_and_may_stand_alone(self):
shown = rpc("tools/call", {"name": "show_panel", "arguments": {"task": self.task, "title": "Prompts", "blocks": [
{"type": "prompts", "id": "p", "question": "What to draw?"}]}}, "show_panel")
task, step = ai_panels._find(shown["result"]["structuredContent"]["panel"])
with self.assertRaises(ValueError):
ai_sealed.seal_answer(self.task, [], "", None, " ")
extra = ai_sealed.seal_answer(self.task, [], "", None, "red-haired girl, rainy night, car window")
self.assertEqual((extra["prompts"], extra["gist"]), ([], "red-haired girl, rainy night, car window"))
ai_panels.answer(self.task, step["id"], "p", text=extra["text"], extra=extra)
got = rpc("tools/call", {"name": "get_answers", "arguments": {"task": self.task}}, "get_answers")["result"]["structuredContent"]
self.assertEqual(got["answers"][0]["gist"], "red-haired girl, rainy night, car window")
both = ai_sealed.seal_answer(self.task, ["canarypos, 1girl"], "", None, "a girl by a window")
self.assertEqual((both["prompts"], both["gist"]), (["[P1]"], "a girl by a window"))
self.assertNotIn("canarypos", json.dumps(both))
marked = ai_sealed.seal_answer(self.task, [], "", None, "a girl in \u27e6canaryecho dress\u27e7 by a window")
self.assertRegex(marked["gist"], r"^a girl in \[P\d+\] by a window$", "the words the user marked are a token inside it")
self.assertEqual(ai_sealed.expand(self.task, marked["gist"]), "a girl in canaryecho dress by a window")
left = ai_panels.leave_message(self.task, "make the \u27e6canaryneg\u27e7 bigger, \u27e6 \u27e7 please")
self.assertRegex(left["text"], r"^make the \[P\d+\] bigger, please$", "and in a message they leave")
def test_the_users_changes_are_told_sealed(self):
ai_tasks.record("test", "describe_canvas", {}, {"structuredContent": {}, "content": []}, time.time(), True, task_id=self.task)
entry = {"id": "e1", "time": time.time(), "source": "canvas", "action": "canvas",
+30 -7
View File
@@ -12,7 +12,7 @@ from unittest import mock
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1]), str(PLUGIN / "tests")]
from api import ai_skills, ai_tasks, mcp_actions, mcp_task_tools # noqa: E402
from api import ai_skills, ai_tasks, mcp_actions, mcp_fit, mcp_task_tools, reference_blocks # noqa: E402
from api.mcp_tools import ToolError # noqa: E402
from test_ai_tasks import isolate, ok # noqa: E402
@@ -53,20 +53,43 @@ class SkillTests(unittest.TestCase):
with mock.patch.object(mcp_task_tools, "_blocks", lambda ctx: {}):
listed = mcp_task_tools.begin_task(self.ctx, "a picture in two passes")["skills"]
self.assertIn("staged_picture", listed)
advice = " ".join(mcp_task_tools.get_toolbox(self.ctx, skill="staged_picture")["advice"])
for way in ('want="midway"', 'want=\"detail\"', 'use="refine"', "its own copy of the model"):
self.assertIn(way, advice)
self.assertIn("may cross families", advice)
answer = mcp_task_tools.get_toolbox(self.ctx, skill="staged_picture")
advice, ways = " ".join(answer["advice"]), " ".join(answer["ways"])
for way in ("use='refine', want='midway'", "use='upscale', want='tile'", "use='refine':", "its own model copy",
"use='refine', want='character'", "use='refine', want='background'", "use='upscale', want='character'",
"may cross families", "may enlarge at the switch", "draws the blur sharp"):
self.assertIn(way, ways)
self.assertNotIn("want=", advice, "which ways there are is told from the blocks, not written into the skill")
self.assertIn('"ways" lists those this ComfyUI can run now', advice)
self.assertIn("add_lora it onto the second model", advice)
self.assertIn("Never add a style, artist or LoRA the user did not pick", advice)
self.assertIn("hold the base's seed yourself", advice)
self.assertIn("control_after_generate", advice)
self.assertIn("base_only=true", advice)
self.assertIn("the switch may also enlarge the picture", advice)
self.assertIn("does not sit on its bench", advice)
ai_skills.set_on("staged_picture", False)
self.assertNotIn("staged_picture", ai_skills.directory())
with self.assertRaises(ai_skills.SkillError):
ai_skills.get("staged_picture")
def test_a_skill_lists_only_the_ways_that_can_run_here(self):
every = ai_skills.get("staged_picture")["ways"]
self.assertEqual(len(every), len([block for block in reference_blocks.BLOCKS.values() if block["job"] in ("refine", "upscale")]),
"each way of the skill's uses says when it is the one to take")
own_nodes = {kind: {} for kind in ("CheckpointLoaderSimple", "KSampler")} # no pack, no upscale model
here = ai_skills.get("staged_picture", info=own_nodes)["ways"]
self.assertEqual([line.split(":")[0] for line in here], ["use='refine'", "use='refine', want='midway'"])
with mock.patch.object(reference_blocks, "turned_off", return_value={"switch_midway"}):
self.assertEqual([line.split(":")[0] for line in ai_skills.get("staged_picture", info=own_nodes)["ways"]], ["use='refine'"])
self.assertNotIn("ways", ai_skills.get("prompt_pose"), "a skill that names no uses lists none")
def test_every_answer_fits_what_an_ai_app_shows_in_the_chat(self):
for name, skill in ai_skills.SKILLS.items():
forms = ({"name": "a.safetensors", "params": params} for params in (0, 1, 10 ** 11)) if skill.get("by_model") else (None,)
for model in forms:
with self.subTest(skill=name, model=model):
answer = ai_skills.get(name, model, bool(skill.get("private_only"))) # every way listed: the longest it gets
self.assertLessEqual(mcp_fit.size(answer), mcp_fit.BUDGET)
def test_the_advice_goes_by_the_size_of_the_canvas_model(self):
def advice(model):
with mock.patch.object(mcp_task_tools, "_canvas_model", return_value=model):
+2 -2
View File
@@ -131,8 +131,8 @@ class ToolboxTests(unittest.TestCase):
step = ai_toolbox.pick_way(self.task, "upscale_tile", True)
box = ai_tasks.get_task(self.task)["toolbox"]
self.assertEqual((box["ways"], box["nodes"], box["mode"]), (["upscale_tile"], ["FaceDetailer"], "only"))
self.assertIn("add_nodes(use='upscale', want='detail')", step["text"])
self.assertEqual(mcp_task_tools.get_toolbox(self.ctx)["ways"], ["add_nodes(use='upscale', want='detail')"])
self.assertIn("add_nodes(use='upscale', want='tile')", step["text"])
self.assertEqual(mcp_task_tools.get_toolbox(self.ctx)["ways"], ["add_nodes(use='upscale', want='tile')"])
self.assertEqual(reference_blocks.pick("upscale", None, None, False, box["ways"]), "upscale_tile", "the AI asked for none")
self.assertEqual(reference_blocks.pick("upscale", "2x", None, False, box["ways"]), "upscale_tile", "the AI asked for another")
self.assertEqual(reference_blocks.pick("upscale", "2x", None, False, ["upscale_tile", "upscale_tiled"]), "upscale_tiled")
+93
View File
@@ -0,0 +1,93 @@
"""A chain of ready-made blocks (api/block_chain.py): what each block gives the one after it, the chain steps
taking the step before's picture, and the chain built on a new tab with each step anchored to the one before."""
import json
import sys
import unittest
from pathlib import Path
from unittest import mock
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
from api import block_chain, mcp_actions, mcp_canvas_edit, reference_blocks # noqa: E402
from api.mcp_tools import ToolError # noqa: E402
A, B, LORA = "diffusion_models:0:a.safetensors", "diffusion_models:0:b.safetensors", "loras:0:style.safetensors"
def node_sets(block):
return [block["nodes"]] + [variant["nodes"] for variant in (block.get("variants") or {}).values() if variant.get("nodes")]
class BlockEndTests(unittest.TestCase):
def test_every_block_says_what_it_gives(self):
for name, block in reference_blocks.BLOCKS.items():
with self.subTest(block=name):
self.assertIn("gives", block, "say {} for a block that changes the step it is put on")
self.assertLessEqual(set(block["gives"]), {"sampler", "save"})
for nodes in node_sets(block):
for key in block["gives"].values():
self.assertIn(key, nodes)
self.assertFalse(nodes[key].get("optional"), "a node a next block goes on from is always there")
own = block.get("own_model")
if own:
self.assertTrue(any(key in block["nodes"] for key in own["nodes"]))
def test_a_chain_step_works_on_the_picture_of_the_step_before(self):
for name, block in reference_blocks.BLOCKS.items():
chained = "save" in block["gives"] and not block.get("standalone") and block.get("takes") not in ("picture", "reference")
for nodes in node_sets(block) if chained else ():
for key, spec in nodes.items():
with self.subTest(block=name, node=key):
self.assertNotIn(["$decode", 0], list(spec.get("inputs", {}).values()),
"$save.images: after an enlargement the decode's picture is the older one")
class ChainTests(unittest.TestCase):
def run_chain(self, steps, answers):
calls, answers = [], iter(answers)
with mock.patch.object(mcp_canvas_edit, "new_workflow", side_effect=lambda ctx, name: calls.append(("new", name))), \
mock.patch.object(mcp_canvas_edit, "add_nodes", side_effect=lambda ctx, **kw: (calls.append(("add", kw)), next(answers))[1]), \
mock.patch.object(mcp_actions, "add_lora", side_effect=lambda ctx, lora, after_node_id: calls.append(("lora", after_node_id))), \
mock.patch.object(mcp_actions, "set_model", side_effect=lambda ctx, node, model: calls.append(("model", node, model))):
return calls, block_chain.build({}, steps, "plan")
def test_each_step_goes_on_from_what_the_one_before_gave(self):
calls, built = self.run_chain(
[{"use": "picture", "model": A, "loras": [LORA]}, {"use": "refine", "model": B, "loras": [LORA]},
{"use": "follow", "want": "pose", "image": "pose.png"}, {"use": "detail"}, {"use": "upscale"}],
[{"block": "picture", "nodes": {"model": "1", "sampler": "5", "save": "7"}},
{"block": "refine_other_model", "nodes": {"second_model": "20", "redraw": "24", "save": "26"}},
{"block": "control_pose", "nodes": {"look": "30"}},
{"block": "detail_face_hands", "nodes": {"face": "40", "hands": "41", "save": "42"}},
{"block": "upscale", "nodes": {"redraw": "50", "save": "53"}}])
self.assertEqual(calls, [
("new", "plan"), ("add", {"use": "picture", "model": A}), ("lora", 1),
("add", {"use": "refine", "near_node_id": 5, "save_node_id": 7}), ("model", 20, B), ("lora", 20),
("add", {"use": "follow", "want": "pose", "image": "pose.png", "near_node_id": 24, "save_node_id": 26}),
("add", {"use": "detail", "near_node_id": 24, "save_node_id": 26}),
("add", {"use": "upscale", "near_node_id": 24, "save_node_id": 42})],
"a pose changes the step it is put on; the details keep the redraw's sampler and give their own picture")
self.assertEqual(built["nodes"]["5.save"], "53")
self.assertEqual([step["block"] for step in built["steps"]][-1], "upscale")
def test_a_copied_model_line_keeps_its_loras(self):
calls, _built = self.run_chain(
[{"use": "picture", "model": A}, {"use": "upscale", "want": "tile", "model": B, "loras": [LORA]}],
[{"block": "picture", "nodes": {"model": "1", "sampler": "5", "save": "7"}},
{"block": "upscale_tile", "nodes": {"own_1": "9", "own": "10", "redraw": "12", "save": "14"}}])
self.assertEqual(calls[-1], ("model", 9, B))
def test_a_plan_starts_with_its_picture_and_a_model_needs_a_loader(self):
with self.assertRaisesRegex(ToolError, "at least its first step"):
self.run_chain([], [])
with self.assertRaisesRegex(ToolError, "starts with the picture"):
self.run_chain([{"use": "upscale"}], [{"block": "upscale", "nodes": {"redraw": "2", "save": "3"}}])
with self.assertRaisesRegex(ToolError, "no loader of its own"):
self.run_chain([{"use": "picture"}, {"use": "upscale", "model": B}],
[{"block": "picture", "nodes": {"model": "1", "sampler": "5", "save": "7"}},
{"block": "upscale", "nodes": {"redraw": "12", "save": "14"}}])
if __name__ == "__main__":
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
"""The model details' Translate (api/description_translation.py): paragraphs in pieces a provider takes, smaller
ones when only MyMemory answers, the answer kept for the same text and language, and nothing sent twice. The
providers are stand-ins; kept in a temporary folder."""
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
from api import ai_tasks, description_translation as dt # noqa: E402
TMP = tempfile.mkdtemp(prefix="amb_desc_tr_")
class TranslationTests(unittest.TestCase):
def setUp(self):
ai_tasks.tasks_dir = lambda: os.path.join(TMP, "ai_tasks")
folder = os.path.join(TMP, "translations")
for name in os.listdir(folder) if os.path.isdir(folder) else []:
os.remove(os.path.join(folder, name))
def test_pieces_cut_at_sentences(self):
text = "One two three. " * 30
parts = dt.pieces(text.strip(), 100)
self.assertTrue(all(len(part) <= 100 for part in parts))
self.assertEqual(" ".join(parts), text.strip())
self.assertEqual(dt.pieces("x" * 250, 100), ["x" * 100, "x" * 100, "x" * 50], "a sentence too long is cut anywhere")
def test_translated_once_and_kept(self):
calls = []
def chain(text, target):
calls.append(text)
return f"[{target}] {text}", "google", None
with mock.patch.object(dt.translation_routes, "translate_chain", side_effect=chain):
first = dt.translate(["Hello.", "", "World."], "zh-CN")
again = dt.translate(["Hello.", "", "World."], "zh-CN")
self.assertEqual(first["paragraphs"], ["[zh-CN] Hello.", "", "[zh-CN] World."])
self.assertEqual((first["kept"], again["kept"], first["engine"]), (False, True, "google"))
self.assertEqual(sorted(calls), ["Hello.", "World."], "the same text is not sent again")
def test_a_long_piece_goes_small_when_only_mymemory_answers(self):
def chain(text, target):
if len(text) > dt.SMALL:
raise RuntimeError("QUERY LENGTH LIMIT EXCEEDED")
return text.upper(), "mymemory", None
long = "Short sentence here. " * 40
with mock.patch.object(dt.translation_routes, "translate_chain", side_effect=chain):
done = dt.translate([long.strip()], "en")
self.assertEqual(done["paragraphs"][0], long.strip().upper())
def test_bad_requests(self):
for paragraphs, target, words in (([], "zh-CN", "1 to"), (["a"], "fr", "target"), (["x" * 30001], "en", "over")):
with self.assertRaisesRegex(ValueError, words):
dt.translate(paragraphs, target)
if __name__ == "__main__":
unittest.main()
+49 -3
View File
@@ -11,14 +11,15 @@ from unittest import mock
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1]), str(PLUGIN / "tests")]
from api import example_workflows as examples, mcp_fill, mcp_tools, reference_blocks # noqa: E402
from api import example_workflows as examples, lllite_fit, mcp_fill, mcp_tools, model_guide, reference_blocks # noqa: E402
OWN_NODES = {"CheckpointLoaderSimple", "UNETLoader", "CLIPLoader", "VAELoader", "CLIPTextEncode", "EmptyLatentImage", "KSampler", "VAEDecode",
"VAEEncode", "VAEEncodeTiled", "VAEDecodeTiled", "SaveImage", "LoadImage", "LatentUpscaleBy", "SetLatentNoiseMask"}
"VAEEncode", "VAEEncodeTiled", "VAEDecodeTiled", "SaveImage", "LoadImage", "LatentUpscaleBy", "SetLatentNoiseMask",
"ImageScaleBy", "ThresholdMask", "GrowMask", "MaskToImage", "ImageBlur", "ImageToMask", "DifferentialDiffusion", "PreviewImage"}
FILES = ("CheckpointLoaderSimple", "UNETLoader", "CLIPLoader", "VAELoader", "AnimaLLLiteApply_sdscripts", "LoadImage")
EVERYTHING = {"CheckpointLoaderSimple": "xl.safetensors", "UNETLoader": "anima.safetensors", "CLIPLoader": "qwen.safetensors",
"VAELoader": "vae.safetensors", "AnimaLLLiteApply_sdscripts": "tile.safetensors", "LoadImage": "a.png"}
ALL_HERE = {kind: {} for kind in examples.LOADERS}
ALL_HERE = {kind: {} for kind in [*examples.LOADERS, *(kind for item in examples.EXAMPLES.values() for kind in item.get("packs") or {})]}
class ExampleTests(unittest.TestCase):
@@ -53,6 +54,24 @@ class ExampleTests(unittest.TestCase):
redraw = of("KSampler")[1]["widgets_values"] # seed, after, steps, cfg, sampler, scheduler, denoise
self.assertEqual(redraw[2:], [way["redraw"]["inputs"][name] for name in ("steps", "cfg", "sampler_name", "scheduler", "denoise")])
def test_the_blurred_background_version_is_the_way_on_anima(self):
way = reference_blocks.BLOCKS["upscale_character"]["variants"]["anima"]["nodes"]
nodes = examples.EXAMPLES["two_pass_anima_character"]["workflow"]["nodes"]
of = lambda kind: [node for node in nodes if node["type"] == kind] # noqa: E731
tile = of("AnimaLLLiteApply_sdscripts")[0]["widgets_values"]
self.assertEqual(tile[1:], [way["tile"]["inputs"][name] for name in ("strength", "start_percent", "end_percent", "preserve_wrapper")])
redraw = of("KSampler")[1]["widgets_values"]
self.assertEqual(redraw[2:], [way["redraw"]["inputs"][name] for name in ("steps", "cfg", "sampler_name", "scheduler", "denoise")])
self.assertEqual((of("GrowMask")[0]["widgets_values"][0], of("ImageBlur")[0]["widgets_values"], of("ImageScaleBy")[0]["widgets_values"][1]),
(way["edge"]["inputs"]["expand"], [way["fade"]["inputs"]["blur_radius"], way["fade"]["inputs"]["sigma"]], 1.5))
crop = of("InpaintCropImproved")[0]["widgets_values"]
self.assertEqual((crop[18], crop[19]), (way["crop"]["inputs"]["context_from_mask_extend_factor"], False), "a margin, not resized")
no_cut = {kind: value for kind, value in ALL_HERE.items() if kind != "InspyrenetRembg"}
self.assertEqual(examples._lacks(examples.EXAMPLES["two_pass_anima_character"], no_cut, EVERYTHING), [{"pack": "comfyui-inspyrenet-rembg"}])
no_crop = {kind: value for kind, value in ALL_HERE.items() if not kind.startswith("Inpaint")}
self.assertEqual(examples._lacks(examples.EXAMPLES["two_pass_anima_character"], no_crop, EVERYTHING), [{"pack": "ComfyUI-Inpaint-CropAndStitch"}],
"a pack two of its nodes come from is named once")
def test_read_fills_in_what_is_here(self):
with mock.patch.object(examples, "_here", return_value=ALL_HERE), mock.patch.object(examples, "_fills", return_value=EVERYTHING):
data = examples.read("two_pass_anima_tile")
@@ -63,6 +82,33 @@ class ExampleTests(unittest.TestCase):
with mock.patch.object(examples, "_here", return_value=ALL_HERE), mock.patch.object(examples, "_fills", return_value=EVERYTHING):
self.assertIsNone(examples.read("nothing"))
def test_a_version_with_a_tile_opens_on_a_model_the_tile_fits(self):
from api import model_advice
info = {**ALL_HERE, "UNETLoader": {"input": {"required": {"unet_name": [["anima_big.safetensors", "anima_small.safetensors"]]}}}}
fills = {**EVERYTHING, "UNETLoader": "anima_big.safetensors"}
loader = lambda data: next(node["widgets_values"][0] for node in data["workflow"]["nodes"] if node["type"] == "UNETLoader") # noqa: E731
with mock.patch.object(examples, "_here", return_value=info), mock.patch.object(examples, "_fills", return_value=fills), \
mock.patch.object(model_advice, "_main_base", return_value="Anima"), \
mock.patch.object(lllite_fit, "problem", side_effect=lambda model, tile: {"blocks": 40} if "big" in model else None):
self.assertEqual(loader(examples.read("two_pass_anima_tile")), "anima_small.safetensors")
self.assertEqual(loader(examples.read("two_pass_anima")), "anima_big.safetensors", "no Tile in it: the first of the family")
with mock.patch.object(examples, "_here", return_value=info), mock.patch.object(examples, "_fills", return_value=fills), \
mock.patch.object(model_advice, "_main_base", return_value="Anima"), mock.patch.object(lllite_fit, "problem", return_value={"blocks": 40}):
self.assertEqual(loader(examples.read("two_pass_anima_tile")), "anima_big.safetensors", "none fits: still opened, the run says why")
self.assertEqual(fills["UNETLoader"], "anima_big.safetensors", "what was read for all is not changed for one")
def test_what_holds_for_a_family_is_written_once(self):
batch = model_guide.profile("Anima")["notes"][0]
self.assertIn("video frames", batch)
for key, example in examples.EXAMPLES.items():
with self.subTest(example=key):
text = json.dumps(example, ensure_ascii=False)
for repeated in ("video frames", "视频帧", "depth_embed"):
self.assertNotIn(repeated, text)
self.assertEqual(batch in examples._notes(example), example["family"] == "anima", "an AI gets it with each Anima example")
with mock.patch.object(examples, "_here", return_value=ALL_HERE), mock.patch.object(examples, "_fills", return_value=EVERYTHING):
self.assertEqual({item["family"] for item in examples.listing()}, set(examples.FAMILIES), "the page says a family's note above its versions")
def test_the_picture_taken_is_comfyuis_own_else_the_first(self):
with tempfile.TemporaryDirectory() as folder:
Path(folder, "b.png").write_bytes(b"x")
+65
View File
@@ -0,0 +1,65 @@
"""What a LoRA's own header suggests for the prompt when Civitai gives no trigger words
(header_words.py): the maker's trigger phrase, else the tags most training pictures share."""
import json
import os
import struct
import sys
import tempfile
import unittest
from pathlib import Path
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
import header_words # noqa: E402
LORA_KEY = "lora_unet_input_blocks_4_1_proj_in.lora_down.weight"
def model_file(folder, name, keys, meta):
header = {key: {"dtype": "F16", "shape": [4, 4], "data_offsets": [0, 0]} for key in keys}
header["__metadata__"] = meta
raw = json.dumps(header).encode("utf-8")
path = os.path.join(folder, name)
Path(path).write_bytes(struct.pack("<Q", len(raw)) + raw + b"\0" * 8)
return path
class WordTests(unittest.TestCase):
def test_the_makers_phrase_comes_first(self):
meta = {"modelspec.trigger_phrase": " glowstyle, neon rim ,", "ss_tag_frequency": json.dumps({"d": {"other": 5}})}
self.assertEqual(header_words.from_meta(meta), ["glowstyle", "neon rim"])
def test_tags_most_training_pictures_share_without_the_generic_ones(self):
tags = {"10_mychar": {"mychar": 40, "1girl": 40, "solo": 39, "blue eyes": 36, "looking_at_viewer": 30,
"red scarf": 26, "smile": 25, "hat": 5, "score_9": 40},
"2_extra": {"mychar": 10, "blue eyes": 9}}
self.assertEqual(header_words.from_meta({"ss_tag_frequency": json.dumps(tags)}), ["mychar", "blue eyes", "red scarf"],
"in half as many pictures as the most used tag at least, most used first")
def test_at_most_five_and_nothing_from_broken_metadata(self):
many = {"d": {f"tag{number}": 10 for number in range(9)}}
self.assertEqual(len(header_words.from_meta({"ss_tag_frequency": json.dumps(many)})), 5)
self.assertEqual(header_words.from_meta({"ss_tag_frequency": "{not json"}), [])
self.assertEqual(header_words.from_meta({"ss_tag_frequency": json.dumps({"d": {"x": "many", "y": True}})}), [])
self.assertEqual(header_words.from_meta({"ss_tag_frequency": json.dumps(["a", "b"])}), [])
self.assertEqual(header_words.from_meta({}), [])
def test_only_lora_files_are_read(self):
with tempfile.TemporaryDirectory() as temp:
tags = {"ss_tag_frequency": json.dumps({"d": {"mychar": 3}})}
self.assertEqual(header_words.of_file(model_file(temp, "l.safetensors", [LORA_KEY], tags)), ["mychar"])
checkpoint = model_file(temp, "c.safetensors", ["model.diffusion_model.input_blocks.0.0.weight",
"first_stage_model.decoder.up.0.block.0.conv1.weight"], tags)
self.assertEqual(header_words.of_file(checkpoint), [], "a fine-tune's training tags are no trigger words")
pickle = os.path.join(temp, "x.ckpt")
Path(pickle).write_bytes(b"\x80\x02whatever")
self.assertEqual(header_words.of_file(pickle), [], "formats that can carry code are never opened")
broken = os.path.join(temp, "b.safetensors")
Path(broken).write_bytes(struct.pack("<Q", 10 ** 12))
self.assertEqual(header_words.of_file(broken), [])
self.assertEqual(header_words.of_file(os.path.join(temp, "missing.safetensors")), [])
if __name__ == "__main__":
unittest.main()
+93
View File
@@ -0,0 +1,93 @@
"""Job-sheet cancellation and receipts when the page and MCP return in a different order."""
import sys
import time
import unittest
from pathlib import Path
from unittest import mock
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1]), str(PLUGIN / "tests")]
from api import ai_tasks, job_sheets # noqa: E402
from test_job_sheets import FLOW, OTHER, PAGE, Queue, isolate, picture # noqa: E402
class SheetEdgeTests(unittest.TestCase):
def setUp(self):
isolate()
self.queue = Queue()
patch = mock.patch("api.ai_task_runs._queue", return_value=self.queue)
patch.start()
self.addCleanup(patch.stop)
def start(self, **given):
return job_sheets.start({"rows": ["a cat"], "workflow": FLOW, "page": PAGE, **given})
def report(self, sheet, **fields):
return job_sheets.report({"id": sheet["id"], "page": PAGE, **fields})
def step(self, task):
result = {"structuredContent": {}, "content": [{"type": "text", "text": "ok"}]}
return ai_tasks.finish_step("AI", "run_workflow", {"sheet": {}}, result, time.time(), task_id=task)
def test_clearing_a_stale_sheet_stops_its_pending_runs(self):
sheet = self.start()
self.report(sheet, queued=[["old-run", 0]], at=1)
with mock.patch.object(job_sheets.time, "time", return_value=time.time() + job_sheets.STALE + 1):
job_sheets.clear(sheet["id"])
self.assertEqual(self.queue.interrupted, ["old-run"])
self.assertTrue(self.queue.deleted[0]((0, "old-run")))
self.assertFalse(self.queue.deleted[0]((0, "someone-else")))
self.assertIsNone(job_sheets.current())
def test_replacing_a_stale_sheet_stops_only_its_runs(self):
sheet = self.start()
self.report(sheet, queued=[["old-run", 0]], at=1)
with mock.patch.object(job_sheets.time, "time", return_value=time.time() + job_sheets.STALE + 1):
new = self.start(page=OTHER)
self.assertEqual(self.queue.interrupted, ["old-run"])
self.assertEqual(job_sheets.current()["id"], new["id"])
self.assertNotEqual(new["id"], sheet["id"])
def test_a_late_queue_receipt_after_stop_is_cancelled(self):
sheet = self.start()
job_sheets.ask(sheet["id"], "stop", PAGE)
now = self.report(sheet, queued=[["late-run", 0]], at=1, state="running")
self.assertEqual(self.queue.interrupted, ["late-run"])
self.assertEqual(now["state"], "stopped")
self.assertEqual(job_sheets.current()["state"], "stopped")
def test_a_report_of_pause_does_not_erase_a_stop_request(self):
sheet = self.start()
job_sheets.ask(sheet["id"], "stop", OTHER)
now = self.report(sheet, state="paused")
self.assertTrue(now["state"] == "stopped" or now["want"] == "stop")
def test_binding_the_ai_step_keeps_earlier_pictures_once(self):
task = ai_tasks.start_task("fast sheet")["id"]
sheet = self.start(task=task)
self.queue.history["fast-run"] = picture("cached.png")
self.report(sheet, queued=[["fast-run", 0]], finished=[["fast-run", "ok"]], at=1, state="done")
step = self.step(task)
job_sheets.attach(sheet["id"], step["id"])
job_sheets.attach(sheet["id"], step["id"])
shown = next(item for item in ai_tasks.get_task(task)["steps"] if item["id"] == step["id"])
self.assertEqual([item["filename"] for item in shown["results"] if item["type"] == "image"], ["cached.png"])
self.assertEqual(shown["state"], "done")
def test_non_successful_runs_do_not_become_a_successful_sheet(self):
for outcome in ("interrupted", "gone"):
with self.subTest(outcome=outcome):
isolate()
task = ai_tasks.start_task("interrupted sheet")["id"]
sheet = self.start(task=task)
step = self.step(task)
job_sheets.attach(sheet["id"], step["id"])
self.report(sheet, queued=[["p1", 0]], finished=[["p1", outcome]], at=1, state="done")
shown = next(item for item in ai_tasks.get_task(task)["steps"] if item["id"] == step["id"])
self.assertEqual(shown["state"], "error")
self.assertFalse(shown["ok"])
if __name__ == "__main__":
unittest.main()

Some files were not shown because too many files have changed in this diff Show More