Compare commits

...
Author SHA1 Message Date
Claude 2cc8dada26 v1.63.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 02:31:16 +00:00
DemonGatanjieuandClaude Opus 5.5 dfd8dd78fd Show where the entry is after installing, and keep the cube under ComfyUI's menus
Viewers could not find the plugin after installing. README install step 2 now has a picture
(both languages) of the cube button at the canvas's top left and the C menu path
Extensions -> Anomalous Model Browser -> Open / Reset floating entry position, and says how
to make the entry easier to spot (labeled pill, large, or the top action bar); a line under
the language links points there.

Taking the picture showed the cube covering the C menu's "New" row: it sat at z-index
2147483000. It is now 1000: over the canvas and its tool layer (999), under the top bar
(1001), menus (PrimeVue, about 1800) and dialogs. Checked on the test ComfyUI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:29:53 +08:00
DemonGatanjieu b43c534797 Merge branch 'main' into feature/1.63 2026-11-08 02:00:21 +08:00
DemonGatanjieuandClaude Opus 5.5 20e2916579 Cloud server guide: a picture of the View only chip and its dialog
In both languages, the dialog's command showing an example path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 02:00:16 +08:00
DemonGatanjieu 220cb1d804 Merge branch 'main' into feature/1.63
# Conflicts:
#	CHANGELOG.md
#	README.md
2026-11-08 01:48:32 +08:00
DemonGatanjieuandClaude Opus 5.5 a0f0cb22bb v1.62.4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:47:58 +08:00
DemonGatanjieu 4f26a47cd1 Merge branch 'main' into feature/1.63
# Conflicts:
#	CHANGELOG.md
2026-11-08 01:36:44 +08:00
DemonGatanjieuandClaude Opus 5.5 a063628866 Count this computer at its own LAN address as local; say why a remote save was refused
ComfyUI listening on its LAN address only (#26): the browser on the same computer
comes from that very address, so it was taken for another device. A request whose
peer is the server's own end of the connection, naming that same IP, is local now;
DNS rebinding and proxy headers are still refused.

Opened from another device (#25), the settings page said the proxy's format was
wrong and that Civitai could not be reached, when the server had refused the
request. It now says so, and refusals point to the View only chip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:34:22 +08:00
DemonGatanjieuandClaude Opus 5.5 efa588bdc2 Say what Model Check can do without fingerprints, link its rules
The README now says in both languages that fingerprints come from this
plugin, what Model Check still does for a workflow without them (a file of
the same name to confirm, the workflow's own links, ComfyUI-Manager's list),
and has a button to the rules page, now also in English. The rules page
names ComfyUI's own model links. Also corrected: interface scale (not font
size), what Import keeps and replaces, the Gates list, and the one-server
note on cloud servers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 16:05:40 +08:00
DemonGatanjieu aa4b2ac150 Rewrite the README for 1.63 and refresh its showcase pictures and animations 2026-11-07 15:34:11 +08:00
DemonGatanjieuandClaude Opus 5.5 7e16459798 Say what the plugin is for in the README
A short section in both languages, under the opening lines: not another
WebUI or a ComfyUI replacement, ComfyUI made easier to use; fewer chores,
still ComfyUI's own canvas and nodes (the plugin adds none), and the AI
deciding while the plugin does the work. The opening lines and the
tagline now name saves and the AI workbench too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:06:11 +08:00
DemonGatanjieuandClaude Opus 5.5 a8780cb568 Picture the AI workbench and saves in the README, redraw them on a language switch
Four pictures (Chinese and English) from the isolated test ComfyUI with
scenery only: a Gemini task in the AI workbench, and three saves of one
canvas with the saves bar. Taking them showed the AI workbench stage and
message box and the saves bar staying in the old language after a switch
until a reload: they are drawn again now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:17:36 +08:00
DemonGatanjieuandClaude Opus 5.5 6c258f48ec Describe the AI workbench and saves in the README
Two items in both languages (let an AI app do it while you watch; try a
change, keep both, go back) and the AI workbench, official examples and
saves in the pages table, with the labels as the window shows them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 01:54:07 +08:00
DemonGatanjieuandClaude Opus 5.5 46eca1b594 Run a picture's own workflow, keep a task on one page, read a big canvas whole
- run_workflow takes a gallery picture as its workflow (mcp_fill.opening
  says what a name opens, for open_workflow too); a picture's workflow
  opens with an id of its own, and the tab's mark goes into the workflow
  before it loads, so ComfyUI finishing the tab does not drop it.
- With ComfyUI open in two browser tabs a task's actions were split
  between them (one opened the task's tab, the other could not find it):
  the page that did the last action gets the next first now, another
  only when it is gone (mcp_bridge.py, pages keep an id of their own).
- describe_canvas on a big canvas: titles off, then nodes as a line each
  with their settings, so every value stays readable.
- search_models names the models that did not fit; begin_task's tool
  directory goes by name alone when the answer would be too long.
- ask_user refuses a "test" question with made-up options too.
- Exam round recorded: question 2 with nothing filed away, a big canvas
  read right in 3 calls, question 3 in 12 calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 01:16:17 +08:00
DemonGatanjieuandClaude Opus 5.5 139380e960 Give the AI what it needs: answers it can read, hints on an empty canvas, AMB always loading
- An answer past Antigravity's size is filed away unread (eight LoRA
  searches in the exam): search_models answers small (loaders once,
  four trigger words, a LoRA's author weight); describe_canvas gives a
  big canvas's last nodes a line each; mcp_fit.py keeps any other list
  answer within the size and names what it left out.
- An empty canvas: run_workflow, describe_canvas and get_situation say
  to open a workflow first; get_image_info says a picture's workflow
  opens with open_workflow.
- ask_user does not put a "test" question to the user; a task's picture
  count counts what its runs made.
- AMB failed to load now and then: ComfyUI loads every file at once and
  ui_ai_workbench.js set ui_ai_toolbox.js's sideHost before it was made
  when the toolbox file ran first. Connected on first draw now;
  tests/load_order.mjs runs every file first.
- input_facts moved to api/picture_mask.py to keep mcp_tools.py short.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:53:50 +08:00
DemonGatanjieuandClaude Opus 5.5 05e2d269b4 Let open_workflow open the workflow a gallery picture carries
To change a picture from the gallery the AI had to open the saved card
and set back, one by one, the model, LoRA and prompt the picture had
been made with (8 to 12 calls in the exam). open_workflow now takes a
picture's name too when no saved workflow has it: the server checks
the picture is in the output folder and carries a workflow, and the
page reads it from the PNG and opens it in a new tab, without the
marks of the tab it ran on (the card that tab came from, which
run_workflow would mistake it for, and its group of saves). The tool
list is 18 characters shorter than before.

Retaken with Gemini 3.8 Flash, questions 3 and 4 only: question 3 on a
changed picture went from 28 calls (7 failed) to 21 (4), opening the
picture's own workflow; written into docs/architecture/tool-exam.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 19:57:13 +08:00
DemonGatanjieuandClaude Opus 5.5 54facc44c1 Record the 1.63 standard exam and fix two things it found
Gemini 3.8 Flash took all 8 standard questions the 1.63 way (the
question in the task's message, the new task page's sentence to the
AI, a blank canvas each): 128 calls, 16% failed (21% last time), no
detours. Written into docs/architecture/tool-exam.md.

- A task's picture count counted a picture twice when the AI's report
  showed the one its run had made; pictures are counted once each now.
- After a switch of language the AI workbench kept rows drawn in the
  old one until a reload: syncRows makes a row again when the language
  it was drawn in is not the one shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 19:18:06 +08:00
DemonGatanjieuandClaude Opus 5.5 d307e28957 Record the 1.63 exam with Gemini 3.8 Flash
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:01:11 +08:00
DemonGatanjieuandClaude Opus 5.5 2280e55fde Let an AI read the workflows it is offered
A run with Gemini 3.8 Flash asked for picture to picture and built it node by node, 13 failed calls on the way, with an official example ready:

- list_workflows was 23,630 characters on 17 cards, 21,033 of them settings. Antigravity files an answer over about 4,000 away, unread. A list of more than 3 now goes with name, route, tags and short notes.
- Examples showed only when no saved workflow matched, and only by English words: 图生图 found nothing. They are always named now, and found by their Chinese titles, img2img, txt2img and inpaint.
- Every card's route line was empty, and cards_of found nothing, since c84b596: _list_recipes leaves the graph out, and the tests fed cards that had one. Both ask for it now, with a test on a file on disk.

A waiting task the user wrote to also takes its name from what they wrote, instead of the AI's "take the task".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:51:12 +08:00
DemonGatanjieuandClaude Opus 5.5 ad41beaa5a Say in the Anima examples how to get several pictures
Anima's VAE takes a batch of pictures for video frames, so a batch of 2 is one picture after the VAE encode of the second pass. The two examples keep their tested route and say to run several times instead, to the user and to the AI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:24:35 +08:00
DemonGatanjieuandClaude Opus 5.5 f5a3b5f9ff Say a prompt's role for values kept from pictures
Current node showed "negative prompt" only on values from a workflow. A material's blocks carried no role, and when a picture and a workflow held the same text the merged card kept the picture's empty one, so a negative prompt could be offered to a positive box with nothing to say so.

- api/materials.py works the role out by what the prompt was wired to, as the material details already do; it reads the stored workflow, so pictures kept earlier get it too.
- node_parameter_sets.js takes the role from whichever source knows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:54:43 +08:00
DemonGatanjieuandClaude Opus 5.5 ec81f8b230 Bring a save's pictures back onto the output nodes
Going back to a save left the last run's picture on the output node, so the canvas and the picture told different stories.

- api/workflow_saves.py keeps what each output node made, previews too, the newest 16, and read_group gives them as `outputs`.
- ui_snapshot_bar.js puts them on the nodes through the tab's changeTracker (store, restore): no `executed` event, so no plugin takes them for a new run. Several runs sit on the node as a batch does. A node the save has no picture for goes empty; Load Image's own picture stays (snapshot_plan.outputsAt).

The lit number went out every other time with a Preview Image on the canvas, and an empty undo step came with it: a loaded workflow serializes a little differently from its file, and a node gains an empty widget list when it shows a picture. The number now goes out only when the canvas really changed (withoutEmptyValues), and those differences take no step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:20:59 +08:00
DemonGatanjieuandClaude Opus 5.5 d778aec946 Fix what a walk through every page found before 1.63
Every page in both languages, all themes and at 480:
- Give the window its own text colour: text with none took ComfyUI's, white on the light theme (Tidy headings and file names, the site chip).
- Keep a task's link chip in its row, its x in reach, and colour the next-task chip.
- Let choices in Settings wrap, the Models search shrink, and a workflow's preset row go to two lines.
- Add --amb-on-link for text on the accent: pale in the dark themes, so white was hard to read.
- Lists and colons follow the language (listSeparator, wordColon); tests/words_in_language.mjs keeps it so.
- Right plurals in English, "AI" on the rail, one line left in Chinese translated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 03:18:07 +08:00
DemonGatanjieuandClaude Opus 5.5 6afbbf9059 List combos in Current node and put the rail's tools by use
Current node now reads what combos hold for the node's type: every box of
a node structure, by name, and a plain combo's prompt on a prompt box. They
get a chip of their own, a press goes to the combo, and the values go in
only on the user's press, as with the other sets (AD-021).

The rail's tools go by how often they are used: Current node, Model Check,
then the scan, and the tour follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:28:37 +08:00
DemonGatanjieuandClaude Opus 5.5 83c4bdf057 Remove the styles the chips commit doubled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:15:30 +08:00
DemonGatanjieuandClaude Opus 5.5 b3d5b3dc00 Show where Current node's sets came from as chips
A line per source with an Open link made the cards too dense. A set now
has a chip per channel (workflow, picture, saved here), each its own
colour: a press goes to the one source, or lists several by name first.
A name no longer repeats the node's type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:12:51 +08:00
DemonGatanjieuandClaude Opus 5.5 5667fa2d9e Make the materials picker's models work like the Models page
Types go by their usual names, base model chips narrow the list, the
version stands apart from the name, and a model's versions are one card
whose version opens the list to show another; a pick goes with it. The
grouping, the families and the type names are the Models page's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:01:40 +08:00
DemonGatanjieuandClaude Opus 5.5 9b277d22c6 Merge main (v1.62.1 to v1.62.3) into 1.63
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 01:36:17 +08:00
DemonGatanjieuandClaude Opus 5.5 a2b25ab1d9 Group the examples by plan and add Anima two-pass versions
Anima loads through other nodes than a checkpoint, so none of the examples
could open on it. Two passes now comes in three versions: SDXL / SD 1.5,
Anima on ComfyUI's own nodes, and the recommended Anima held by Tile, the
way's tested values, kept in step by a test. The own-nodes version redraws
at 0.35: at 0.5 the shapes began to move with nothing holding them.

The window shows plans, versions and notes. A version that lacks a pack
or a model says what and has no Open button; an AI only gets those that
can run here. The layers of cards, examples, ways and skills are written
down as AD-025.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 01:30:25 +08:00
DemonGatanjieuandClaude Opus 5.5 c3433a9241 Pick a way for a task, and ship official example workflows
Ways: "Use it this time" in the Ways view, for one task. It joins the
task's picks as a new kind, the AI is told, and add_nodes builds that way
for its use whichever one the AI asks for. "Only" lets in the nodes a
picked way builds.

Examples: text to picture, two passes, picture to picture and repainting
an area, on ComfyUI's own nodes. One card, first on the Workflows page,
opens them in a tab of their own. The files name no model or picture; a
checkpoint and a picture that are here are filled in when one is read.
list_workflows gives them to an AI when no saved workflow fits, and they
open and run by name like saved ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:51:26 +08:00
DemonGatanjieuandClaude Opus 5.5 c94ec1d756 Ways opens at once and stays as it was left
Each click drew the list from what was kept, then drew it all again when
the read came back, losing the scroll and what was open, and the first click
showed "reading". It is read ahead when the page shows a task, read
again quietly on opening, and drawn anew only when something changed. A
switch keeps the scroll too, and a failed read keeps a list that shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:13:22 +08:00
DemonGatanjieuandClaude Opus 5.5 e36d898fb9 A waiting task's page has one thing to do
It said "waiting for an AI" three times and kept two folds that did what
the message box does. Now it is the sentence and its Copy button, in the
middle of the column; what to say first and pictures go in the message
box, whose words say so. The connect card comes first until an AI has ever
come, and is gone after that. The wish line, the big gallery fold and
what only they used go with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:01:47 +08:00
DemonGatanjieuandClaude Opus 5.5 a47e85ff90 v1.62.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:33:43 +08:00
DemonGatanjieuandClaude Opus 5.5 6d1776d856 Tests no longer look like node tables to ComfyUI-Manager
Manager reads every .py file of a plugin, tests too, and listed ten names
from two test files as AMB's nodes, the only match for Power Lora Loader
among them, so a workflow missing it could offer AMB in place of rgthree.
The tables are written so its scanner passes them by, and a new test uses
the scanner's own patterns to keep any file from looking like one again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:33:42 +08:00
DemonGatanjieuandClaude Opus 5.5 9d4c3a1b15 Tests no longer look like node tables to ComfyUI-Manager
Manager reads every .py file of a plugin, tests too, and listed ten names
from two test files as AMB's nodes, the only match for Power Lora Loader
among them, so a workflow missing it could offer AMB in place of rgthree.
The tables are written so its scanner passes them by, and a new test uses
the scanner's own patterns to keep any file from looking like one again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:25:34 +08:00
DemonGatanjieuandClaude Opus 5.5 47442d00f6 Keep the Saves list still during a press
A new picture puts itself first in its line, so a press on a picture
ended on another one and was no click. While the main button is held on
a button of the list, what comes waits until the press and its click
are over.

Tested with a real mouse on an isolated ComfyUI: a press held until a
new picture came opened the pressed picture 4 times of 4 (0 before), and
16 of 16 long presses during runs answered (13 before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 21:21:41 +08:00
DemonGatanjieuandClaude Opus 5.5 f005b0af73 Fix the count box showing a count that was not saved
When a count's save failed the box kept what was typed, with only a
console warning, while Run went by the stored count. The box now goes
back to the stored one, a box being typed in too, and a toast says why.

Tested with a real mouse on an isolated ComfyUI, the save refused and
the server unreachable, on the bar and on the Saves page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 21:21:20 +08:00
DemonGatanjieuandClaude Opus 5.5 b368a4fb35 AI task list: lines of tasks stand together, a more button on each row
Follow-ups hang one step in under the first task of their line, so the
list shows what goes on from what; a filter's tasks still show alone. Each
row has the head's menu, without opening the task. Removing a task joins
those after it to the one before. The pick list now stands over everything
at the place the menu opened: the head is drawn anew under an open menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:42:50 +08:00
DemonGatanjieuandClaude Opus 5.5 3ca898518e Fix Run going by the old count when its answer is slow
The click can come before the answer to a count typed just before it,
and all three Run buttons held the saves as they were drawn: a save set
to 3 runs ran once. Run now waits for the counts on their way and reads
the group as it stands at its turn, in one place for all of them.

Tested with a real mouse on an isolated ComfyUI, the answer held back
400 ms: the bar, a part and a card each queue the new count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:03:56 +08:00
DemonGatanjieuandClaude Opus 5.5 029dce4d4d Fix the Saves page losing clicks the same way
A part redrew on new elements each time a count was answered or a run
ended, so a press on the page lost its click and a count being typed
lost its focus. settle moves to ui_dom.js for both; a card is kept for
its save, and a picture line stands while its pictures do.

A press on a picture just as a new one reaches that line is still lost:
the line shifts, and another picture is under the pointer by the mouseup.

Tested with a real mouse on an isolated ComfyUI. tests/snapshot_bar.html
now covers the page and fails 5 checks on the previous code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:36:52 +08:00
DemonGatanjieuandClaude Opus 5.5 de48bff5b0 Fix the saves bar losing the first click
Every redraw replaced the whole row, and the browser sends no click to
an element that left the page during the press. Three things redraw in
that gap: ComfyUI's graphChanged at mouseup, the answer to a count, and
a run ending. The last also wiped a count being typed. Update the
elements in place, a save's part only for that save.

Tested with a real mouse on an isolated ComfyUI: 19 flows, 5 of them
passing before the fix. tests/snapshot_bar.html fails 7 checks on the
old code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:10:35 +08:00
DemonGatanjieuandClaude Opus 5.5 1edf885e5a Insert several LoRAs at once from the model picker
Inserting took a round per LoRA. The picker now gathers them: click to add,
click again to drop, the number on a card is the order they go in. They go in
as a chain of ComfyUI's loaders, or through one Power Lora Loader or Easy-Use
stack where installed, all or nothing and as one undo step. Third-party nodes
change height just after they appear, so they are placed, and the step taken,
once they have settled. New nodes stand in one column and the view goes to
them when a crowded canvas leaves them out of sight.

Fixes the CLIP line going missing for a LoRA inserted before a sampler with a
LoRA already there: that LoRA counted as a prompt node, so there seemed to be
two CLIP sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 04:48:49 +08:00
DemonGatanjieuandClaude Opus 5.5 6a61682d2a Make the window and its pages come and go smoothly
Opening, closing and changing pages jumped; now they take a short
transition. Styles alone do it, since a dozen modules open the window:
anything leaving display: none starts its animation. The rail's highlight is
one piece that slides between the entries. Going between docked, floating
and full window lays the page out once and moves only its picture, where the
docked rule used to animate the width and lay it out on every frame.

Only opacity and position move, and all of it stays off under reduced motion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 04:01:36 +08:00
DemonGatanjieuandClaude Opus 5.5 e6a9c3be93 v1.62.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:49:01 +08:00
DemonGatanjieuandClaude Opus 5.5 b34936a99f Fix workflow cards squeezed into strips
A card clips its content, so plain grid rows shrank to fit the list's height
instead of letting it scroll: with many workflows every card ended up 56 px
tall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:49:00 +08:00
DemonGatanjieuandClaude Opus 5.5 dc7e9e9ef0 Make the docked window's width draggable
Drag the right edge from 480 up to leaving 240 of the canvas; the width is
remembered and a double-click restores 660. The limits live in the CSS, so
they hold when the browser window shrinks too.

Going through every page at 480 turned up what this needed:
- Workflow cards shrank into strips instead of the list scrolling: a card
  clips its content, so plain grid rows fit the height.
- The Prompts page's own columns beat the narrow stacked rule, leaving half
  a page empty, in a narrow floating window too.
- The Models header pushed the close button out.
- The Workflows bar only wrapped on a small screen, not a small window.
- The AI tabs kept a gap for their hidden icons and wrapped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 03:04:25 +08:00
DemonGatanjieuandClaude Opus 5.5 69a1bb37da Esc puts the AI page's task list away first
With the list a drawer over the task, Esc closed the whole window while the
drawer was open. It now closes the drawer, and the window on the next press.
The task's back button left the Esc list: from the task it would only reopen
the drawer, and Esc could never reach the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:40:34 +08:00
DemonGatanjieuandClaude Opus 5.5 5710f84229 Show the narrow AI task list as a drawer over the task
Filling the page, the list read as a page of its own rather than the records
of conversations. It now slides over the task shown, which stays visible and
dimmed beside it; a click there goes back. With no task it still fills the
page. The welcome page moved to ui_ai_ready.js to keep the workbench under its
size limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:26:52 +08:00
DemonGatanjieuandClaude Opus 5.5 05c5407079 Fit the AI page to a docked window, Prompts and Saves to a wide one
With no room for the side column the AI page showed a row of bare icons in the
title bar, and two tabs that changed under the user. It now shows labelled tabs
over the task, the timeline and every view, kept in the head while the
timeline scrolls, and words that named a right side say what they do. One
function gives both strips, in place of tabBar.

On a wide window the Prompts page left two thin boxes over empty space: the
boxes now share the room, side by side and full height, with wider cards. The
Saves page puts its cards side by side like the workflow cards and says how
many canvases and saves there are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:11:51 +08:00
DemonGatanjieuandClaude Opus 5.5 8a4c5d5c72 v1.62.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:52:28 +08:00
DemonGatanjieuandClaude Opus 5.5 b9fd30ad72 Fix Anomalous sometimes not starting
ComfyUI imports every script under web/ on its own and at once, so any module
of the big import cycle can run first. ui_ai_workbench.js wrote to a const of
ui_ai_results.js as it loaded, which failed when ui_ai_results.js or
ui_ai_step_body.js was entered first, and the whole plugin went with it until
a reload. The value now lives in a module that imports nothing, and
tests/module_cycles.mjs refuses the pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:52:28 +08:00
DemonGatanjieuandClaude Opus 5.5 c624f3f134 Tidy the AI workbench's task head and fix a startup race
Walking through the AI workbench as a user: the task head held nine loose
controls that wrapped to four or five lines with the side column open. It is
now two lines whatever the width, with rename, privacy, links and remove in a
menu. The stages and what a task made share one line over the timeline, a
failed step is a mark instead of red text, New task opens at once, and a
missing label in the Ways panel is filled in.

The walk also caught AMB not starting at all on one page load. ComfyUI imports
every script under web/ on its own and at once, so any module of the big import
cycle can run first; ui_ai_workbench.js wrote to a const of ui_ai_results.js as
it loaded, which failed when that module was entered first. The value now
lives in a module that imports nothing, and tests/module_cycles.mjs refuses
the pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:40:33 +08:00
DemonGatanjieuandClaude Opus 5.5 8ac69cbb70 Make the saves bar and page behave as a person expects
A walk through the whole flow, as a user would, showed what felt wrong.
The folded bar floated over the canvas: it is now a handle on the canvas's
bottom edge. The bar lives in the canvas's panel, so the logs stay uncovered
and it wraps before going under ComfyUI's canvas tools; it holds the newest
six saves, the rest behind a button to the Saves page, instead of growing
into a block. A lit number now means the canvas stands at that save, going
dark on any change or undo. Words float over the bar and leave with the runs,
and on the page a kept card is a toast and runs show on their own save, so no
button moves under the pointer. Removing a save tells the next one what it
changes now, and the Saves view says the way back to the cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:10:58 +08:00
DemonGatanjieuandClaude Opus 5.5 0050c8d66d Fold a save's extra pictures into gallery cards, no scrollbars
A save's line had a scrollbar under its pictures, and long change words had
one too. The line now holds what fits and folds the rest into a last +N tile,
refitting when the window changes. The tile, or the save's menu when all fit,
opens all its pictures as the gallery's own cards, in the list's place until
Back or Esc. Cards take their pictures from an option, so stepping, delete
and compare stay inside the save; the Gallery page passes nothing and works
as before. Change words stop at four lines, the rest on hover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:46:23 +08:00
DemonGatanjieuandClaude Opus 5.5 a8169fb35d Make a save a card, words beside its pictures, with their parameters
The pictures dropped to a line of their own under the save's words, and a
thin line was all that told one save from the next. Each save is now a card:
what changed, its runs and buttons on the left, its pictures in a line on the
right, going under them only in a narrow window. A picture opens View
Parameters too, and the arrow keys step through all of that save's pictures.
The part sets its own text color, which light themes were missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:34:13 +08:00
DemonGatanjieuandClaude Opus 5.5 100d0dbac1 Make saves lighter: a page by canvas, one way out to a workflow card
Presets, combos and Add to saves took more to keep sensible than they gave,
so a save now only becomes a new workflow card and only the canvas makes one.
The panel above the bar moves to the Saves page, a part for each canvas, open
or not, with each save's pictures and its runs: the whole group in order, or
one save alone. Running moves to snapshot_run.js for the bar and the page;
what is clicked goes in in click order, and closed canvases run too.

A run taken off ComfyUI's queue is now forgotten at once, so "runs to go" does
not stay wrong for a day. A first save on an unsaved tab now reaches ComfyUI's
draft of it, so a reload right after keeps the tab's saves on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:23:31 +08:00
DemonGatanjieuandClaude Opus 5.5 845d2b6178 Let saves and workflows reach each other
A save copies to a workflow card, a preset of a card with the same structure, or a
combo; a card's preset can join the open canvas's saves. The Workflows page lists
every group, so saves of a closed tab or a deleted file can be opened again, on a
new tab where the group goes on, or removed. A card keeps no key of a canvas's saves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:39:22 +08:00
DemonGatanjieuandClaude Opus 5.5 bffa239903 Keep saves for good, per workflow, with the pictures they made
Saves move from the page to a store (api/workflow_saves.py), a group for each
canvas, found by a key in the workflow's extra or by its file, and go into
backups. The bar follows the open tab. A save's runs are named to the server, which
finds their pictures in ComfyUI's history, so a reload during a run loses nothing.
A save opens above the bar with what it made and what changed; removing one goes to
the Recycle Bin, and a group past its most takes no more and drops nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:29:30 +08:00
DemonGatanjieuandClaude Opus 5.5 72d3ca12ea Fix saves: run each save, not the canvas; keep Ctrl+Z after going back
ComfyUI-Manager wraps app.graphToPrompt and drops the graph, so every run and
the check before it compiled the canvas: same seeds, nothing of the later marks.
A result that is not the copy's is now made by ComfyUI's own method.

Going back is one of ComfyUI's undo steps, so Ctrl+Z returns to the state left
behind. A save can also open on a new tab, the empty canvas's menu and a + on
the bar keep one, and marks show as saves, runs counted as runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:46:27 +08:00
DemonGatanjieuandClaude Opus 5.5 537ebc46d7 Make the new-route reminder say try it, not change models
A Flash exam showed a light model can build a route it is told, so the reminder
no longer sends the user to a flagship model: the route is untried, see how the
run turns out, and save a card if it is good.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:12:50 +08:00
DemonGatanjieuandClaude Opus 5.5 c45c3b672c After the Flash exam: kept bases are no outside change, one pass picks no base
Three exams with Gemini 3.8 Flash on an isolated ComfyUI all went the right
way. They showed that a base kept by Anomalous was reported as a file changed
outside it, and that the one-pass route still told the AI to show bases
first. begin_task's words are shorter: with a real node list it went past
its 4000 characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:07:29 +08:00
DemonGatanjieuandClaude Opus 5.5 64607a2c19 Add the snapshot queue: marks of a whole workflow run in order
A node's "Mark this state" keeps the serialized canvas as a mark in a bar
under it. A mark's number goes back to that state, each has its runs, and
seeds are Random or Locked (one per round, shared by the marks; a fixed
seed alone would come from ComfyUI's cache). Run checks every mark with
validate_prompt first, then compiles detached copies with their seeds
written, so the canvas and its seeds stay as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:38:23 +08:00
DemonGatanjieuandClaude Opus 5.5 5e32f247b7 Routes for two picked models; a reminder when the AI builds a new route
With two main models picked, ask_user(routes=true) shows the routes this
ComfyUI can build for them, each with what it does to the picture and a
sample, a saved card using both first; the answer says what to call, and
add_nodes(route=...) builds the route from the ways on a new tab, the LoRAs
on each model. get_toolbox lists each model's cards as its examples. A way
counts as ready when a family variant has what it needs. An AI placing
nodes itself is a new route: one reminder a task, or with the new gate the
user's OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:27:09 +08:00
DemonGatanjieuandClaude Opus 5.5 c6bacab7ff Add the picture way: an empty canvas starts on the picked model
add_nodes(use="picture") builds a text-to-image graph on the model the
user picked for the task, or add_nodes(model=...): one checkpoint for SDXL
and its kin; for Anima the UNET with its text encoder and VAE, found in
the model folders ("files"), and the tested base-pass sampling. A
standalone block is built only on a canvas making no picture yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 02:48:28 +08:00
DemonGatanjieuandClaude Opus 5.5 eab141f971 Merge the upscale ways into three; repaint is one use for the AI
Upscale held by Tile takes in the tile upscale, the Anima Tile detail
upscale and the second-model upscale: its redraw has its own copy of the
canvas's model line (copy_of "$sampler.model": loader and LoRAs, titled
"Upscale model"), so a second model is one set_model and the LoRAs stay;
on Anima it is the user's tested route (latent 1.22x, Tile LLLite,
denoise 0.85). The iterative upscale goes. add_nodes no longer lists every
block name, and the repaint hints name the use, not a block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 02:32:09 +08:00
DemonGatanjieuandClaude Opus 5.5 c84b5960fd List each saved workflow with its route in one line
list_workflows gives each card a route read from its saved graph: its
passes in run order, the model each draws with, a split's steps, an
enlargement, control models such as a Tile LLLite and what comes after.
A Flash run picked a one-pass card for a two-model job; it saw names only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 02:01:25 +08:00
DemonGatanjieuandClaude Opus 5.5 9f72cfce83 Pick a base: run the first pass alone as previews, keep the one picked
run_workflow(base_only) queues the first pass alone (base_only.js cuts each
queued prompt; save nodes become previews, the canvas stays). When it ends,
ai_bases.py shows the previews on a picks panel; the picked one is copied
into the output folder and its seed held. The next run takes the kept
picture where the rest takes the base's picture, so the base is not drawn
again; a base handed on as a latent (mid-way switch) is drawn again from
its seed and compared with the kept one. Drawn again from the same seed, a
base once came out grainy on a 6 GB card, hence the kept picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 00:17:42 +08:00
DemonGatanjieuandClaude Opus 5.5 d0d9afc48c Ways' notes after GPT's check: hand-fix rate is no promise, ask before enlarging twice
The hand fix's "3 tries in 4" holds for the pictures tried, not for crowds or hard poses;
its checks now compare with the enlarged redraw and look for seams, missed hands and good
hands made worse. Enlarging at the mid-way switch asks the user first when the workflow
already enlarges the picture downstream. Both ways record GPT's runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:23:16 +08:00
DemonGatanjieuandClaude Opus 5.5 525bcfeaea Fix hands with a SAM3 pass first; enlarge at the mid-way switch
The hand-fix way now redraws each hand SAM3 finds by the word ("hand:8": the word alone
takes only the likeliest hand) inside its own outline before the hand detector's pass, as
the reference author does. The pass is optional: its nodes go when SAM3's node or model
(sam3.1, now in the ways' model list) is missing, with the nodes that only fed them.

Switch models mid-way says how to enlarge at the switch: a LatentUpscaleBy after the first
part, which then finishes its picture, and the second part adding noise anew. Enlarging
the leftover noise itself (the author's wiring) covered the picture in coloured blocks on
Anima at step 21 and 12. The staged-picture skill says so in one sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 04:49:20 +08:00
DemonGatanjieuandClaude Opus 5.5 a127987348 Current node: compare the node alone, the models page's cards in the picker, no click-through
Three fixes from the user's use of the current-node panel:

- ⇄ Compare on a saved set listed the whole workflow's values. It now
  compares that node alone: a copy of the node holding the set's values,
  nothing wired, against the canvas, under the node's id so the two pair
  (a set's workflow numbered otherwise, with two prompt nodes, paired
  neither). compareParams takes `only` for this; the comparison window
  takes `nodeId` and leaves out the structure badge. Every set can now be
  compared; the unused "could not read the set's workflow" text is gone.
- The Change model picker drew cards of its own: full-size covers and
  videos without a still, so most showed blank, and only file names. It
  now shows the models page's card face (modelCardFace, split out of
  ui_grid.js): the cover thumbnail, a video's still until pointed at, the
  base badge, version, readable name and file name; "current" and the
  chosen outline stay. Versions are not folded. Its type and base chips
  and their texts are gone.
- In the generated-pictures gallery (current node, View all), once
  scrolled, "Close gallery" opened the picture scrolled up under it:
  Chrome's hit-testing again (lessons.md). Its scroll box and the
  picker's card area get clip-path: inset(0).

Checked in an isolated ComfyUI: the prompt node's compare lists only its
text; the picker shows covers and names, selects, replaces; the models
page's cards keep their buttons and open the details; a 13-step scroll
of the gallery leaves every click on the close button. Full suite and
structure check pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 02:41:52 +08:00
DemonGatanjieuandClaude Opus 5.5 c6dc86ea59 Fold the AI workbench's tabs by the window's width, so docked it keeps its buttons
The tab labels in the title bar folded to icons only when the browser
was under 1100 px. Docked, Anomalous is 660 px wide in a wide browser,
so the six labelled tabs pushed maximize, dock and close out of the
window. A container query on the Anomalous window folds them instead.

Checked in an isolated ComfyUI at 1600 px: docked (660 px) and the
narrowest floating window (600 px) show icons and all four buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 02:05:17 +08:00
DemonGatanjieuandClaude Opus 5.5 62af348394 Hold the base's seed before a second pass; er_sde splits badly on Anima, not everywhere
From a picture check of the staged ways (another AI judged the pictures):

- The staged skill shows the base first so the user can pick it. With
  the sampler's seed moving after each run, adding the second pass and
  running again redrew a different base. The skill now says to hold the
  base's seed (control_after_generate fixed) before that first run.
- Split er_sde came out grainy on two Anima models but cleanly on Nova
  Anime XL (one seed each). The mid-way switch no longer says er_sde
  never splits: it keeps dpmpp_2m_sde_gpu on Anima and calls it the
  safe choice elsewhere. Its SDXL version ran for the first time (Nova
  Anime XL to MiaoMiao Harem v2.0, step 18 of 30, 41 s on 6 GB) and is
  recorded as tested.

No values changed. Full suite and structure check pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:53:06 +08:00
DemonGatanjieuandClaude Opus 5.5 442d656b5b Fix open_workflow, broken when saved workflows gained their sets
3d720ef made mcp_fill.saved_workflow return the set too, and
run_workflow took it, but open_workflow still unpacked two values and
failed on every saved workflow ("too many values to unpack"). It now
passes the set on to the page like run_workflow, so "workflow / set"
opens with that set applied.

A test calls open_workflow with a workflow and with one of its sets; it
fails with the old line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:53:06 +08:00
DemonGatanjieuandClaude Opus 5.5 4621ff5dbf Replace the Anima illustration skill with a general skill for drawing in stages
The skill staged_picture says how, not values: different models for a
picture's early and late steps (refine midway); a locked redraw while
enlarging, which keeps the model or switches in that same redraw to a
second model of the same family (upscale detail, anima tile, steady),
with no extra refine pass after it; model after model (refine). It says
which switches need one family (any reusing the first stage's prompts and
VAE; the single-file refine with its own loader may cross families, its
prompts rewritten), and that a style LoRA the user picked goes onto the
second model when the look is to be kept. The user's saved cards and
combos come first; nothing the user did not pick is added.

Written to the user's handoff (.tmp, three points). Code checks only, as
asked: no picture was made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:14:36 +08:00
DemonGatanjieuandClaude Opus 5.5 0bed7973e0 Add a mid-way model switch way; the model-switch ways work on Anima
New way switch_midway (refine, want midway): the canvas's sampler becomes
two KSampler Advanced parts, the first on the canvas's model up to step
21, the second on a new loader from there, taking the old sampler's place
and links. On Anima it takes the tested sampling (35 steps,
dpmpp_2m_sde_gpu, beta): er_sde comes out grainy when split, even with
the same model in both parts, which the AI is told.

refine_other_model and upscale_two_models get Anima variants: a UNET
loader "like" the canvas's, reusing its text encoder, VAE and prompts; on
Anima the second-model upscale is the Tile route the user verified (1.22x
latent, LLLite 0.7, denoise 0.85, ColorMatch).

A node "like" or replacing a sampler now takes its seed as noise_seed on
KSampler Advanced and the other way round. The scene argument's
description is shorter to keep the tool list within its budget.

Ran on the isolated test ComfyUI on an Anima canvas: each way built
through add_nodes and made a picture (79 s, 148 s, 127 s on 6 GB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:24:58 +08:00
DemonGatanjieuandClaude Opus 5.5 186f5467b0 Lay a workflow's details out as flow, preset tabs and the preset shown
The left column is gone. The details are one column: the flow, a row of
tabs under it (the workflow's own values, each preset with how many
values it changes, + to save the canvas or copy the one shown, all
pictures and versions at the end; it stays in view while the panel
scrolls) and the tab chosen in a panel joined to it.

The flow is small and shows only the structure: the stages in the order
they run, then the finishing nodes and the saves; above them one lane
each for the model files, the LoRAs and the control models, each a bar
over the stages it feeds (recipe_flow.flowLanes, tested). Steps, splits
and strengths moved into the boxes' tooltips; the legend and the hint
line are gone. What the preset shown changes from the workflow's own is
orange, and its head says how many values that is. A box's values open
under the flow only once one is picked, with x to close.

The set rows' key-value chips went with the column; the tabs count the
differences with ui_param_set_keys.setDifferences. Remaining "方案"
labels in the preset menus now say 预设.

Checked on the isolated test ComfyUI with the two-pass Anima card: each
preset marks the right boxes, the inspector, the + menu and its editor,
all pictures, versions, sticky tabs, and the narrow (docked) layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:05:38 +08:00
DemonGatanjieuandClaude Opus 5.5 d45fdb9d7c Show a workflow's models, control and finishing in its flow, each box opening its values
The flow now reads the whole graph: the model files and LoRAs (strength, the stages they
feed), control models above the stage they guide, finishing nodes such as ColorMatch after
the samplers. A stage the shown values do not run (a split's empty part, a bypassed sampler)
hangs dashed beside a straight line and no longer counts as changed when only its model
changed. Every box opens its node's values below, changed ones against the workflow's own,
and an unused box names the presets that use it. The in-flow preset row is gone; the left
column already lists them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:21:04 +08:00
DemonGatanjieuandClaude Opus 5.5 d4ead01c77 Open a workflow's details with its flow and call its sets presets
A workflow card's details now begin with its flow, in the look of a
stage diagram: one box per sampler in the order they run (base picture,
goes on, enlarge and redraw), each with the main model it is fed
through LoRAs and patches (+ Tile for a Tile LLLite), its steps or the
part of a split it runs ("Not run" when none), the enlargement and the
redraw strength; then the saves. Under it the default and each preset;
the one shown is lit and the stages it changes are marked. The stages
are read from the saved graph alone (recipe_flow.js, pure, with
tests/recipe_flow.mjs); the drawing is ui_recipe_flow.js.

"参数方案" / parameter sets are now 预设 / presets everywhere on screen
(the term table in ui-flows.md lists the old word as retired). "Hand it
to the AI" moves from the bar to the preset's own actions and hands the
preset shown ("workflow / preset"), which run_workflow already applies.

Checked on an isolated ComfyUI with the verified two-pass Anima card:
the default, the "upscale on Realskin" and the "switch mid-way" presets
each mark the right stages and show their models and steps; the hand-off
sentence names the preset. Full suite and structure check pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:51:41 +08:00
DemonGatanjieuandClaude Opus 5.5 3d720ef38d Let the AI run a workflow card's parameter sets by name
A workflow card's saved parameter sets (方案) were the user's alone. Now
list_workflows lists each card's sets as "workflow / set" (those unlike
the card's own values, as the Workflows page lists them), and
run_workflow(workflow="workflow / set") opens the workflow and applies
the set with the page's own Apply code before filling in the blanks; the
tab is marked with the set too, so another set opens afresh. No new
argument: the tool list stays within its budget.

The Anima skill points at the sets: the daily run is the user's saved
workflow or one of its sets.

Checked on an isolated ComfyUI: a card built from the verified two-pass
Anima graph with its own values as the plain route and two sets (upscale
on another model, model switch mid-way); run_workflow on the second set
queued exactly its values and made pictures pixel-identical to that
route's verified ones. Full suite and structure check pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 17:48:27 +08:00
DemonGatanjieuandClaude Opus 5.5 c495482a85 Keep the Anima illustration skill to judgment only
A skill now says how to judge and names the parts; models and values
live in the way and in the user's own combos and workflows. The Anima
skill drops the tested models, LoRA and sampler numbers (the
upscale_anima_tile way keeps its second-pass values), says to use a
workflow or combo the user saved for this route as it is, and keeps the
findings as judgment: show the base first, what Tile does and does not
keep, and when changing models between passes is untried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:52:22 +08:00
DemonGatanjieu 73d04041df feat: add Anima Tile illustration skill 2026-11-02 04:16:21 +08:00
DemonGatanjieuandClaude Opus 5.5 98c90dd8e4 Move training out of AMB: drop the run record and the training network
AMB only uses: training goes to its own plugin. This takes back the
record of what works (api/ai_evidence.py), the training network and
rounds (api/ai_training.py, ui_ai_network*.js, the round form), the
Gates' record switch, the ways' order by record and the external_skill
field of log_outside that only fed the record.

Kept, as part of using AMB:
- the user's word under a finished run, now only told to the AI
  (ai_run_control.set_verdict, ui_ai_verdict.js);
- the Gates' "Custom nodes: only the ways'" rule;
- a picks block left to the AI takes its recommended picture.

Reverts 73518dc, 67bf5b2, 6647b05 and 49d05cf, then puts those three
back with tests in test_ai_run_watch, test_ai_gates and test_ai_panels.
Full suite and structure check pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:11:05 +08:00
DemonGatanjieuandClaude Opus 5.5 49d05cfa69 Make the training network a full view over the AI page
The side switch's training network now opens over the whole AI
workbench: numbers on top, a filter by kind, a graph or a table with a
cell per pair, and a detail column listing each run of a pair (model,
AI app, judgement, pictures, task), a base model's models and the
training rounds. New GET /anomalous/ai_training/runs; the network data
adds each model's runs and the rounds.

Not yet verified in the browser: Esc after routing it through the
browser's back buttons. Full test run pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:11:02 +08:00
DemonGatanjieuandClaude Opus 5.5 6647b059e4 Hide the training network's record from the AI unless the user shows it
The AI goes by the ready-made ways and skills alone by default; the Gates
window's "training network record" shows it the record, and a training
round the user starts always does. add_nodes still picks the way that did
best either way. The network view says whether the AI sees it now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:09:26 +08:00
DemonGatanjieuandClaude Opus 5.5 67bf5b24c9 Add the training network and AI-run training rounds
The AI workbench's side column gets a training network view: base models
on the left, ways, skills and node packs on the right, a line for each
pair that ran, coloured by how it turned out (the user's word first, else
the AI's, dashed) and as thick as it ran often. A node or line shows its
record, last pictures and the pairs not tried yet in a card kept in sight.

"Start a training round" picks a skill (the AI's prompts with its advice
and without) or the ways of one use, up to four models and the pictures
each, and makes a task for the next AI (api/ai_training.py). Each run that
ends fills the trial it fits by what it used; the AI is told the next trial
and, once a model's trials are done, to compare them in a picks panel with
its own pick marked recommended. The record keeps the AI's pick apart from
the user's, and a picks block left to the AI takes its pick.

Tests: tests/test_ai_training.py; full suite and structure check pass.
Checked on an isolated ComfyUI: a round started from the view, four real
runs matched to their trials with the next one told each time, the
comparison panel and the user's pick reached the record and the graph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:02:59 +08:00
DemonGatanjieuandClaude Opus 5.5 73518dcf88 Add a record of what works: ways, skills and packs by model
Each run the AI starts now counts for the ways it still has, the skills
fetched before it and the custom node packs of its nodes, by the base
model of its main model (api/ai_evidence.py). How it went comes from what
the user does anyway: a picture picked in a picks panel or kept in the
gallery, one passed over, or their word under the run (Turned out well /
Not quite, told to the AI as their message).

begin_task and get_toolbox give the AI the record in a few words, add_nodes
a way's or the packs' own, and add_nodes(use=...) picks the use's way that
did best on the canvas's model. The Ways panel shows each way's and skill's
record by model. The Gates window gets "Custom nodes: Any / Only the ways'",
which keeps the AI to the ways' nodes and the task's picks.

Tests: tests/test_ai_evidence.py; full suite and structure check pass.
Checked on an isolated ComfyUI: a real run was recorded with its model and
skill, the verdict reached the AI, and the ways-only rule refused a custom node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:15:04 +08:00
DemonGatanjieuandClaude Opus 5.5 bbe9d8a8c4 Model versions: keep the tab clear of the labels in compact view
The compact views set the card labels' padding with !important, so the
room kept for the versions tab was lost and the pin and title slid under it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:23:20 +08:00
DemonGatanjieuandClaude Opus 5.5 4e5faad768 Give a private task its own skill only, in the Ways panel too
ai_skills.get refuses a private-only skill in a task that is not private and every other
skill in a private one, so a private task's AI gets the privacy skill alone; get_toolbox
tells it by the task. The Ways panel lists the skills of the task's kind and is drawn
again when the task's lock is turned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:15:48 +08:00
DemonGatanjieuandClaude Opus 5.5 eceb51b6a1 Seal a private task's prompts and picture names as tokens, with a prompt box
ai_sealed keeps a private task's prompt text and picture file names behind tokens ([P1],
[img1], task["sealed"]): mcp_server puts the texts back into a call's arguments for the tool
only, and ai_privacy.guard has every result masked on its way out (a node's STRING inputs
by ComfyUI's node list, prompt fields, picture names, any sealed text, also in the text
content). The user's own canvas changes are told sealed. A "prompts" panel block is the
user's prompt box (ui_ai_prompt_box.js): prompts, a negative one and reference pictures from
the gallery or dropped in, answered only from Anomalous, the AI getting tokens; above it the
prompt_pose skill's words for the canvas's model. A new private-only skill, privacy, tells
the AI how to work, and begin_task points a private task at it. Tests plant canary prompts
and picture names in every tool's result and check none gets out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:56:37 +08:00
DemonGatanjieuandClaude Opus 5.5 34eba4a901 Add privacy mode: a private task sends the AI none of its pictures
Each task has a lock in its header (task["privacy"]); the Gates window sets the default for
new tasks, and turning the default off pins the tasks it covered. mcp_server hands every
tool result to ai_privacy.guard last, after run pictures and the user's messages were
added, so a private task's image content never leaves; a call tied to no task is private
while the default is on or a private task is unfinished. The card in the AI app shows no
pictures for it, begin_task tells the AI, and turning it on or off is a user step and a
decision. A test calls every tool with a picture in its result and checks none gets out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:20:41 +08:00
DemonGatanjieuandClaude Opus 5.5 87be68ad34 Drop the pop-up frame from the Workflows page
It fills the page like Combos but kept a pop-up's border, round corner and dark shadow,
which showed as a grey edge in the light theme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 12:26:00 +08:00
DemonGatanjieuandClaude Opus 5.5 6417ad4549 Make an open model's versions stand out in the light theme
A thin pale orange edge went unseen on a light page: the whole accent, doubled by a ring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 12:19:01 +08:00
DemonGatanjieuandClaude Opus 5.5 1bc06f7400 Keep the versions tab dark over the cover in the light theme
Like the labels and buttons laid over a picture; it took the light theme's dark text on
its dark background and almost disappeared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 12:15:04 +08:00
DemonGatanjieuandClaude Opus 5.5 f65f12152b Fix model kinds misread from headers, checked against real files
Checked against the headers of 315 models in ComfyUI-Manager's list: a Hunyuan-DiT
checkpoint and LBM were read as VAEs, SUPIR as a ControlNet, XLabs' Flux LoRAs and
IP-Adapter as diffusion models, a whole CLIP as a text encoder only; also PhotoMaker, LoRAs
of weight differences or OFT, and checkpoints without a VAE. Files two loaders read from
different folders (SUPIR, a whole CLIP) are not sure. A lone drawing model in checkpoints
loads there (the checkpoint loader reads it as a diffusion model). Newly told: diffusers
UNets, Stable Cascade, SDXL T2I-Adapters, RCAN/SPAN/compact upscalers. 301 of 315 now agree.
"Looks like no drawing model" said what it could not tell: now "Kind not told". Kept header
answers carry the rules version and are read again when it changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:15:37 +08:00
DemonGatanjieuandClaude Opus 5.5 c30180cb8d Fix Tidy misjudging Anima LLLite and IP-Adapter files
An Anima LLLite in controlnet is not misplaced while the ComfyUI-Anima-LLLite pack
(which reads it from there) is loaded, and identical copies kept one per loader folder
(controlnet and model_patches) are listed as shared, both in use, not as duplicates to
recycle. An IP-Adapter carrying LoRA layers (the Anima IP-Adapter) is no longer read as a
LoRA. The header cache keeps the new "form" field (older entries are read again).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:27:49 +08:00
DemonGatanjieuandClaude Opus 5.5 e956ad8dea Fold the versions of a model behind a tab on its card
A model with other versions downloaded is one card with a numbered tab on its right edge;
a click slides the versions out beside it, under one accent edge, the tab on the last folding
them back. A pin on each picks the version shown when folded. Open models stay open, and
"Open all versions" opens or folds every one. Replaces the version label menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:06:27 +08:00
DemonGatanjieuandClaude Opus 5.5 de333ce016 Let run_workflow queue a list of prompts as one run
prompts (1-8, instead of prompt; at most 16 tries with batch) fills and queues each prompt
in turn with the call's other blanks; the receipts form one run and by_prompt says which
are which. A prompt that cannot be filled or queued stops the rest, the queued ones stay
tracked and submission_error names where it stopped. The run_workflow description is
tightened so the tool list stays within its budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 05:23:25 +08:00
DemonGatanjieuandClaude Opus 5.5 36668d2ebf Show on the timeline which skills the AI went by
Each skill the AI fetches with get_toolbox(skill=...) is a "skill" step of its task
(ai_skills.note_use), with the form its advice came in for the canvas's model; the same
advice again before the next run adds nothing. A run's row lists the skills fetched since
the run before it (ai_task_view.skillsBefore). A skill step never names an untitled task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 05:17:37 +08:00
DemonGatanjieuandClaude Opus 5.5 927e35edc7 Give the pose advice by the size of the canvas's main model
The prompt_pose skill now comes in the form for the model the canvas's sampler uses:
its drawing model's parameters are counted from the file's header (model_kind.
diffusion_params: safetensors shapes, GGUF tensor dims; a checkpoint's text encoders
and VAE left out), so a compressed GGUF counts like the full model. From 10 billion
(Flux.1, Flux.2, Qwen-Image) the AI is told hands and hard poses come out well and
not to hide or simplify; under it, the clear-pose and optional hand-hiding advice;
when the model cannot be told, both cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 05:01:39 +08:00
DemonGatanjieuandClaude Opus 5.5 b261968e79 Show the versions of one model together and switch between them
Files a scan found on the same Civitai model page (metadata model_id; never a similar
file name) share one card in the model list, the newest version shown or the one picked
last (kept in this browser). The card's version label opens the shared action menu to
switch. The details list the versions here under the name, and "All versions on Civitai"
(GET /anomalous/download/versions, this computer only, asked only when pressed) lists
the page's versions to open or download beside this one, checked by SHA-256, a taken
file name getting the version's name added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 04:44:10 +08:00
DemonGatanjieuandClaude Opus 5.5 b616487d17 Add skills beside the ways: advice an AI fetches for a kind of job
The pose and hands advice for writing prompts moves out of the set_prompt and
run_workflow descriptions into the first skill, prompt_pose (api/skills/). begin_task
lists the skills that are on in a line; get_toolbox(skill=...) gives one; the prompt
arguments only point at it. The tool list is back within its budget (it was 44726 of
44000 characters). The Ways panel lists the skills under the ways, each with a switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 04:33:48 +08:00
DemonGatanjieu 1b0d6c5ba1 Allow naturally hiding nonessential hands 2026-10-30 03:52:26 +08:00
DemonGatanjieu e4f8d45d17 Guide flexible prompt poses through MCP 2026-10-30 03:48:12 +08:00
DemonGatanjieuandClaude Opus 5.5 71dca092b6 Mark the enlarge-then-fix hands way a fallback
In the user's tests Codex's FLUX-Fill redraw of one crop per hand did better.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:22:25 +08:00
DemonGatanjieuandClaude Opus 5.5 83dc2aed2f Enlarge a picture for fixing hands by its size: about 4 MP, 1x to 2x
1.5x left the Codex test picture's hand better but still soft; 2x gave
sharper fingers in the same seeds (about 105 s instead of 75 s on a 6 GB
card). The fix-hands way now enlarges to about 4.2 megapixels, never
smaller, at most 2x: reference_blocks.enlargement reads the picture's
size (add_nodes passes picture_size of its input copy). Tried through
AMB on an isolated ComfyUI: 2547x1656, no memory trouble, 2 clean hands
of 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:03:29 +08:00
DemonGatanjieuandClaude Opus 5.5 b3f40131cf Warn about a sampler's size only past what a 6 GB card did fine
The comfortable size was 4.2 megapixels on 12 GB, 2.1 on 6 GB: the fix-
hands way's 4-megapixel redraw got "ask the user whether to stop" though
a 6 GB RTX 4050 ran it (Anima, 10 steps) in about 100 s. It is now 8.4 on
12 GB (4.2 on 6 GB); the decode warning keeps its old line in its own
constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:03:29 +08:00
DemonGatanjieuandClaude Opus 5.5 2a80e1e96d Add the fix-hands way: enlarge the picture, then redraw each hand
A block (detail, want hands) that takes a picture: enlarged 1.5x and
redrawn lightly by the canvas's sampler (denoise 0.4), then every hand
found by hand_yolov8 redrawn at guide 768, denoise 0.45 ("detailed
hand"), saved as hands_fixed. Tried on the Codex test picture on an RTX
4050: the hand pass alone fixed about half the seeds, enlarging first
about 3 in 4; the way built through AMB on an isolated ComfyUI gave 2-3
good hands of 4.

On an Anima model a variant adds the Anima Turbo LoRA (0.7) for 10 steps
at CFG 1, about 75 s; a variant's LoRA missing falls back to the plain
block with a word on what it would do. Its next step tells the AI to
make 4 tries, check the hands itself, retry at most twice, and let the
user pick in a zoomed panel. The Turbo LoRA joins the recommended models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:35:10 +08:00
DemonGatanjieuandClaude Opus 5.5 3f36ebee89 Fix AMB not loading: the viewer's size words pulled in a cycle
ui_gallery_viewer.js (8c044d0) imported formatSize from
ui_model_download.js, which leads through the settings page and
browser.js back to ui_ai_workbench.js; with ui_ai_images.js ->
ui_gallery_detail.js -> ui_gallery_viewer.js at the start of that chain,
ui_ai_workbench.js called setMarker before ui_ai_images.js had run, and
the whole plugin failed to load ("Cannot access 'marker' before
initialization"). formatSize now lives in file_size.js, which imports
nothing, and every module takes it from there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:26:36 +08:00
DemonGatanjieuandClaude Opus 5.5 efe281c757 Never send a video as its own gallery card
When a video's first frame could not be read (a broken file, no decoder)
the card's thumbnail route served the whole video as the picture. It now
gets the same SVG placeholder with its size as a picture with no card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:43:42 +08:00
DemonGatanjieuandClaude Opus 5.5 88eaa77472 Make fixing one spot keep its size, and let picks zoom on it
Codex's hand tests (2026-10-29): at denoise 0.55-0.75 the repainted hand
filled its whole mark and came out too big, at 0.4 it kept its size. The
crop-and-stitch block now repaints at 0.45 (the built-in one at 0.5), and
its next step and the inpaint note tell the AI to mark tightly, keep the
strength low, make 4 tries and let the user pick.

Slider and picks blocks take a zoom {x, y, w, h}: every picture shows
only that part, enlarged, from the picture itself. The checks of block
parts move to ai_panel_parts.py (ai_panels.py was at the 600-line line).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:39:10 +08:00
DemonGatanjieuandClaude Opus 5.5 ca05da0966 Give Load Image an input copy of an output picture, and tell the AI what the page marks missing
run_workflow's image filled a LoadImage with "name.png [output]"; once
ComfyUI's run history is gone, its newer frontend marks that value as a
missing file though LoadImage reads it (Codex found this on 2026-10-29).
An output picture is now copied into the input folder (content-named,
transparency kept) as the gallery's menu already does.

get_situation's canvas errors also carry what the page's missingMedia
store marks missing, so the AI sees the red error the user sees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:37:09 +08:00
DemonGatanjieuandClaude Opus 5.5 0740e7c5c8 Give each try of a batch its own seed
run_workflow's batch queued the same prompt N times when the sampler's
seed was held "fixed": ComfyUI ran it once and the user had one picture
to pick from. While a batch is queued a fixed seed counts up instead
(the native control moves it per prompt); afterwards the control and the
seed are as they were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:36:47 +08:00
DemonGatanjieuandClaude Opus 5.5 2fdc2f5f30 Warn before a run saves a very large picture
The run estimate followed each sampler's size but not what a SaveImage
finally gets: a 5120x3328 sample upscaled 4x saved 20480x13312 (324 MB)
without a word. A save past 64 megapixels now warns on the timeline and
in the AI's run answer, with the PNG size it will take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:24:31 +08:00
DemonGatanjieuandClaude Opus 5.5 8c044d0d5a Keep very large pictures from freezing the gallery
A 20480x13312 upscale is past Pillow's pixel limit, so its card image
failed and the catch-all fallback served the 324 MB original as the
thumbnail: the gallery and the AI workbench decoded it every time.

- A picture past the limit is shrunk once by a child process
  (huge_picture.py, low priority, one at a time) and cached; a card is
  never a large original, one with no card image gets an SVG with its size.
- /anomalous/output_preview gives the viewers the picture or a cached
  4096 px copy of a very large one; the viewer (moved to
  ui_gallery_viewer.js), the image workbench's stage and the AI pages'
  larger pictures use it, with a note and "Open the original".
- The listing carries each file's size (and big ones' pixel size); such a
  card says "Very large".
- The browser-side PNG metadata read stops at the image data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:23:22 +08:00
Claude 57bc795ce7 Merge branch 'feature/1.62' into feature/1.63 (v1.62.0)
# Conflicts:
#	CHANGELOG.md
2026-10-29 02:06:29 +00:00
Claude e01524c80e v1.62.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 02:06:12 +00:00
DemonGatanjieuandClaude Opus 5.5 5f336d39cd Ways: each block says what it does in one sentence
The panel's text for each of the 18 blocks (about, zh and en) is one short sentence; where it came from and how it was tried stay under Details, the AI's instructions (after, notes) unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 02:19:59 +08:00
DemonGatanjieuandClaude Opus 5.5 cb403867de Ways panel: grouped by use, details folded
The two new uses get their headings (换背景·人物不变, 换模型重画); each block's source, how it was tried and its models sit under Details, so a row reads as its title and a line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 02:16:44 +08:00
DemonGatanjieuandClaude Opus 5.5 0ea99a10f1 Ways by use: the AI names a use, Anomalous picks the block
The 18 blocks are grouped into 7 uses (a block's "job": repaint,
new_background, upscale, refine, follow, detail, process); a block that is
not its use's default has a "want" word (best, 2x, pose, ...).
add_nodes(use=…, want=…) has reference_blocks.pick choose the first by rank
this ComfyUI can run (packs and models present, not turned off), the block
taking a second picture when one is given; block= by name still works.
begin_task's directory is a line a use with its wants, the rest by what it
needs ("LanPaint: repaint best"), so it no longer grows with the blocks and
the word budget tiers are gone. Tried live: use=new_background with two
pictures built place_in_scene.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 00:09:49 +08:00
DemonGatanjieuandClaude Opus 5.5 d0de7cab54 Refine with another model: single-file checkpoints only, says so
Its CheckpointLoaderSimple takes any SD 1.5 or SDXL family checkpoint; a model in separate UNET, CLIP and VAE files (Anima) does not fit it, which the block and the changelog now say instead of "another family too".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 23:48:54 +08:00
DemonGatanjieuandClaude Opus 5.5 0f9738b708 Ways directory: names before what to install goes
begin_task's text was at its 4000-character limit with every new block. The
directory now shortens in steps: every word while it fits (625 characters),
then the blocks' names with the missing ones grouped by what they need
("LanPaint: inpaint_lanpaint"), then names alone. With every pack in, the
names (about 400) leave begin_task near 3850 even with the stale tool list
line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 23:48:10 +08:00
DemonGatanjieuandClaude Opus 5.5 d0190a1eec Ways: new style, same layout
restyle reads a handed picture's outlines with ComfyUI's own Canny, holds
the layout with the union ControlNet (canny mode, 0.7 to 80% of the steps)
and redraws the whole picture in the prompt's new style. Tried on an RTX
4050 6 GB with SDXL (Illustrious) 832x1216: at denoise 0.8 it only got
lighter, at 1.0 it became a watercolour with the layout held, so 1.0 is
the default; about 41 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 23:29:16 +08:00
DemonGatanjieuandClaude Opus 5.5 6ccb93e385 Ways: refine with another model
refine_other_model redraws the made picture at denoise 0.4 with a second
checkpoint of its own (any family: its own CLIP and VAE, prompts copied from
the first, LoRAs added onto it with add_lora) and saves it beside it
(prefix refined), as the reference workflow's refiner keeps a loader and
LoRA stack apart. Tried on an RTX 4050 6 GB, Illustrious then NoobAI at
832x1216: about 66 s for the whole run, the composition kept, the style the
second model's.

A block node may be "like" a path ("$sampler.positive"): it takes the
settings of the node feeding it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 23:17:51 +08:00
DemonGatanjieuandClaude Opus 5.5 ce4b8a8a49 Ways: put a character into a given picture
place_in_scene takes two pictures (add_nodes' new scene argument, $scene in
a block marked "scene"): the character is cut out (inspyrenet-rembg), the
place scaled and cropped to its size (GetImageSize, ImageScale), the
character pasted on (ImageCompositeMasked) and everything else redrawn at
denoise 0.5 with the edge grown 6 px, so shadow and ground meet it while
the place keeps its look. Tried on an RTX 4050 6 GB with SDXL (Illustrious)
832x1216: about 40 s.

The blocks' short words are shorter so begin_task stays under 4000
characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 23:08:16 +08:00
DemonGatanjieuandClaude Opus 5.5 d95b2b309f Ways: new scene, same character
change_scene cuts the character out of a handed picture (inspyrenet-rembg),
inverts it and redraws only the rest at denoise 1.0 from the prompt's new
scene; GrowMask lets 6 px of the edge be redrawn for the seam. Tried on an
RTX 4050 6 GB with SDXL (Illustrious) 832x1216: about 30 s, the character
kept, the forest replaced by a night street.

A picture handed to a block may be written "name.png [output]" as Load
Image shows it. Two block descriptions are shorter so begin_task stays
under 4000 characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:52:30 +08:00
DemonGatanjieu d1d53cfc63 Merge branch 'feature/1.62' into feature/1.63 (v1.61.1) 2026-10-28 21:36:08 +08:00
DemonGatanjieuandClaude Opus 5.5 9c5075b3a3 Merge branch 'feature/1.61' into feature/1.62 (v1.61.1)
The delete-character change was already on 1.62; its CHANGELOG lines move to the v1.61.1 section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:35:57 +08:00
DemonGatanjieu baedf16b6f Merge branch 'feature/1.62' into feature/1.63 (delete character needs TTS interface 19) 2026-10-28 20:59:57 +08:00
DemonGatanjieuandClaude Opus 5.5 be91723c57 v1.61.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:59:28 +08:00
DemonGatanjieuandClaude Opus 5.5 f3100efde7 Delete character needs Anomalous_TTS interface 19
Interface 18 shipped in Anomalous_TTS v1.1.0 without the route, so the
button now waits for 19 (v1.1.1) instead of failing on a click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:56:46 +08:00
DemonGatanjieuandClaude Opus 5.5 f1ae47f21a Delete a character from its card, to the Recycle Bin
Anomalous_TTS issue #2: there was no way to remove a character. Each card
now has an orange Delete character button (also on broken characters);
after a confirm, the node (interface 18, local only) moves the character's
folder to the Recycle Bin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:56:42 +08:00
DemonGatanjieuandClaude Opus 5.5 5767b4614c Delete character needs Anomalous_TTS interface 19
Interface 18 shipped in Anomalous_TTS v1.1.0 without the route, so the
button now waits for 19 (v1.1.1) instead of failing on a click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:56:27 +08:00
DemonGatanjieu 2f8db12f8e Merge branch 'feature/1.62' into feature/1.63 (delete a character) 2026-10-28 20:42:10 +08:00
DemonGatanjieuandClaude Opus 5.5 9a270d0a42 Delete a character from its card, to the Recycle Bin
Anomalous_TTS issue #2: there was no way to remove a character. Each card
now has an orange Delete character button (also on broken characters);
after a confirm, the node (interface 18, local only) moves the character's
folder to the Recycle Bin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:42:06 +08:00
DemonGatanjieu 65f7b5140d Merge branch 'feature/1.62' into feature/1.63 (audio gallery under Voices) 2026-10-28 20:31:50 +08:00
DemonGatanjieuandClaude Opus 5.5 cae7fd2a4a Audio gallery becomes a view of Voices; the rail fits the number keys
With the AI workbench the rail had ten entries for nine number keys, so
the audio gallery had none. It is now the third tab of Voices (Characters,
Voice-over, Audio); 9 reopens Voices on the view used last, and a test
keeps the rail within nine entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:26:31 +08:00
DemonGatanjieuandClaude Opus 5.5 d610d69a1c Merge branch 'feature/1.62' into feature/1.63 (output pictures in Load Image)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:57:22 +08:00
DemonGatanjieuandClaude Opus 5.5 1d444f0669 Let the AI's Load Image take an output picture
new_workflow / add_node checked a LoadImage value against the input folder's files only, so
an AI could not build an img2img on a picture it had just made (and nothing at all with an
empty input folder). Names ComfyUI annotates ('x.png [output]') now pass; ComfyUI checks the
file. CHANGELOG also notes the rail labels fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:57:21 +08:00
DemonGatanjieuandClaude Opus 5.5 78bfd1d553 Merge branch 'feature/1.62' into feature/1.63 (rail labels)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:35:27 +08:00
DemonGatanjieuandClaude Opus 5.5 d3f249fd1a Rail shows 模型 / 图库 again, not the raw keys
ce35145 removed 'models' and 'gallery' as unused, but the rail (labelKey) and the page
titles (TITLE_KEYS) name them by key. A test now checks every such key in both languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:35:26 +08:00
DemonGatanjieuandClaude Opus 5.5 78502e66c4 Ways: a maintained models list with links and SHA256, and how each was tried
- api/block_models.json: the 15 models the ways recommend, each with its
  Hugging Face / Civitai / GitHub links, size, SHA256 (all checked against the
  sites), licence and folder. A block's "models" names its recommended ones; the
  Ways panel ticks those here (by name, or by size for a renamed copy) and
  downloads a missing one in a click with model_download.downloadFrom, its hash
  checked.
- A block's "tested": the card (RTX 4050 laptop, 6 GB), sizes and a whole run's
  time, shown in the panel and told to the AI with the block (answer.tested).
- color_lineart gets an Anima variant with Anima's lineart LLLite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:14:48 +08:00
DemonGatanjieuandClaude Opus 5.5 ba1d124fc1 Tile-held upscale, line drawing colouring, union types, a second look for new models
- upscale_tile: the 1.25x upscale with a tile ControlNet holding the redraw
  (the source's 0.6 to 80%), so it can redraw at 0.5; NoobAI's tile model for
  NoobAI/Illustrious, the union one for SDXL, Anima's tile-repair LLLite on
  Anima (slow on a 6 GB card, the AI is told to say so).
- color_lineart: a line drawing, inverted to white on black, steers a lineart
  ControlNet; its lines are left as a preview.
- A union ControlNet gets SetUnionControlNetType for what it follows.
  ControlNets not scanned yet take their base from their folder or name.
- MissingModel: a block's model not in ComfyUI's lists makes add_nodes read
  them anew once (a model just downloaded into a subfolder), then try again.
- The upscales prefer the least enlarging model that reaches their size.

Live on the isolated 8199 ComfyUI: NoobAI tile upscale 77 s, Anima tile 710 s,
lineart colouring 34 s (the union model found after the lists were read anew).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:55:35 +08:00
DemonGatanjieuandClaude Opus 5.5 80ebf5f954 Test: the Anima depth build keeps its map as a preview too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:03:14 +08:00
DemonGatanjieuandClaude Opus 5.5 8b51dffd2b Follow a pose reads it with DWPose; pose and depth maps left as previews
OpenPose misread anime characters; DWPose (torch detector: the onnx one found
nobody with this onnxruntime-gpu) reads a single anime character down to face
and fingers. A crowded, heavily stylised picture can still come out empty, so
both control blocks keep their map as a PreviewImage the AI sees in
run_status's previews, and their next step says what to do when it is empty.

Live on the isolated 8199 ComfyUI: DWPose followed a hand-behind-head pose
(64 s); the three-elf picture stays unreadable for both readers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:59:55 +08:00
DemonGatanjieu 81d0b59c41 Merge branch 'feature/1.62' into feature/1.63 2026-10-27 15:30:17 +08:00
DemonGatanjieuandClaude Opus 5.5 c882d79eea A failure that named no target is resolved by a later success of its tool
279ad22 keys recovery by target, so a first try with missing or misnamed
arguments (a light model's {} call) recorded no target and a corrected retry
could never match it: the task stayed marked "a step failed". Such a failure is
now resolved by any later completed success of the same tool; a failure with a
target still needs that target's success.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:30:08 +08:00
DemonGatanjieuandClaude Opus 5.5 e34112463e Blocks built for the canvas's model: Anima LLLite for depth, pose and repaint
A block's "variants" rebuild it for the canvas model's family
(reference_blocks.build, which also returns its roles and next step): on an
Anima model control_depth and control_pose patch the model with an Anima
ControlNet-LLLite instead of a ControlNet, and inpaint_crop_stitch adds Anima's
repaint LLLite on the crop; the AI still only names the block. Base packs are
checked before the page is asked which model the canvas has.

Live-tested on the isolated 8199 ComfyUI with an Anima model: repaint changed
the marked hand (43.9) and nothing outside (0.0); depth steers gently, so the
Anima next step says the prompt has to agree with the picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:16:04 +08:00
DemonGatanjieu 279ad22a20 Honor completed task follow-ups and count only completed target retries as recovery 2026-10-27 03:58:05 +08:00
DemonGatanjieuandClaude Opus 5.5 dd9c3ee2f5 Tags from a picture, run words to the AI, colour matching after upscales
- tags_from_picture: WD14 tagger (the source's model and thresholds) on a
  picture the user handed; run_status now carries what nodes showed as words
  (texts: a tagger's tags), with a summary and next step for a run that made
  words but no pictures.
- Optional nodes: a block node marked optional goes when its pack is missing,
  and a link "match|decode" takes the first one there. The four upscales end in
  KJNodes' ColorMatch (mkl, the source's) when it is installed; the panel says
  "better with" it otherwise.
- LanPaint's next step keeps the prompt about the whole picture; Follow a pose
  says OpenPose misreads anime (use control_depth), as the source's author did.
- The directory has a size cap: past it, block names alone, so collected ways
  never make begin_task long; shorter headings and few-word lines.

Live-tested on the isolated 8199 ComfyUI: LanPaint (area changed 14.6, outside
0.0, 82 s), tiled 2x (167 s), depth ControlNet (follows the layout), OpenPose
(misreads an anime picture), background removal (8 s), tags (166 s with the
model download), upscale with ColorMatch (73 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:31:04 +08:00
DemonGatanjieuandClaude Opus 5.5 22a58d3a83 Ways panel: blocks as data files, a switch each, greyed when something is missing
Each block is now api/blocks/<name>.json (nodes, packs, models, the AI's words
and the panel's in both languages), so a way collected later is one more file.
The AI workbench's side column has a Ways view: blocks by job, what each does
and where it came from, greyed with the pack (opens ComfyUI-Manager) or model it
lacks, and a switch (user/anomalous/ai_blocks.json) that keeps it from the AI;
begin_task lists only the blocks on and runnable here, the rest in one line.
New blocks: upscale_iterative (Impact Pack), upscale_two_models ($loader role,
a second loader "like" it), remove_background (inspyrenet-rembg).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:40:51 +08:00
DemonGatanjieuandClaude Opus 5.5 5d023f3a7c Blocks: LanPaint repaint, upscale, tiled upscale, pose and depth ControlNets
A block node can be "like" a role (takes its settings) or "replace" it (its
links, place and settings too; LanPaint's sampler for the KSampler). $save is
the output node the picture reaches, past a stitch. Upscale blocks take an
upscale model this ComfyUI has; control blocks a ControlNet whose base fits the
canvas's model, and the picture to follow copied into the input folder. The
directory is grouped by job, a few words a block, to keep begin_task short.
Drop-down choices are read in both forms of ComfyUI's node list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:01:09 +08:00
DemonGatanjieu becca7cdb1 Merge remote-tracking branch 'origin/feature/1.62' into feature/1.63 2026-10-27 01:22:21 +08:00
DemonGatanjieuandClaude Opus 5.5 6ce8af60cd Repaint block: the margin the source used, denoise 0.75, an area prompt
A Flash exam showed the crop-and-stitch repaint painting a whole new
person into the hand: the context margin was 1.2 where the distilled
workflow used 2.0, and the sampler kept denoise 1 and the whole
picture's prompt. A block can now set settings on the canvas nodes it
finds (roles: the sampler's denoise 0.75, in the same Ctrl+Z step),
and the block's answer and the repaint note tell the AI to set the
prompt to what belongs in the area. Retake: a natural hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:22:21 +08:00
DemonGatanjieuandClaude Opus 5.5 39a0e50fc1 Keep what the AI shows right after done in that task
From a Flash run: after report(done) it showed a before and after and
asked whether the user is happy, and both went into a new task. A
panel, question or note within 5 minutes of the AI's own report(done)
now opens that task again (unless the user started or wrote to
another); a look still leaves it ended. "Just done" now means ended
by a report, not that the report is the last step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:20:30 +08:00
DemonGatanjieuandClaude Opus 5.5 c9870151c1 Ready-made blocks the AI names and Anomalous builds, spliced in
api/reference_blocks.py holds groups distilled from workflows that do
one job well: a repaint that crops around the mark, repaints at 1024
and stitches back (ComfyUI-Inpaint-CropAndStitch), the same with
built-in nodes only, and face and hand detailing (Impact Pack, the
detector models this ComfyUI has). begin_task hands the AI their
directory; add_nodes(block=...) builds one in one call. Its links name
roles web/modules/canvas_roles.js finds on the canvas by their sockets
(the sampler, its decode and save, what feeds an input upstream), so
canvas_wiring splices it into the workflow there; several samplers
need near_node_id, a missing pack names the pack and the stand-in.
The repaint note now points to the block. Checked on the test ComfyUI:
the repaint changed the marked area only, the detailer the face area.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:57:50 +08:00
DemonGatanjieuandClaude Opus 5.5 1d3bb5ae2b Task list says what is true: recovered errors, looks after done, waiting tasks
From the user's first real run: the finished task showed as failed for
one wrong argument corrected on the next call, a look the AI took
right after report(done) made an empty one-step task, and a task the
user started 18 hours earlier still read "waiting for the AI" though
it had lapsed after 12 hours. Now only failures the AI did not get
past mark a task, a read-only call within 5 minutes of report(done)
goes into that task (it stays ended) unless the user started or wrote
to another, and a task the user starts waits until an AI takes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:58:50 +08:00
DemonGatanjieuandClaude Opus 5.5 1b03553946 Correct a needed argument only from a known other name
A value of the right type under an unknown name may mean something
else (set_prompt {style: "anime"} is no prompt), so the correction now
takes only the other names light models were seen to write in the tool
exams (name/workflow_name, file/filename/image_name, text/positive,
model/id, node/id); any other name is answered with what the tool takes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:34:04 +08:00
DemonGatanjieu db405a79f0 Keep corrected MCP arguments consistent and preserve answer editing state 2026-10-26 21:25:15 +08:00
DemonGatanjieuandClaude Opus 5.5 6289e895ee Take a misnamed needed argument as meant; second Flash exam
A call lacking its one needed argument but carrying one unknown
argument of that type is run with it (open_workflow {name} is
{workflow}), and the AI is told the right name. The timeline names
zoom_image steps; a test makes every tool have a name in both
languages. tool-exam.md records the second exam, all eight tasks
including the repaint one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:23:03 +08:00
DemonGatanjieuandClaude Opus 5.5 54b072bd91 Keep half-typed answers: no auto-pick over them, no wipe on redraw
A question the user began to answer is never answered with the
recommended option on time-out; the AI is told they began an answer
and ends its turn. On the page, what was half typed in a question's
line survives the question being drawn again (another question came,
the AI read an answer), with the cursor; that question stays first in
the strip; a narrow window lights the stage tab instead of taking it
from under the user's typing; Enter that picks an input-method word no
longer sends a stage answer line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:02:46 +08:00
DemonGatanjieu e29fd6f5f5 Fix task resumption and answer delivery at wait deadlines 2026-10-26 18:42:32 +08:00
DemonGatanjieuandClaude Opus 5.5 9633d3451a Make tools easier to get right on the first call, from a Flash exam
Gemini 3.8 Flash took seven exam tasks through Antigravity (107 calls, 19
failed, every task done). Nearly all failures were a tool's first use:
empty or guessed arguments. So:

- An argument error lists what the tool takes ("set_prompt takes: prompt
  (needed), role, node_id, mode"), and the next call is right.
- One argument name per thing: set_prompt's text is prompt (as in
  run_workflow), open_workflow's name is workflow (as in run_workflow).
- A wait over 55 s waits 55 s instead of failing; search_models takes a
  model type in the singular too.
- An unread reply older than two hours no longer draws a new chat's calls
  into its old task (a two-day-old mark took the first exam task).

The exam doc gets the setup, the Antigravity command line and the results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:24:58 +08:00
DemonGatanjieuandClaude Opus 5.5 0764683c52 Remove the daily tool set: AI apps get every tool
Antigravity, Claude Code, Cursor and VS Code load tool descriptions on
demand themselves; looking tools up through Anomalous (tool_help, use_tool)
only doubled that: in one Gemini 3.8 Flash run 18 of 29 calls went to it,
while full-set chats had none. tool_help, use_tool, action_catalog.DAILY,
ai_gates.tool_set and the Gates row go.

An app still holding an older list is now told apart by the list it fetched
last (a fingerprint per app in anomalous/ai_tool_lists.json), so the note
under the task header also covers an Anomalous update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:01:28 +08:00
DemonGatanjieuandClaude Opus 5.5 cb523930da Guide the AI with fewer detours and guesses
- An AI app calling by an older tool list (tool_help or use_tool not offered,
  use_tool for a tool offered directly, a tool the list lacks) is told so, and
  the task header shows how to reconnect until the app fetches tools/list again.
- add_lora / set_model compare the base models on the model line
  (model_advice.py; the page reports the line) and add base_model_note.
- get_model gives author_examples (sampler, steps, CFG, size, a LoRA's weight
  from the Civitai record's example pictures) and the version name.
- run_workflow says ComfyUI's refusal as which node, what is wrong and the tool
  that fixes it (run_refusal.py; mcp_run.js passes the refusal as data).
- Gates "When I don't answer": wait for me, or go with the recommendation once
  the waiting time runs out (choices with a recommended option only), handed to
  the AI in the same call and shown on the stage and under Decisions.
- A tool budget test (count, size, description length, required arguments) and
  the exam tasks with their scorecard (docs/architecture/tool-exam.md,
  tools/tool_exam.mjs). The tool set's text recommends Full for Antigravity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:26:41 +08:00
DemonGatanjieuandClaude Opus 5.5 9f9a13b11b Keep CHANGELOG.md's CRLF line endings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:55:16 +08:00
DemonGatanjieuandClaude Opus 5.5 342946141e Make the AI's questions and the waiting page say what to press
The user took an option for something to select and then pressed "skip"
to confirm; boxes shown open read as fields to fill.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:54:20 +08:00
DemonGatanjieuandClaude Opus 5.5 6443ddf81b Let the AI agree on the repaint area with the user first
The user's marks are rough. propose_repaint_area takes the AI's area for
them (boxes or ellipses, grown and softened by pixels), draws both areas
tinted over the picture into the task's marks folder and shows them side
by side on a panel (block "area"): the AI's, the user's own, or marking
again, or words on what to change. The picked area is written into the
input folder like the user's own mask (ai_marks.write_inpaint, soft edges
kept) before the answer is recorded, so the AI reads inpaint_image and the
inpaint note with it. Building the repaint stays the AI's: the note names
the usual nodes, no template. Options Anomalous makes carry a page word
(label_key). The panel hint and guide say "Your call".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:02:50 +08:00
DemonGatanjieuandClaude Opus 5.5 6dbb73591f Keep the AI waiting for the user's answer; daily tools reach the rest
- The daily tool set now also offers tool_help and use_tool, and the
  directory marks the tools reached that way; show_panel joins the daily
  set. The "lazy" set is gone: a small model spent a third of its calls
  looking tools up and never built what it planned.
- Gates window "Waiting for your answer" (1, 3 or 10 minutes): an open
  question tells the AI to wait on with get_answers until then, and while
  the user types (POST /anomalous/ai_tasks/{id}/typing) until a minute after
  the last key. report and show_panel remind the AI to end each round with
  ask_user; a question written in a note is pointed to ask_user.
- "Skip this one" is "Your call": the AI decides and does not ask again.
- ComfyUI's own `$$` widgets (the canvas image preview) are no longer
  reported to the AI as the user's canvas change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 01:49:34 +08:00
DemonGatanjieuandClaude Opus 5.5 fd952fb9ce Go on in the task the user wrote to, not a new one
When the user wrote to an older task while the AI had moved on, the AI's
next call (once its own task ended or paused) started a new task, where
report(reply_to=...) could not find the message. Now an app with no open
task goes back to the newest task of its own holding a reply it has not
read, and opens it again; an open task it is busy with stays. Naming the
calling app moves to api/ai_callers.py to keep ai_tasks.py under its size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:30:22 +08:00
DemonGatanjieuandClaude Opus 5.5 ea9d446151 Open a finished task again when the user writes to it
A message or marks on a task the AI had ended stayed "done": no line to
copy for the AI showed, and the AI's next call started a new task where
report(reply_to=...) could not find the marks. Writing to the task now
clears `ended`, so the copy line shows and the AI goes on in it; an older
ended task with an unseen reply also offers the line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:49:12 +08:00
DemonGatanjieu f543eb92c8 Merge branch 'main' into feature/1.62 2026-10-25 22:19:28 +08:00
DemonGatanjieuandClaude Opus 5.5 3679f42095 README: re-shoot the combo pictures on 1.61 (right-click → AMB → Save as combo, new combo page)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:19:23 +08:00
DemonGatanjieu 39f5c2380c Merge branch 'feature/1.61' into feature/1.62 (v1.61.0)
# Conflicts:
#	CHANGELOG.md
#	web/modules/locales.js
2026-10-25 22:04:35 +08:00
DemonGatanjieuandClaude Opus 5.5 730aec54f7 v1.61.0
Update guide for 1.61 (quick search, canvas menu, combos kept from pictures, tidier model
folders, multi-character voice-over), CHANGELOG heading dated, README badge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:04:01 +08:00
DemonGatanjieu 13698c2389 Merge branch 'feature/1.61' into feature/1.62 2026-10-25 19:27:37 +08:00
DemonGatanjieuandClaude Opus 5.5 0e1fc778d5 Hide the floating button while a model picker is open
Opened from the canvas's right-click menu the browser is closed, so the floating button
(topmost on purpose) sat on the picker's head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:27:30 +08:00
DemonGatanjieuandClaude Opus 5.5 8df38d1c8d Clear detached graphs after reading them (prompt boxes left on the page)
A workflow read off a picture (keep as combo / workflow, parameter compare) was configured
on a detached LiteGraph graph whose nodes still made ComfyUI DOM widgets; they stayed on
the page at its top left. readDetached reads, then clears the graph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:25:28 +08:00
DemonGatanjieu bc9d52c4f3 Merge remote-tracking branch 'origin/feature/1.61' into HEAD 2026-10-25 15:05:06 +08:00
DemonGatanjieu cac468c808 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	CHANGELOG.md
2026-10-25 15:05:02 +08:00
DemonGatanjieuandClaude Opus 5.5 0956f7fc02 v1.60.2: the browser opens whatever the plugin folder is called
The stylesheet and the no-cache rule used the fixed path
/extensions/Anomalous_Model_Browser/. A plugin unpacked from a GitHub ZIP
sits in Anomalous_Model_Browser-main: its look never loaded, the browser
opened unstyled under the canvas (a click seemed to do nothing) and the
top-bar entry showed although the floating button was chosen. Both now
follow the folder's real name. Reported in a Bilibili comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:01:18 +08:00
DemonGatanjieuandClaude Opus 5.5 ce35145e67 Remove the 32 screen words no code uses any more
Old toolbar, scan-wizard and workspace labels and a few hints, in both languages; each
checked against the web files, tests and the backend (no t() use, no key built in a
template, no error message used as a key). The UI map's scanner read a quoted string with
an escape ('\n') wrong and missed the key after it on the same line (delNote is used).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:45:14 +08:00
DemonGatanjieuandClaude Opus 5.5 b220fc8a31 Call a saved workflow a workflow on screen, not a recipe
- The 54 screen words that said 配方 / recipe for a saved workflow say 工作流 / workflow
  (the Workflows page, saving and exporting, the activity log, backups, the gallery's keep);
  配方包 / recipe package stays the .zip given to others. README and the MCP guide too.
- The Workflows page's empty guide drops its fallback texts: translate() returns the key
  when a word is missing, so they were never shown and still said 配方.
- The glossary retires 配方 and recipe; a retired name may list where it is not that name
  ("配方(配方包、搭配方案除外)"), and {placeholders} do not count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:52:08 +08:00
DemonGatanjieuandClaude Opus 5.5 375ba6ce26 A UI map: from a word on screen to its code, styles, route and backend function
- tools/ui_map.mjs find <words on screen | key | /anomalous/route | file.js>, read from the code
  each time (tools/ui_map_scan.mjs: the words, what each web file shows and calls, its CSS
  classes, imports, the backend's routes); build writes the whole map to the untracked
  docs/ui-map.md, with the routes no page calls and the words no file uses.
- docs/architecture/ui-flows.md: the hand-written part for the AI workbench (what each part
  is for, what a click does, why) and a glossary of one name per thing. The structure check
  fails when a word in it is no longer on screen, a file or route it names is gone, or a
  retired name is back on screen.
- One name each on the AI page: 素材 (not 材料), 留言 (not 消息), 节点结构 (the / command
  too), 连接 for the last AI, 副本 for the task's tab, 撤销; English "Node map".
- AGENTS.md: look a button up with the map before changing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:29:04 +08:00
DemonGatanjieuandClaude Opus 5.5 0de22f8f46 AI workbench: how to change a marked picture, a quicker task list, "AI edits a copy", lit source rows
- Marker: "make a repaint mask" becomes How to change it (leave it to the AI, only the
  marked areas, the whole picture anew); the AI reads the choice first as the user's, the
  timeline row says it, the repaint brush picks "only the marked areas".
- Task list: folded, it stays laid out out of sight and slides in at once from its button
  or the page's left edge; redraws make anew only the rows that changed (sync_rows.js).
  A picked task shows at once from the copy read before or read ahead on hover
  (ai_task_cache.js), then what changed; activity entries follow instead of holding it up.
  Stage pictures load as thumbnails first, the file itself only when shown larger.
- Header: "Own tab" is "AI edits a copy ▾", a two-way menu with what each means (kept open
  across redraws), and "Show the copy".
- Materials: the Source list lights the hovered row and ticks the one shown (a later rule
  had hidden both), tooltips only for cut names, arrows and Enter (ui_ai_facet_menu.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:08:46 +08:00
DemonGatanjieu ae1d3b3a17 feat(ai): show stage content count and unread indicator 2026-10-25 00:42:24 +08:00
DemonGatanjieu 3b0857f4f1 fix(ai): make jump to latest responsive and uninterrupted 2026-10-25 00:32:22 +08:00
DemonGatanjieu d54c0a0791 feat(ai): collect task outputs in a results side panel 2026-10-24 21:08:36 +08:00
DemonGatanjieuandClaude Opus 5.5 3092e201ec Make the ways to the right column easy to see
Each panel's line in the timeline becomes a card: an icon of what it holds,
a line of what is in it, and a solid "Show on the right" button that turns
into "On the right now" while the panel is in view on the stage. A run's
"See it large on the right" uses the same button, and the blue text links
under steps become small buttons with icons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:22:46 +08:00
DemonGatanjieuandClaude Opus 5.5 b9e864cca1 Open the stage when the AI puts a new panel on it
A new panel opens the side column on the stage at that panel, even after
the user closed it or picked another view; the AI changing a panel does not.
A switch in the stage's head turns it off (kept on this browser). In a
narrow window it lights the stage's tab instead of taking the timeline's
place; a question still brings the stage forward.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:14:47 +08:00
DemonGatanjieuandClaude Opus 5.5 a33d838d7f Fold the AI's working steps in a row into one timeline row
Three or more reads, canvas changes and decisions in a row, two of them not
reads, become one row: how many steps, chips of what they did, the last one
while the AI is at it, and undo for their canvas changes. It opens to the
steps as before, and a jump to one of them opens it. Words, notes,
questions, panels, runs, errors and waiting or running steps stay rows of
their own. The folded rows move to ui_ai_chain.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:08:43 +08:00
DemonGatanjieuandClaude Opus 5.5 24a9eb9ed9 Make ask_user wait for the answer even when told not to
The AI's question waits at least 30 s, so an answer the user gives in
Anomalous right away reaches the same call and the AI goes on without a word
in its chat (Gemini asked with wait_seconds 0, stopped, and the user had to
say "go on"). add_node's description points to add_nodes for a group of
nodes wired to each other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:16:10 +08:00
DemonGatanjieuandClaude Opus 5.5 72e0132c14 Keep lines for the AI's chat over the message box
When the AI is not in a call, a run or a question, the AI workbench offers a
line to copy into the AI's chat, picked by what waits for it: the run that
ended, a reply in Anomalous it has not got yet, else "Go on", and a line
that picks the task up in a new chat. The lines under a finished run's step
move there. The Hand to AI sentence no longer names show_panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:01:45 +08:00
DemonGatanjieuandClaude Opus 5.5 59916d1ea3 Offer an on-demand tool set: a few tools, and the directory for the rest
A third choice for "Tools for the AI" in the Gates window. With "On demand",
AI apps are offered only action_catalog.CORE plus two tools of
mcp_directory.py:
- CORE: begin_task, get_situation, run_workflow, run_status, report, ask_user,
  get_answers.
- tool_help: the description and arguments of up to three tools named in the
  directory.
- use_tool: calls any tool of the directory.

tools/list drops to 11 tools and about 9,000 characters (daily 22,200, full
40,700). With this set the directory begin_task brings names every tool and
says how to reach the ones not listed.

use_tool has no run of its own. mcp_server._call makes the call the tool it
names before anything else, so the user's gates, the timeline's step, the run
watch and the answer are all that tool's; the timeline never shows use_tool.
An unknown name, the card's own tools or wrong arguments come back as tool
errors that point to tool_help.

The default stays Full. Switching back to Daily or Full in the Gates window
undoes this without a revert, and this commit stands alone if it has to go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:11:41 +08:00
DemonGatanjieuandClaude Opus 5.5 9f0133e1b7 Give the AI a directory of the tools by what it wants to do
In the user's real tasks the AI spent many calls finding its way: reading the
canvas, searching node types, looking at combos and the activity log. The
directory gives it what ARCHITECTURE.md gives the code: each tool offered,
under its stage of the work, with its title. The stages are run, canvas,
models, prompts, combos, pictures, voices, the library, and the task and the
user. Seeing it, the AI can go straight to the tool it needs.

The directory is built from action_catalog's stages and the tool specs, so a
new tool shows up by itself, and a test checks that every stage has a heading.
It lists only the tools offered: about 1,100 characters for the daily set and
2,000 for the full one. begin_task's answer carries it, and so does
get_situation's while there is no task. The server's instructions and
begin_task's note point to it.

retext, the helper that rewrites a result's text from its data, moves from
ai_task_runs to mcp_tools, so both modules share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:04:53 +08:00
DemonGatanjieuandClaude Opus 5.5 f7d8a69e75 Shorten the daily tools' descriptions and begin_task's rules
The descriptions of the daily set are rewritten without the repeats and
roundabout phrasing. Their rules and don'ts stay: no cropping or editing
pictures with code, no reading ComfyUI's folders, ask the user in the
workbench. So do the shared property texts: the query words, node ids, the
task id. tools/list for the daily set drops from about 27,300 to 22,200
characters, and the full one from 46,000 to 40,700. begin_task's note, read at
the start of every task, drops from about 1,900 to 900 characters. The
replacements were made with ast, so each description's adjacent literals
changed as one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:54:42 +08:00
DemonGatanjieuandClaude Opus 5.5 6246df480d Let the user tell the AI when its run is done instead of the AI polling
Waiting on runs was the biggest share of the AI's calls in the user's real
tasks (85 of 316 run_status calls, about ten per ten-minute run). A new Gates
setting, "Waiting for runs", defaults to "Tell AI when done" (ai_gates.run_wait,
stored beside the tool set in ai_tools.json).

In that mode:
- run_workflow still waits its 15 s, so a short run comes back done with its
  pictures.
- Otherwise the answer's note, `next` and the your_run sentence tell the AI to
  say the run is going and end its turn, not to wait with run_status.
  run_status still honours wait_seconds, so a model that polls anyway is no
  worse off than before.

When the run ends before the AI knows, the server marks the step `call_ai`. The
workbench then shows lines to copy into the AI's chat (ui_ai_call.js): "It's
done, take a look", "Again with another seed", "Go on", or "It failed, find out
why". The mark goes as soon as the AI is told, or when a newer run starts, and
the lines go with it. The AI's next call brings the result and the pictures.
"AI waits" keeps the old behaviour. The setting takes effect at once, because
the words are made for each answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:45:39 +08:00
DemonGatanjieuandClaude Opus 5.5 a9928969f5 Show a finished run's pictures in the answer that tells the AI it ended
In the user's real tasks, nearly every run ended with get_image_info, then
show_panel, then report: one extra call just to fetch the picture. The answer
that first tells the AI its run ended now carries up to four thumbnails of the
run's pictures (pictures_attached). That answer can be run_status, run_workflow's
own wait, or any other tool's answer.

It happens once per run. status() and news() mark the run when they first tell
its end, and show_pictures, called for every answer in mcp_server, attaches the
pictures to the marked run and clears the mark. Runs told of before this change
carry no mark, so they never bring old pictures along. The done `next` now says
to report with the pictures and to use a panel only to compare or ask. tell()
and show_pictures share _retext.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:26:41 +08:00
DemonGatanjieuandClaude Opus 5.5 11ad97cb1f Add a group of nodes to the open canvas in one call
In one real task the AI built an inpaint branch with 9 add_node and 16
connect_nodes calls. add_nodes takes the whole group in ComfyUI's API form, the
format models know best:
- links inside the group, and to canvas nodes as "#12", with outputs by slot
  or name;
- a node's wire_to feeds canvas nodes the way add_node does.

All of it is checked before anything changes. The page lays the group out as
one block in a free spot (canvas_build.placedBlock) and applies it in one
transaction: one Ctrl+Z step, and a failure takes every new node out again
(applyWires now takes several nodes). The answer maps the AI's keys to node ids
and lists what is spliced and what is still unwired.

A splice now uses only an input that nothing else in the plan feeds. A node
whose latent comes from the group, or from wire_from, replaces the sampler's
empty latent instead (replace:true) rather than being fed twice. It is in the
daily tool set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:20:20 +08:00
DemonGatanjieuandClaude Opus 5.5 9b4e565826 Set any node's settings by name instead of growing the list of blanks
run_workflow's blanks were a fixed list (prompt, seed, size, image). Anything
else meant a set_values call per node, or yet another blank. Now `values`, on
run_workflow and on set_values without a node, sets any setting by its name, a
third-party node's too.
- The one node that has the setting gets it.
- When several nodes have it (a hires fix's two samplers), nothing changes and
  the nodes are listed, so the AI names one: {"12": {"denoise": 0.5}}.
- One Ctrl+Z step for all of them.

The remaining blanks are roles (which prompt box, which latent), not setting
names. `prompt` now stops when several positive prompt boxes are combined into
one sampler (regional prompts) instead of giving them all one text. A refiner's
own prompt still gets it.

The scalar check moves from mcp_canvas_edit to mcp_fill (flat_values), which
both use; set_values keeps its argument order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 15:13:07 +08:00
DemonGatanjieuandClaude Opus 5.5 4fcceffa1b Keep a hires fix's latent upscale out of run_workflow's size blank
width and height went to every node that outputs a LATENT and has a width
and a height. LatentUpscale has both, so a hires-fix workflow had its upscale
set to the start size and stopped upscaling. The size now goes only to a node
that starts a latent, one with no LATENT input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:59:05 +08:00
DemonGatanjieuandClaude Opus 5.5 7bbea78b1d Splice a new node in front of wired inputs and pair same-name sockets
GPT's auto-wiring (4afee83) was checked on the isolated test ComfyUI. A splice behind
a node, a new branch, the branch choice and the loop check each took one call. A
ControlNet still failed both ways, because its positive and negative have the same
type as a sampler's. A LoRA after a checkpoint wired only its MODEL line.

- Same-type sockets of the same name pair up (positive to positive).
- wire_to into an input that is already fed puts the new node in front: the old
  source now feeds the new node's matching input. A ControlNet before a KSampler
  is 6 links in one call; a LoRA before the sampler and both prompts is 5.
- insert_after splices every type a node passes through.
- add_node errors call the new node #new instead of #-1 and say how to pick sockets.
- frontend.md: the canvas_wiring paragraph moves from Verification to the
  mutation rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 14:48:37 +08:00
DemonGatanjieu 4afee8326c feat(mcp): add typed auto-wiring and compact workflow context 2026-10-24 09:53:22 +08:00
DemonGatanjieuandClaude Opus 5.5 7b43403243 Tell the AI the next step in the answers it acts on
A small model plans poorly but follows a line well. begin_task,
run_workflow and run_status, add_node, connect_nodes and remove_nodes,
and an answered ask_user now carry next: the one thing to do now (wait
again, look at the pictures and report, wire what is still unwired, find
the missing model). The run's per-state note becomes that next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 03:59:14 +08:00
DemonGatanjieuandClaude Opus 5.5 ca60ba36a2 Offer AI apps a daily set of tools, for a small model's everyday runs
Every chat began with 51 tools and about 43,000 characters of their
words, which a small model like Gemini Flash read half of before it
lost its way. The Gates window now offers either every tool or a daily
set of 19 (action_catalog.DAILY: runs of saved workflows with their
blanks, pictures, models and LoRAs, missing models, the task's own),
about 20,000 characters. tools/list follows the choice (ai_gates
tool_set, anomalous/ai_tools.json); calls are not limited by it, and an
app sees a change in a new chat. The window's rows share one builder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 03:55:28 +08:00
DemonGatanjieuandClaude Opus 5.5 ba45d611d2 Let run_workflow fill in a saved workflow: a daily run in one call
A daily run took six or seven calls (find the workflow, open it, read
the canvas, set the prompt, the seed...), each a decision a small model
could get wrong. run_workflow now takes workflow (a saved one, opened in
the task's tab the first time and reused after: the page marks the tab
with the workflow's file) and blanks: prompt, negative, seed, width,
height and image (image_node when there are several LoadImage nodes),
planned by mcp_fill.js and checked before anything changes; the rest
stays as saved. A run with blanks asks as the stricter of the GPU's and
the canvas's gate. get_situation sends an AI whose run failed on a
missing model to check_workflow_models instead of other choices.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 03:48:57 +08:00
DemonGatanjieuandClaude Opus 5.5 68342ad61d Give an AI where things stand in one call, and what is still unwired
A small model lost its way wiring the canvas: describe_canvas says what
feeds each input but not what is still free or what fits it, so it
looked up every node type and kept the graph in mind, and long answers
were filed away by Antigravity for the AI to open.

get_situation (mcp_situation.py) gives the task (what the user asked
last, what waits for them, decisions, pictures, steps outside AMB), the
canvas in brief (mcp_canvas_brief.js: a line per node, the inputs a run
needs that nothing feeds yet with the outputs that fit, ComfyUI's
errors), the latest run and the next step, kept under 3,600 characters.
add_node, connect_nodes and remove_nodes answer with what is still
unwired. The connect sentence, the rules and the instructions point a
new chat to get_situation first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 03:37:49 +08:00
DemonGatanjieuandClaude Opus 5.5 142074a320 Show what changed outside Anomalous while an AI works
Anomalous sees only the calls made to it; an AI app's own commands and
scripts are invisible, and one rewrote a result six times. When the AI
calls, Anomalous now looks at the top of the output and input folders
for pictures no ComfyUI run saved and pictures rewritten in place since
its previous call (by the files' own times, bounded, never in the
background; new pictures wait while a prompt runs), shows them as an
"Outside AMB" step and tells the AI with its answer (ai_outside.py).
log_outside is the AI's own word on a command or script it ran.

The connect guide gains rules to paste into the AI app's own rules:
use AMB's tools, change pictures only by running workflows, ask and
explain in AMB, and say what was done outside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 03:16:28 +08:00
DemonGatanjieuandClaude Opus 5.5 80ed034a24 Size a step's pictures to themselves instead of the whole row
One picture in a step filled the timeline's width as a wide grey box
around a small image. The cards now keep to about 300 px each, from
the left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 02:56:36 +08:00
DemonGatanjieuandClaude Opus 5.5 8ea8c08dd3 Grey a panel's old progress bar and centre a wide task column
A progress block on an AI panel is the AI's own number and never moves.
An AI drew one at 12% during a run, came back after the run with a new
panel and left the old bar standing, which read as live progress. Once
the task goes past a bar without an update (a run ends, a later panel or
note, the task ends), the stage now draws it grey and says when it was
written; update_panel records when a panel's blocks changed. The tool
text tells the AI a run's real progress shows already.

run_status before any run answers state "none" instead of failing, so a
task no longer shows a failed step for asking.

In a wide window the task column keeps its content to about 880 px in
its middle, as chat apps do, instead of stretching across the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 02:47:40 +08:00
DemonGatanjieuandClaude Opus 5.5 09136eabb5 Open AI tasks at their newest step and keep the grip off the scrollbar
A task now opens at its newest step instead of where the previous task
was scrolled to, and a timeline read at its foot follows new steps
whatever the task's state (before, only while a step was running).
Read further up, it stays put; a round button over the message box
leads back down, with the count of the AI's new steps on its corner.
It replaces the "new steps below" pill (ui_ai_newest.js).

The side column's grip lay over the timeline's scrollbar, so grabbing
the scrollbar often resized the column instead. It now has a gap of its
own beside the scrollbar, drawn as three dots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 02:13:31 +08:00
DemonGatanjieuandClaude Opus 5.5 0930d07d4f Give the AI a closer look and a run's previews, so it need not script pictures
In one hour Gemini ran 63 shell commands next to 23 AMB calls: checking files, cropping and
comparing parts of pictures, running OpenPose itself to see a skeleton, and painting pixels
with OpenCV (it overwrote an output picture six times).

- api/mcp_look.py zoom_image: part of a picture in the output, input or temp folder shown
  larger, the same part of a second picture beside it; read only.
- get_image_info reads the temp folder too; run_status lists a run's PreviewImage pictures
  (ai_task_runs.outputs_of(record, "temp"), kept as the step's previews, not its results).
- begin_task and the repaint note: AMB's tools cover looking; change pictures only by running
  workflows, never by editing picture files with code of one's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:21:27 +08:00
DemonGatanjieuandClaude Opus 5.5 c6d4ac9aa4 Let the AI read a picture in ComfyUI's input folder
The inpaint picture a repaint mark writes is in the input folder, which get_image_info did not
read, so the AI checked it with a shell command (and the user had to allow it). get_image_info
with folder=input gives such a picture's size and the area LoadImage gives as MASK (where and
how much), optionally attached; the marks message says the picture is in place already.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:00:51 +08:00
DemonGatanjieuandClaude Opus 5.5 cc0578f893 Keep tool results small enough to stay in the AI's chat
Antigravity saves a tool result over about 4,000 characters to a file and has Gemini open it;
in one evening 16 of its 32 file reads were AMB results (describe_canvas 9 times). Results are
now compact JSON (mcp_tools.tool_text, also for attached answers and run status), the canvas
leaves out the page's own "$$" widgets, and recent_activity gives the last 10 by default.
begin_task also tells the AI that AMB's tools cover models, pictures, nodes, canvas and runs,
so it need not read ComfyUI's folders, source or routes (each asks the user to allow it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 22:37:33 +08:00
DemonGatanjieuandClaude Opus 5.5 945934e0cc Move the run line over the message box with the run's progress
A run's progress comes in alone and only moved the timeline's bar and the header's button, so
the strip over the message box stayed at the run's start ("nodes 0/29, 0%"). The strip keeps
its context and the progress fast path updates it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 22:12:17 +08:00
DemonGatanjieuandClaude Opus 5.5 d834306a7a Keep the task's head to one line in a low window
When the AI page is lower than SHORT_PX (a window not maximised), the task's title, state,
time and steps, the task it goes on from and the tools share one line, the room around them
shrinks, the message box's hint shows only for a failure (its words become the input's
tooltip) and the waiting page's picture is smaller: about a fifth more room for the timeline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:53:38 +08:00
DemonGatanjieuandClaude Opus 5.5 3227a75c73 Add "Change type" for a model, and check copies at start, middle and end
- ui_model_retype.js: on a model's page, and from the toast after importing one model: where it
  is, what its header suggests (GET /anomalous/placement/look; picked and marked when it can
  tell, nothing guessed when it cannot), every type with its loader, where it goes, moved as
  Tidy moves (covers and notes along, never over a file). Kinds and the move's error words are
  shared (model_import.IMPORT_KINDS, model_placement.moveErrorText).
- model_import.move_into: a copy to another drive is compared with the original at its start,
  middle and end, besides its size, before the original goes.
- model_placement: the model a request names is resolved in one place (_source), which also
  refuses a negative root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:44:59 +08:00
DemonGatanjieuandClaude Opus 5.5 dae3632f1d Never replace a file that takes a model's name during a move
move_into renamed with os.replace, which on Windows replaces a file that appeared after
free_path chose the name. _rename_new refuses a taken name (Windows' rename does by itself;
elsewhere a hard link of the file, or of a link itself), so the move fails and both files stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:33:57 +08:00
DemonGatanjieuandClaude Opus 5.5 a74f885c53 Show what the task needs now over the message box; take dropped and pasted pictures
- ui_ai_now.js: over the message box, a run's progress with stop, the AI's step still going, a
  gate to allow or refuse there, and the AI's open question with its options as buttons (own
  words or skip too; several picks and pictures lead to the stage). progressWords is shared
  with the run card; the panel's and gate's answers are exported for it.
- ui_ai_drop.js: pictures dropped on the box or pasted are kept in output/ai_inbox
  (POST /anomalous/ai_pictures/upload, ai_pictures.save_upload: read as a picture first,
  25 MB cap, never over a file) and attached; ComfyUI output pictures dragged in attach as they are.
- Long AI notes are folded to their first lines with "Show all".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:24:48 +08:00
DemonGatanjieuandClaude Opus 5.5 ecbebc049d Dock the message box at the foot of the task like a chat composer
The message box stays at the bottom of the task column however the timeline scrolls. Over the
input, what the message carries (ui_ai_tray.js): pictures with ✎ to mark up (sent on their own)
and ×, chips for what "@" names; gallery pictures are attached, not typed in as "@name". Beside
it the gallery and materials buttons with their counts; the input grows (Enter sends,
Shift+Enter breaks the line) and pictures alone may be sent. "New steps below" appears when the
AI adds steps while the user reads further up; one's own message scrolls into view. Pictures a
message carries show as small image cards with the usual tools. The ready page keeps the big
gallery and what is ready; the box below takes its pictures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:11:10 +08:00
DemonGatanjieuandClaude Opus 5.5 6dc0a6bf98 Make picks, hovers and empty views give clear feedback
From a hover audit of the AI workbench and the other pages: picked gallery pictures get a thick
glowing frame, tint and big tick; the picture button lights while open and counts the message's
pictures; weak hovers (materials rows, folded reads with a chevron, gate options, mode switch,
source menu, send and copy buttons, model tabs, set-up button, activity rows) are clear. The
Stage button opens an empty stage that says what goes there; "Hand to AI · 0" is disabled with
nothing picked; move_model's step names the file; a one-node change shows one "find"; the task
link chip keeps its size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:50:23 +08:00
DemonGatanjieuandClaude Opus 5.5 3ab1fc7c29 Give a waiting task a page of its own for putting things ready
A waiting task now switches between Get started and Put ready at the top. Put ready shows the
gallery as a big searchable grid feeding the note below (click to add or drop a picture), the
note (the message box, moved in), the materials button and what is ready so far. The gallery
grid is split out of the message box's picker so both use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:26:10 +08:00
DemonGatanjieuandClaude Opus 5.5 bcb2577c2c Let the user put pictures and materials ready for a waiting task
A task waiting for an AI gets a "Put things ready" step: the message box (gallery pictures,
"@") moves into it beside the materials button, and what the user put ready is drawn there;
the AI gets it all when it takes the task. Pictures a message names show as thumbnails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 20:18:01 +08:00
DemonGatanjieuandClaude Opus 5.5 c41af60cd9 Tell the AI what loads a model, file model patches right, add gallery pictures to messages
- model_kind: Anima LLLite, Qwen-Image block-wise and Z-Image Fun ControlNets (and other
  ModelPatchLoader files) go to model_patches, not controlnet; import and Tidy follow.
- api/mcp_model_use.py: search_models / get_model say which node types load each model, or
  where a misfiled one belongs; move_model moves it there behind a new "files" gate (asks by
  default, never overwrites).
- An empty drop-down in add_node says ComfyUI found no file; the task tab switch is no longer
  logged as the user's canvas change.
- The folded task list stays while the pointer is in the box around its button and the list.
- The message box's picture button picks gallery pictures; "@" offers the gallery's newest and
  searches it as you type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:50:57 +08:00
DemonGatanjieuandClaude Opus 5.5 6e376e7caf Make the side column's grip easy to catch
The whole gap left of the card catches the pointer, top to bottom; the
handle shows faintly at rest and the edge lights up when pointed at.
Focused, the arrow keys resize it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:41:42 +08:00
DemonGatanjieuandClaude Opus 5.5 030e955332 Add a simple view of the run map
Per sampler: the model and LoRAs feeding it, its prompts, where it starts
(an empty canvas, a picture, or the part before, upscaled) and where it is
saved, "same as part N" when nothing changed; every name finds its node.
Simple (the default) and Full switch in place, kept on this browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:34:43 +08:00
DemonGatanjieu 47bab22373 Fix task canvas ownership and exact message references 2026-10-23 16:15:12 +08:00
DemonGatanjieuandClaude Opus 5.5 ac2aa471e9 AI workbench: pictures deleted since say so
A task's pictures no longer in the output folder are marked gone when the
task is read and shown as "Picture deleted" (timeline, results strip,
stage panels and picks) instead of a broken picture; "@" no longer offers
them. The AI hears plainly that the user deleted one, and a tool reading a
gone picture says it may have been deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:56:44 +08:00
DemonGatanjieuandClaude Opus 5.5 572caf9cb1 Tasks remember the task they go on from
A new task an AI begins while on another, or one the user starts while
looking at a task, keeps it as its "from". Links under the title lead both
ways, the list marks follow-ups, the user can link one by hand or take the
link off (never a loop), and an AI beginning a follow-up hears what was
decided in the task before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:46:20 +08:00
DemonGatanjieuandClaude Opus 5.5 bcb80bd7d3 Key decisions stay in Anomalous
The AI records what was settled with log_decision (a card on the timeline:
what, why, what was left out, who decided, the steps it is about); the
user's own choices in Anomalous count as decisions too (panel answers,
gates, materials, the canvas mode, stops). A Decisions view in the side
column lists them all, links each to its step and notes work done since
the AI's last one; begin_task repeats them to an AI picking the task up.
A step's words move to ui_ai_step_body.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:39:07 +08:00
DemonGatanjieu 036817bf48 Merge remote-tracking branch 'origin/feature/1.62' into feature/1.62 2026-10-23 15:18:08 +08:00
DemonGatanjieuandClaude Opus 5.5 7c8c3f27ed AI workbench message box: @ to name things, / for commands
"@" lists the task's pictures, combos, models, workflows, prompts, node
packs and node types; what is named rides with the message and the AI gets
each in full (pictures attached with what made them). "/" lists commands
that act at once (stop, undo, side views, new task, canvas mode) or fill in
words to send. get_toolbox shares the lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:17:56 +08:00
Claude 1f6bdd501f Merge branch 'feature/remote-access' into feature/1.61
# Conflicts:
#	CHANGELOG.md
2026-10-22 21:46:41 +00:00
Claude 3d41489f45 Merge branch 'feature/1.61' into feature/1.62 2026-10-22 21:46:41 +00:00
Claude a6115df306 v1.60.1 is a stable release, not a beta
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:46:18 +00:00
Claude 641ea9f93d Merge branch 'feature/1.61' into feature/1.62 2026-10-22 21:43:08 +00:00
Claude 6634f17bdd Merge branch 'feature/remote-access' into feature/1.61
# Conflicts:
#	CHANGELOG.md
2026-10-22 21:42:45 +00:00
Claude 44ac79a218 v1.60.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:42:25 +00:00
DemonGatanjieuandClaude Opus 5.5 eb3c6fe48d Materials side panel: sources as a button and a searchable list
Instead of a strip to scroll sideways, one "Source: All" button opens the
list of sources (or types), with a search when it is long; a pick, Esc or a
click elsewhere closes it, Esc without closing the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 03:14:59 +08:00
DemonGatanjieuandClaude Opus 5.5 68ec277b79 Materials: hand the AI a whole custom node pack
A pack shown in the nodes' second column has a bar to pick it whole
(packs, by folder). The AI gets its node types in get_toolbox and finds
them first; with "only" every node of the pack is allowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 03:05:17 +08:00
DemonGatanjieuandClaude Opus 5.5 5410ff797c Materials picker in the side panel: one line per item, search and filters on one line
A short panel lists many: badge or small cover, name, where it is from and
the tick per line; the filters become icons beside the search. Switching
between the side panel and the window redraws as lines or cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 02:57:18 +08:00
DemonGatanjieuandClaude Opus 5.5 0e4a60518d AI page scrollbars: thin, shown only while the pointer is over what scrolls
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 02:49:00 +08:00
DemonGatanjieuandClaude Opus 5.5 8ecd23a103 AI workbench: the side column as a resizable card, the task list folding from the title bar
The side column is a card of its own, narrower by default, its width set
by dragging its left edge (kept; double-click resets; the timeline keeps
its room). The task list folds from a title-bar button and peeks over the
page while pointed at. In the docked materials picker a card's detail opens
from its info button, not on hover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 02:23:55 +08:00
DemonGatanjieuandClaude Opus 5.5 d237f93b31 One view at a time in the AI workbench's side column, switched from the title bar
The stage, a run's node map at full size (pick the run; each run step has
"see it large") and the materials picker share the side column; the switch
sits in the window's title bar on the AI page, the shown one again hides the
column, and each view keeps its state. The task header stays on top while
the timeline scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:28:11 +08:00
DemonGatanjieuandClaude Opus 5.5 4dcda6edf8 Show whether the AI has each answer, and let a question be skipped
Every answer, message, set of marks and handed picture shows a pill:
sent and waiting, then the AI has it with the time it did (delivered_at).
Panel questions can be skipped; the AI reads skipped: true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:13:03 +08:00
DemonGatanjieuandClaude Opus 5.5 9abd596186 What the AI ran, a canvas tab of the task's own, and pictures straight to the AI
- Each run keeps its shape read from the prompt ComfyUI queued (ai_run_map.py): boxes by role
  with their settings, typed links, plumbing wired through, and what changed since the task's
  run before. The run step shows a one-line summary, the changes as chips and the boxes (top
  down, fitted to the column, each locating its node); show_run_map puts it on the stage.
- By default the AI works in a tab of the task's own (ai_task_canvas.py, mcp_task_tab.js): the
  open workflow is copied before its first change, the tab is brought back (a click on
  ComfyUI's tab) before later changes, reads and runs, and the user's tab stays untouched.
  Per-task switch in the header, default in the Gates window; the AI is told once.
- Output pictures can be handed to the task an AI is on (gallery card menu, the inspector's
  ✦), with the user's words: the AI gets them attached with what made them (ai_pictures.py).
  search_images(with_pictures) attaches the first six.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:56:01 +08:00
DemonGatanjieuandClaude Opus 5.5 049b265126 Run control: stop a run, see its console, time left, VRAM and size warnings; materials as a side panel
- A stop button on a running run (timeline, and beside the run-status button), and stop_run for
  the AI: only this run's prompts leave the queue or are interrupted; the step ends "cancelled"
  and a user's stop reaches the AI as a "stop" message (ai_run_control.py).
- The run's progress carries ComfyUI's console since it began (the bar's note, models loading),
  time left and per step, and VRAM; once queued, each sampler's working size is estimated from
  the prompt, with warnings for an upscaled picture sampled again, a size past the card and a
  huge decode, for the user and in the AI's run news (ai_run_watch.py, ui_ai_run_live.js).
- The materials picker opens as a side panel that leaves the page usable (the full-screen
  browser makes room), expands to the large window and back, and remembers the choice.
- Esc closes the run-status card only (data-anomalous-escape).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:23:08 +08:00
DemonGatanjieu b60ab63833 AI workbench: make status button icon-sized and clamp fixed popover when stage is open 2026-10-22 23:02:16 +08:00
DemonGatanjieu 0259b98af0 AI workbench: replace tall sticky execution banner with compact toolbar button and popover 2026-10-22 22:53:44 +08:00
DemonGatanjieu 183d6b7158 AI workbench: confirmed execution status header, bounded run_workflow wait, and track_run 2026-10-22 22:26:35 +08:00
DemonGatanjieuandClaude Opus 5.5 bb744a8c38 Repaint an area: the marker's brush makes an inpaint mask for the AI
The marker gains a repaint brush (size adjustable) and "make a repaint
mask": the page sends a mask (white where boxes, circles, lines and brush
strokes are) and the server writes the picture with that area as its
alpha to ComfyUI's input folder (anomalous_inpaint_<step>.png, a new file
each time), so LoadImage gives the picture and the area as MASK. The AI's
marks message carries the file and how to inpaint with it; the timeline
says the mask is ready. Canvas tools read ComfyUI's lists anew once when a
value is not among a drop-down's choices, so they take the new file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:27:07 +08:00
DemonGatanjieuandClaude Opus 5.5 353b5d9ed5 The AI replies in Anomalous: report(reply_to) and laid-out notes
report takes reply_to, the id of the user's message or marks it answers:
the two steps point at each other and the timeline links them both ways
("Reply to step 2" / "The AI replied · step 4"). The AI's notes are laid
out (headings, lists, bold, code) by ai_text_format.js with textContent
only. The notice that brings the user's marks and messages, the report
description and the server instructions tell the AI to answer in
Anomalous, and to ask which fix with ask_user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:15:43 +08:00
DemonGatanjieuandClaude Opus 5.5 52bd4cbce2 The AI asks in Anomalous: ask_user, with a suggested option and a line of the user's own
ask_user shows a question in the AI workbench (options with an optional
recommended one, and unless other=false a line for an answer of the
user's own; no options: a line) and waits in the same call for the
answer, returning the choice or the text. Choices blocks gain
recommended and other; the step is named ask_user ("Asked you"). The
server instructions, begin_task and the workbench prompt tell the AI to
ask every question this way, never in its own chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:51:03 +08:00
DemonGatanjieuandClaude Opus 5.5 355f95af48 AI workbench: a new look for the task list, the task header and a waiting task
The task column (now ui_ai_task_list.js) leads with a New task button, icon
buttons for Connect an AI and the gates, which AI came last, quick filters
by state with counts, and state icons; the current task has an accent bar.
A task's header uses icon buttons (Materials, Undo all, show the stage,
rename and remove as pencil and bin). A waiting task shows a picture, three
numbered steps joined by a line (what to do, the sentence with a large
Copy button, a faint preview of steps) and a connect card that opens the
guide in place; the empty page matches. One set of line icons for the AI
pages (ui_ai_icons.js). Illustrations made with ChatGPT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:37:57 +08:00
DemonGatanjieuandClaude Opus 5.5 f42a1f795e Materials window: ChatGPT illustrations for the empty selection and missing covers
The empty pane shows a toolbox with cards rising out of it, and a combo,
workflow or model without a cover shows a stand-in illustration of its
kind, all transparent WebP of a few dozen KB in web/assets (made with
ChatGPT for this window). The hand-drawn SVG picture they replace is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:20:29 +08:00
DemonGatanjieuandClaude Opus 5.5 fd9e415123 Materials window, redone: cards, sources, details, and no waiting
A large window: one search over every kind (/ jumps to it), kinds on the
left with picks and matches, a column by source (each custom node pack
with a badge of its own colour, ComfyUI's own, paid API nodes), prompt
origin or model type, a card grid drawn as it scrolls, the card in focus
in full on the right, and the picks below with thumbnails and a prefer /
only switch. A node card is the node in miniature, sockets in ComfyUI's
colours; combos, workflows and models show their covers; prompts their
text, including the prompts inside combos and workflows (combo:<name>,
workflow:<name>), which get_toolbox now resolves. Vector icons, badges
and the empty picture follow the theme.

It opens at once: node types come from the page's own definitions, the
rest in one answer (api/ai_toolbox_catalog.py) read ahead when a task
header is drawn and kept a minute, refreshed behind an open window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:46:31 +08:00
DemonGatanjieuandClaude Opus 5.5 622ae8be9a Materials for the AI: pick nodes, combos, workflows, prompts and models
A Materials button on each AI task opens a picker: every node type of this
ComfyUI grouped by source (built-in, each custom node pack from
/object_info's python_module, page-only helpers, paid API nodes last), plus
combos, saved workflows, prompts and models by the names the AI's tools
use. Picks go to the AI as a user_toolbox step (message kind toolbox), in
begin_task and in full from get_toolbox; "only" makes add_node and
new_workflow refuse other custom or paid node types (built-in stay
allowed). Favourites stand in for tasks without picks. find_nodes lists
picks first and marks paid API nodes. open_workflow opens a saved
workflow in a new tab; a workflow's page can hand it to the AI.

Checked on the isolated ComfyUI with copies of the user's 10 node packs:
all 1642 node types can be placed, and a Comfyroll workflow ran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:19:58 +08:00
DemonGatanjieuandClaude Opus 5.5 854e63ba71 The AI builds workflows on the canvas the user sees
find_nodes looks up this ComfyUI's node types (inputs, outputs, choices)
from its own /object_info. new_workflow opens a new tab, blank or built and
laid out from a workflow in API form, never touching the open one;
add_node, connect_nodes, set_values and remove_nodes edit it. Everything is
checked against the nodes before the canvas changes (types, loops, settings,
choices with close matches); each is one Ctrl+Z step, logged as the AI's
and shown under its workbench step. Receipts now show rewiring ("wired":
what feeds an input, before and after). A run ComfyUI refuses reports
ComfyUI's own reason instead of "Prompt execution failed".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:56:23 +08:00
DemonGatanjieuandClaude Opus 5.5 cce04d9ec1 Styles: reload the AI flow sheet for the run percent
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:42:06 +08:00
DemonGatanjieuandClaude Opus 5.5 70234d293d Run percent, and the run's news in the AI's other answers
The bar under a run shows a percent like ComfyUI's tab title (the running
node's steps, else the nodes done). While a run goes, every other tool answer
carries your_run with one sentence on where it stands, and the next answer
after it ends says so once (run_status telling it counts), so the AI need not
look in the output folder or at the screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:41:46 +08:00
DemonGatanjieu 249eac2dee fix(ai): track workflow results by exact submission IDs 2026-10-22 17:39:02 +08:00
DemonGatanjieu 6157585e3a Fix AI approval consumption, task input routing and run monitoring failures 2026-10-22 16:40:47 +08:00
DemonGatanjieuandClaude Opus 5.5 84d66bf932 Mark a picture for the AI: boxes, circles, arrows and lines, each with a note
- ui_ai_marker.js: ✎ on a picture of the AI page opens the marker (box, circle, arrow, pen; three
  colours; a note per mark and one for the whole picture). Send draws the marks onto the picture
  and hands both to the task; the timeline shows the marked picture and the notes.
- api/ai_marks.py: the PNG is checked (signature, size, dimensions) and kept with the task in
  anomalous/ai_marks/ (never the output folder; removed with the task); a user_marks step the AI
  gets as a message of kind "marks", where each mark is in fractions of the picture, with the
  marked picture attached as an image to the same answer (also from get_answers, whose tool now
  returns the answer and those pictures).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:08:52 +08:00
DemonGatanjieuandClaude Opus 5.5 57e70dee48 Run progress for the AI and the workbench: run_status, and a live bar under the run
- ai_task_runs.py reads where a run stands from ComfyUI's queue and progress registry (never
  changing them): queued and how many are ahead, or the node running, its steps and the nodes
  done. It is kept on the step in memory and sent as "progress" events that only move the
  step's bar; the run's step is saved only when something new is kept.
- run_status (read-only): the task's latest run and its progress, its pictures once done;
  wait_seconds (<= 55) waits for the end. run_workflow's answer tells the AI to use it instead
  of guessing from the time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:59:41 +08:00
DemonGatanjieuandClaude Opus 5.5 adcf5a9d15 Tests: a model note is cut only past the receipt's new 2000-character limit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:51:56 +08:00
DemonGatanjieuandClaude Opus 5.5 e57858c93b Canvas receipts: a prompt change shows the tags taken out and put in, with their meanings
- ui_canvas_changes.js: a prompt written as tags lists the tags taken out (red, struck) and put
  in (green) first; "Show meanings" puts each one's translation under it (prompt_gloss.js);
  "Whole text" marks those tags in both texts and dims the rest. Other text marks the part that
  differs. A tag the log cut in half at either end is never called changed.
- activity_log.py: before/after values keep up to 2000 characters (was 400), room for a whole
  prompt; longer ones are still cut around the first difference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:47:44 +08:00
DemonGatanjieuandClaude Opus 5.5 4f09154a49 AI workbench: a waiting task asks what you want and gives one sentence to send
The card of a task waiting for an AI (ui_ai_connect.js waitingCard) is centred: a line for
what the AI should do (it renames the task), the sentence to send the AI with Copy, three small
steps, and the first-time connection folded under "AI not connected yet?". No message box and no
timeline line while nobody has taken the task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:35:21 +08:00
DemonGatanjieuandClaude Opus 5.5 1dadd5b0f0 Hand to AI: a model, an image or a combo starts a task that carries it
- ui_ai_handoff.js: "Hand to AI" on a model's page, the image inspector (✦) and combo cards.
  The user says what they want (prefilled), Anomalous starts a waiting task with `about` (the
  model_id, the output image, the combo's name; checked in ai_tasks.start_task) and shows the
  sentence to send the AI; begin_task hands `about` to the AI so it needs no search.
- The AI windows sit above the image inspector they can open from; going to the AI page from
  there closes the inspector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:05:35 +08:00
DemonGatanjieuandClaude Opus 5.5 ccbd0a28cf MCP: Anomalous's card in the AI app's conversation, and ready-made prompts
- api/mcp_apps.py + api/mcp_app/task_card.html (MCP Apps, io.modelcontextprotocol/ui): apps that
  support it (Claude, ChatGPT, VS Code…) show Anomalous's own card under begin_task, show_panel,
  report and run_workflow results: the task's steps, latest thumbnails (inline), the AI's open
  questions answered with a click (app_answer, plus a chat message so the AI goes on). The card
  reads through app_task_view; both tools are for the card only (visibility "app"), are never
  steps and never take the user's news. The card never answers a gate: an OK is only given in
  Anomalous, so no tool an AI can reach grants one.
- api/mcp_prompts.py: prompts/list and prompts/get, four ready-made requests (slash commands):
  workbench_task, pick_best, fix_missing_models, explain_canvas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:55:48 +08:00
DemonGatanjieuandClaude Opus 5.5 cd9751ef14 AI workbench: the flow over a task, and gates that wait for your OK
- api/ai_gates.py: changing the canvas, using the GPU, downloading and scanning online can each
  go ahead, ask first or never (downloads ask by default; anomalous/ai_gates.json). A call that
  asks waits on its step (state "asking", what it would do listed) up to 50 s for Allow or No
  (with the user's reason for the AI); unanswered, the AI is told to wait and call again, the
  later answer reaches it as a message, and the same call after an Allow goes ahead.
- ui_ai_gate.js: the card under a waiting step and the Gates window; a toast while elsewhere.
- ui_ai_flow.js + ai_task_view.flowStages: the task's stages in order, each done, running,
  waiting or failed; a stage leads to its first step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:48:43 +08:00
DemonGatanjieuandClaude Opus 5.5 00958578fb AI workbench: one action catalog; your own changes join the AI's task; tick two pictures to compare
- api/action_catalog.py: every MCP tool's kind, stage of the work and gate in one place (a test
  keeps it and the tools in step); steps now carry their stage.
- api/ai_task_changes.py: changes the user makes in Anomalous while an AI works become steps of
  its task, reach the AI once with its next call (kind "change": keep it), and the activity
  entry names the task; the activity page links to it ("AI task ->").
- api/ai_task_runs.py: run watching moved out of ai_tasks.py (600-line rule); a step a restart
  left "running" is marked stopped when the record is read again.
- Pictures: inspector / compare with canvas / keep as tools on hover instead of three buttons
  under every picture; tick any two (any step or task) and compare from the bar at the foot of
  the page; the results strip shows thumbnails again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:38:37 +08:00
DemonGatanjieu 9b1d857e12 Integrate AI task results with gallery comparison and canvas receipts 2026-10-22 13:27:16 +08:00
DemonGatanjieu 97259ff8af AI workbench: record in-flight MCP steps and piggyback pending user answers/messages 2026-10-21 23:01:45 +08:00
DemonGatanjieu c924772344 Preserve chronological content in AI panel updates 2026-10-21 22:09:25 +08:00
DemonGatanjieu f694b40ab0 Add close and reopen controls for the AI stage 2026-10-21 21:57:01 +08:00
DemonGatanjieu 5c7f4b9cad Fix AI task handoff and stage bottom following 2026-10-21 19:36:40 +08:00
DemonGatanjieuandClaude Opus 5.5 6a256b7018 AI workbench: New task, Connect an AI, and the stage read top to bottom
New task: the user starts a task (POST /anomalous/ai_tasks) that waits for
an AI; the next app that calls takes it over, so its work starts there
instead of joining its last task. Connect an AI (ui_ai_connect.js): the
address, a sentence to send the AI so it adds the server itself and
starts on a waiting task, and each common app's way; beside the task list,
on the empty page and in a waiting task. The stage now lists the task's
panels oldest first with their step numbers, the newest at the bottom (a
new one scrolls there, or "New panel" says it came), and the strip of
results runs oldest to newest. The routes move to ai_tasks_routes.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 02:58:05 +08:00
DemonGatanjieuandClaude Opus 5.5 af627bff9d AI workbench: the stage, a column on the right for the AI's panels
Panels are shown on the stage beside the timeline (ui_ai_side.js), so they
stay in view however many steps follow; the timeline keeps one line per
panel that brings it there. The stage follows the newest panel until the
user goes back to another (pager or a panel's line). Too narrow for three
columns, the timeline and the stage become two tabs; a panel that asks
brings the stage forward. The task list can be folded away. What a step
made moves to ui_ai_results.js; the toast goes once the workbench opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:21:49 +08:00
DemonGatanjieuandClaude Opus 5.5 b9c8685b40 AI workbench: redraw only the steps that changed; diagrams read in order
While the AI works the task's parts stay in the page and a step is drawn
again only when it changed, so a diagram or strip scrolled sideways no
longer jumps back and half-typed text in a panel or the message box stays.
Diagrams put a box that only feeds one further on right before it and sort
boxes in a column by what feeds them. The message box and the home list
move to ui_ai_message.js and ui_ai_recent.js (the workbench was over 600
lines).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:06:51 +08:00
DemonGatanjieuandClaude Opus 5.5 8fe9106e7b AI workbench: write to the AI from a task; tasks grouped by day
A box under a task's timeline sends the user's message (POST
/anomalous/ai_tasks/{id}/message), kept as a 'user' step; get_answers now
also brings the user's messages and, with no panel named, waits for one.
The step says whether the AI has seen it; when the task has ended the box
says to ask in the AI app. The task list is grouped by day with an icon
for where each task stands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:23:39 +08:00
DemonGatanjieuandClaude Opus 5.5 5ae61fb0ee AI workbench: steps numbered in order down the timeline
Each step's dot carries its number (1, 2, 3...) in its kind's colour, reads
folded into one numbered row, and while the AI works the next number
waits at the foot, so the order is plain at a glance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:05:50 +08:00
DemonGatanjieuandClaude Opus 5.5 f57a78e08c AI workbench: the stage - marks on pictures, before / after, picks, diagrams
New panel blocks, data only and drawn by Anomalous: marks on pictures
(circles or boxes at fractions of the picture, numbered, labels under it
that zoom there), a before / after slider, picks (pictures with the AI's
reason, Take this one as an answer, Put on the canvas through ComfyUI's
own file loading, drag onto the canvas) and diagrams (boxes and arrows laid
out by ai_stage_layout.js; a box naming a canvas node finds it). No SVG
or drawing code of the AI's is taken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:54:04 +08:00
DemonGatanjieuandClaude Opus 5.5 0999e6f9c4 AI workbench: panels the AI draws from Anomalous's blocks, and asks you on
show_panel lays out a panel as a step of the AI's task from fixed blocks
(heading, text, pictures in a row, grid or side by side, the user's models
as cards with covers, table, list, params, progress, divider) and can ask:
buttons (one or several) and a line of text. api/ai_panels.py checks every
block (only these kinds, bounded plain text, pictures only from the output
folder, models only from the user's folders); nothing is run or read as
markup, and ui_ai_panel.js writes text only. Answers come in through
POST /anomalous/ai_tasks/{id}/answer and wait for get_answers (which can
wait up to 55 s); update_panel changes or closes a panel. A toast says
when a panel comes while the browser is elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:35:38 +08:00
DemonGatanjieuandClaude Opus 5.5 8b549f463a AI workbench: every step an AI takes through MCP, kept, opened, undone
A new rail page keeps each MCP call as a step of a task (api/ai_tasks.py,
one file per task in the user folder, the newest 300): named by the AI
with the new begin_task tool, else one app's calls without a long pause.
A step keeps what it did and what it made (models, the combo, the canvas
change by its activity entry, the pictures a run made, watched in the
history until it ends, spoken audio, scans); the new report tool leaves
the AI's notes and pictures it made another way. The page shows the tasks
and one task's results strip and timeline with links (picture viewer,
model page, combo, find or undo on the canvas, audio), updates live, and
gives the rail a dot while you are elsewhere; Home lists the last tasks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:19:49 +08:00
DemonGatanjieuandClaude Opus 5.5 dd2ea0e353 Merge feature/remote-access: the Put on canvas fix now listed under 1.60.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:03:36 +08:00
DemonGatanjieuandClaude Opus 5.5 95147fda5e Merge feature/model-import: the browser no longer opens blank after Put on canvas
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:01:43 +08:00
DemonGatanjieuandClaude Opus 5.5 a679b82af5 Combos: the browser no longer opens blank after Put on canvas
Put on canvas hid the combo page before closing the browser, so the next
open showed the Combos title over nothing. Now only the browser closes and
it opens again on the combo list or the combo put down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:00:22 +08:00
DemonGatanjieuandClaude Opus 5.5 31b3925805 Combos: the browser no longer opens blank after Put on canvas; Voice-over says when ▶ reads a sample
Put on canvas hid the combo page before closing the browser, so the next
open showed the Combos title over nothing; now only the browser closes and
it opens again on the list or the combo. A character with no line in the
script hears a sample sentence on ▶, which the panel now says; on the
whole script ▶ reads the line with its own character.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:53:35 +08:00
DemonGatanjieuandClaude Opus 5.5 596ac9728d Reference audio editor: move a clip to the Recycle Bin from its row
🗑 on a row asks first (saying where the clip is used), then has the node
move it with its own .txt / .lab to the Recycle Bin (never a permanent
delete) and take it out of the settings; the window drops it from its
voices too. Needs Anomalous_TTS interface 18.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:38:03 +08:00
DemonGatanjieuandClaude Opus 5.5 a3ce3cae94 Reference audio editor: every clip listed, main and auxiliary references
"Reference audio and emotions" replaces Edit emotions: a large window with
the voices on top (main voice, emotions, new emotion) and every clip of the
character below, one row each (listen, file and length, its line editable
in place, where else it is used, Main / Aux for the voice picked), with
filters, search and paging. Auxiliary references (timbre averaged in, v1 /
v2 / v2Pro), fixed lines (`lines`) and lengths need Anomalous_TTS
interface 18. tts_voice_refs.js holds the rules; the old clip search and
merge helper go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:52:19 +08:00
DemonGatanjieuandClaude Opus 5.5 0a315bf1f8 Voice-over: a drafts box
Every script is a draft, saved on its own in ComfyUI's user folder
(anomalous_model_browser/voice_over_drafts.json through /userdata), read
again and joined before each write so two tabs keep each other's drafts.
A bar over the script names it and leads to New, Drafts and Close; the box
shows a card per draft (open, rename, copy, delete) and Recently deleted
(30 days). A reload opens the draft that was open; a file voiced again
from the gallery becomes a draft of its own; the one localStorage draft
of the earlier build moves in once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:01:26 +08:00
DemonGatanjieuandClaude Opus 5.5 2a7cfe5075 Voice-over: three layers, whole script / character / line, and a draft
The side panel changes one layer at a time under a path (Whole script ›
character › line); each layer has the same rows (speed, tone, language),
each saying where its value comes from, with ↺ to follow the layer above.
The more particular wins: line, character, whole script, the character's
usual settings. The whole script's values go into the node's inputs and
only lines that differ get tags. Lasting settings are links at the foot.
The script is kept as a draft in this browser and comes back after a
reload; editing the text keeps unchanged lines' own settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 04:21:00 +08:00
DemonGatanjieuandClaude Opus 5.5 db045b8f57 Voice pages: tabs instead of folds
GPT-SoVITS Settings gets a status chip by the title and tabs (tones, other
languages, base files with a dot when they need the user, where things are,
version); the tones have a Simple / Numbers switch. Voice-over's side panel
gets How it reads / Numbers / Settings tabs for a character or picked lines.
The More fold is gone; ui_tab_strip.js replaces ui_tts_fold.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 02:39:53 +08:00
DemonGatanjieuandClaude Opus 5.5 0c1287ddc2 Voice pages: less on screen, plainer words
Voice-over's side panel shows who reads, speed, the language (one line
while detected) and the tone with a listen button; the comparison, the
numbers and the links fold under More. GPT-SoVITS Settings opens with one
status line and the tone to use; the language rules, base files, places
and version are folded and open by themselves when they need the user.
Plainer words throughout: tone, Steady / Natural / Lively, As the
character, Always read like this, Own settings, base files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 02:19:32 +08:00
DemonGatanjieuandClaude Opus 5.5 31ab335a25 Voice-over: with many characters, Read by lists the script's own and a searchable menu for the rest
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:55:37 +08:00
DemonGatanjieuandClaude Opus 5.5 09ae7c6b33 Voice-over: pick on the left, change it on the right; settings per character and per line
The side panel follows what is picked: the whole script, one character in
it (its language, speed and mode for this script), or the picked lines (who
reads them, and their own values, each following the character until set).
A line set for itself carries a tag on its card. The values reach
Anomalous_TTS interface 17 as [lang:…][speed:…][preset:…] on each line;
an older node reads the whole script as before. Clicking a character in the
cast now shows it instead of giving it the picked lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:28:22 +08:00
DemonGatanjieuandClaude Opus 5.5 f3517a986d Tidy: drop CSS no element can match any more
69 rules and 21 selectors in lists named plugin classes no script makes (old paper-note
cards, the old drawer header, the combo editor's old toolbar, icon buttons, tag colours…).
No behaviour or look changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:10:39 +08:00
DemonGatanjieuandClaude Opus 5.5 bd156625fc Tidy: split the two files over 600 lines, drop 221 unused UI texts
ui_tts_import.js gives its frame and toolbar to ui_tts_import_screens.js and its window
drop to ui_tts_file_drop.js; material_inspector.js gives the PNG chunk reader to
png_metadata.js. 221 texts no code uses any more leave locales.js (both languages).
No behaviour changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:05:40 +08:00
DemonGatanjieuandClaude Opus 5.5 806bf07565 Merge branch 'feature/remote-access' into feature/1.61
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:54:13 +08:00
DemonGatanjieuandClaude Opus 5.5 2fa934574e Merge branch 'feature/model-import' into feature/remote-access
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:52:46 +08:00
DemonGatanjieuandClaude Opus 5.5 4f9f1bddba Decisions: AD-020 (downloads and ComfyUI-Manager), AD-021 (a combo never changes a value)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:38:44 +08:00
DemonGatanjieuandClaude Opus 5.5 0d4d174e7f Model Check: Search Hugging Face for a foundation component no source knows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:33:15 +08:00
DemonGatanjieuandClaude Opus 5.5 1da4386d42 Docs: Model Check's rules where the repository keeps guides (docs/guides/model-check-rules.md)
docs/ itself is ignored but for its folders, so the page was never committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:10:10 +08:00
DemonGatanjieuandClaude Opus 5.5 c62fe4fb5e Model Check: ComfyUI-Manager's place in the rules
A download by name says where the list's file is; an entry of no stated type no longer
fits a node of known type; without the Manager, the page says it would be one more
source. docs/model-check-rules.md gets a section on the Manager.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:08:29 +08:00
DemonGatanjieuandClaude Opus 5.5 db3e94a8dc Docs: Model Check's rules in one place (docs/model-check-rules.md)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:40:30 +08:00
DemonGatanjieuandClaude Opus 5.5 7a59068ebb A picture kept as a combo keeps its nodes, every value as it was
Keep -> Its combo now saves the nodes upstream of the picture's samplers' model and prompt
inputs (loaders, LoRA chains, CLIP skip, both prompts) as a node-structure combo, values
untouched; models are named by where they are here, missing ones kept by the picture's name.
A workflow without a sampler still gives the simple combo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:37:46 +08:00
DemonGatanjieuandClaude Opus 5.5 b5f66fae91 Rules: feature principles (Model Check, combos never change a value); Manager's model list in the docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:16:52 +08:00
DemonGatanjieuandClaude Opus 5.5 2f7cda709c Right-click menu settings: placement as two drawn cards, a preview line
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:02:51 +08:00
DemonGatanjieuandClaude Opus 5.5 2836cceb7c A combo's page redone; the gallery's Kept view
The combo editor gets a head with its picture (See the picture), a large name with a
Rename button, chips of what it holds and Put on canvas; models show only what is chosen,
Change / Add open the grid of compatible ones. No Save button: everything saves itself
(a tag changed in the bilingual view now saves too). The gallery's ★ Kept lists only the
pictures kept as a workflow, combo or prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:52:03 +08:00
DemonGatanjieuandClaude Opus 5.5 58b11deae0 Quick search on the canvas (Alt+B)
A bar over the canvas finds combos, models and saved prompts as you type
(GET /anomalous/quick_index lists the models in a few words) and puts the chosen one
where the pointer was. Also in the empty canvas's right-click menu, now registered.
Saved model paths in a linked model folder get their real place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:34:41 +08:00
DemonGatanjieuandClaude Opus 5.5 de94e090bb Canvas right-click menus: AMB's entries, set in Settings -> Canvas
A node's menu offers Open in AMB, change model by picture, insert a LoRA before / after,
saved values, the model's pictures and details, save values and save as combo, each where
it applies; the empty canvas's menu opens AMB's pages. Entries can be switched off, moved,
and put in one AMB submenu or right in the menu. The model picker's Esc closes only the
picker, wherever the focus is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:19:43 +08:00
DemonGatanjieuandClaude Opus 5.5 74beded02c Combos kept from a picture show it and get a telling name
The card shows the output image with the main model's cover as a badge; the name is the
first LoRA (else the main model) and two telling tags; the toast after keeping shows the
picture and renames the combo on the spot, and the editor's title renames on a click and
opens the picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:06:32 +08:00
DemonGatanjieuandClaude Opus 5.5 b899348435 Changelog: the narrow-window reference fix ships in 1.60, said once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:02:27 +08:00
DemonGatanjieu 2206f78f7e Merge branch 'feature/model-import' into feature/1.61 2026-10-19 02:02:17 +08:00
DemonGatanjieuandClaude Opus 5.5 ea6b29d783 Prompt Studio: in a narrow window the reference takes the cards' place
Three columns in a side-panel window left each about 200px, tags one per
line and a sideways scroll. Below 980px the open reference now replaces
the card list (two columns of half the width each), and "‹ Cards" brings
the list back; wide windows keep three columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:02:04 +08:00
DemonGatanjieuandClaude Opus 5.5 6a9f2f7ed4 Tests: side files beside the model whatever this computer chose
With Settings set to the hidden .amb folder on this computer, nine tests
read the real api/config.json and looked for side files in the wrong
place. tests/isolated_layout.py points model_sidecars at no config, so
the tests that write side files always get the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:54:05 +08:00
DemonGatanjieuandClaude Opus 5.5 61c5affc23 Prompt Studio: in a narrow window the reference takes the cards' place
Three columns in a side-panel window left each about 200px, tags one per
line and a sideways scroll. Below 980px the open reference now replaces
the card list (two columns of half the width each), and "‹ Cards" brings
the list back; wide windows keep three columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:54:05 +08:00
DemonGatanjieuandClaude Opus 5.5 48ad658d0c Prompt Studio: cards drawn as notebook pages
The card list read as plain grey rows. Each card is now a page with a
bound spine in its role's colour, its title, word count and role label,
and its first tags on two ruled lines; Common and My prompts are index
tabs with their counts, and the card open as the reference wears a
ribbon (deck.setOpen, fed by the reference column's onShow).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 01:16:27 +08:00
DemonGatanjieuandClaude Opus 5.5 9c814ff0c1 Tidier model folders: side files in a hidden .amb, no duplicate covers
A user found model folders cluttered after scans: four files per model.
model_sidecars.py now says where the plugin's own side files are (.info,
.anomalous.json, .civitai_bak.*): beside the model, or in the folder's
hidden .amb (Settings -> Models -> Files that come with models). Every
reader and writer goes through it and reads both places; covers stay
beside the model for ComfyUI and other plugins.

Scans no longer keep a copy of Civitai's picture identical to the cover:
the cover is noted as Civitai's (SHA-256 in the .info) and a copy is kept
aside only while the user's own cover shows. api/sidecar_layout.py moves
existing files and drops the duplicates to the Recycle Bin. The folder
tree leaves .amb out; the duplicated tree builder and cover owner check
are shared now (model_catalog.py, backup.py back under 600 lines).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:54:28 +08:00
DemonGatanjieu 761575acf6 Merge branch 'feature/model-import' into feature/1.61 2026-10-18 23:31:30 +08:00
DemonGatanjieuandClaude Opus 5.5 653386ad6e Prompt Studio: a clicked card opens as a reference to take single tags
A user asked to pick tags from a saved prompt instead of pouring the
whole card in. A clicked card now opens between the cards and the prompt
being edited (ui_prompt_reference.js): a click on one of its tags adds
that tag, Add all the whole card, ticks show what the box has. The
preview's Add and dragging a card work as before; beside the canvas the
drawer widens by the column while it is open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:31:20 +08:00
DemonGatanjieuandClaude Opus 5.5 fe60ede301 Voice-over cards: pick with a click, edit with a double-click; each line has its character
Every line is read by its own character: pasted and added lines go to
the one picked in the list on the left, the node's character is the
first line's, and the "Who speaks" bar is gone. Cards (ui_script_cards.js)
show the text as text: a click picks, a double-click or the pencil edits
it in place (Enter keeps, Esc puts back), and the name on a card opens a
menu of characters (all picked lines when it is one of them). Language
and speed are no longer offered to keep when several characters read;
the settings link names the listened line's character.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:20:01 +08:00
DemonGatanjieuandClaude Opus 5.5 89fa1a93bf Voice-over: several characters in one script
Pick lines on Voice-over (click; Ctrl / Cmd adds, Shift a run; Esc lets
go) and click a character in the list on the left to have it read them;
with none picked, the click changes who reads by default. On Voice-over
the list is the cast: narrower, characters with their colours, and a
click never leaves the page. Cards show who reads each line in its
colour, with that character's emotions; the bundle carries [name] where
the speaker changes, so generating, the cards' listen button, pushing,
dragging and copying follow it, and a file voiced again from the audio
gallery keeps its speakers. The character cards' Voice-over button is
gone. The workflow actions move to ui_script_workflow.js and the cast
helpers to ui_script_cast.js, keeping the director under 600 lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:53:03 +08:00
DemonGatanjieuandClaude Opus 5.5 998f232307 Voice-over: listen from the ▶ on each line card
Each card has a ▶ (on hover, and on the card you are on) that reads its
line in the mode picked on the side; the side names the line and keeps
the modes heard on it as buttons to compare, without a listen button of
its own. The cards are drawn again once the node's modes are known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:31:12 +08:00
DemonGatanjieuandClaude Opus 5.5 17a2c2f03d Full window for the browser; more room on Voice-over
A full-window button beside Dock (and a double-click on the title bar)
makes the window fill the ComfyUI page, remembered; it is neither dragged
nor resized then, and docking turns it off. Voice-over: one row per line
card, a divider between the lines and the generating side (dragged and
remembered, ui_split_bar.js; about half each by default), "Into your own
workflow" folded, who reads and their voices on one row, the how-to line
only until there are lines. On a wide side the modes' scale and numbers
are shown open unless the fold was closed before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:20:17 +08:00
DemonGatanjieuandClaude Opus 5.5 8e923c5bbe Voice-over: listen to the line you click on the left
The line card you click or edit becomes the one the modes are heard on:
it is marked "heard on the right", and the run section names it beside
Listen ("line 3: ..."), replacing the dropdown. An empty line falls back
to a sample in the character's language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:56:41 +08:00
DemonGatanjieuandClaude Opus 5.5 156cede0c6 Voice-over: a shorter mode area, and listening names the line it reads
On the narrow side the scale joins the numbers in one fold and the long
notes became one sentence with the mode's description, so the side fits
without scrolling at a mid-size window; the side is a little wider. A
dropdown beside Listen shows which line of the script is read (#1 at
first) and picks another; the takes heard follow that line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:52:36 +08:00
DemonGatanjieuandClaude Opus 5.5 7341ab52d1 Make the modes' editable numbers read as values, the box only on hover
A bordered input pulled the eye from "15 -> 10". The number now looks like
plain bold text with a dashed underline; the box shows on hover or while
typing, without spin arrows (they covered the digits). The old value is a
little brighter so the change reads as one line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:38:37 +08:00
DemonGatanjieuandClaude Opus 5.5 5e23c4a52d Measure modes against the default, steady to lively, and listen to them
A mode is shown against one fixed mode (the default preset in Settings,
Auto on Voice-over): a steady-to-lively scale with both marked and a
sentence ("a little livelier than your default"); the numbers move into a
fold. Listening reads one line with one seed through Preview Audio, only
the mode changing; takes are kept, and the modes heard stay as buttons to
compare. Settings picks the character and the line; Voice-over uses the
script's first sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:51:38 +08:00
DemonGatanjieuandClaude Opus 5.5 9f7f58cb67 Generation modes: pick a preset to see its numbers against the last one
GPT-SoVITS Settings and the Script Director share one switch: the modes
as choices, the picked one's numbers drawn like the Current node card
(two bars, before -> now, an arrow), each with its plain and its node
name, editable in place and saved into the preset. Settings gets Make
default / Reset / + New / Rename / Delete around it. The Script Director
replaces its folded fine-tuning numbers (which showed the node's defaults,
not what the presets used) with Auto or one preset for the whole script,
sent as the node's preset input, plus links to the presets and to the
character's generation settings; Save as default keeps the language and
speed and leaves the rest of the character's defaults alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:33:12 +08:00
DemonGatanjieuandClaude Opus 5.5 39ca8ab25c Show every preset's numbers side by side; make, rename and delete presets
The numbers table marks values that differ between presets and changed
built-in values (with the built-in one), like the compare window; built-in
presets reset one by one or together. New presets start as a copy, are
named by the user and can be picked everywhere. A character's generation
settings show each row's numbers in effect next to the general ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:23:12 +08:00
DemonGatanjieuandClaude Opus 5.5 3159249eb2 Generation presets in GPT-SoVITS Settings; a character's generation settings
For Anomalous_TTS interface 16: the default preset (Steady / Standard /
Lively), a preset for each pair of languages the characters can meet and the
presets' numbers, saved at once to the node's presets file; a Generation
settings button on character cards for the character's own preset, speed and
numbers, as a whole and per language. Older nodes get a pointer to the update
line instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:44:12 +08:00
DemonGatanjieuandClaude Opus 5.5 2e62703923 Update Anomalous_TTS from GPT-SoVITS Settings
The version manager takes an optional Checkout, so the companion node pack is
handled like the plugin itself: api/tts_version.py finds it among the
custom_nodes folders by its node class and accepts only a git clone of the
official repository; releases are fetched only on "Check for updates", an
update checks out the newest stable tag and can be undone. A checkout without
the release tags (cloned before it, then pulled) is matched by ls-remote.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:08:10 +08:00
DemonGatanjieu 469e199542 Merge branch 'main' into feature/model-import
# Conflicts:
#	CHANGELOG.md
#	README.md
2026-10-17 12:55:33 +08:00
DemonGatanjieu d857322c96 Merge branch 'feature/model-import' into feature/remote-access 2026-10-17 12:55:33 +08:00
DemonGatanjieuandClaude Opus 5.5 8697663026 v1.59.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:55:01 +08:00
DemonGatanjieuandClaude Opus 5.5 5b3c365026 CHANGELOG: the v3/v4 voice entries ship in 1.59.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:50:50 +08:00
DemonGatanjieu 33177a8994 Merge branch 'feature/model-import' into feature/remote-access 2026-10-17 12:49:55 +08:00
DemonGatanjieu 964f7e0195 Merge branch 'main' into feature/model-import
# Conflicts:
#	CHANGELOG.md
#	web/styles.css
2026-10-17 12:49:48 +08:00
DemonGatanjieuandClaude Opus 5.5 07a989fbe9 Show the GPT-SoVITS model version on character cards; v3/v4 imports (1.59.1)
From 1.60.1 (6996f07): character cards show the version Anomalous_TTS reports
(interface 15); v3/v4 base models and vocoders are listed and reminded of only
when a character or a dropped SoVITS file needs them, with their own labels;
the import tells an unreadable SoVITS file from a version an older node cannot
use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:48:37 +08:00
DemonGatanjieuandClaude Opus 5.5 4997edecb0 CHANGELOG: v3 and v4 voices; v5 left out until tried with a real model
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:40:09 +08:00
DemonGatanjieuandClaude Opus 5.5 6996f07063 Show the GPT-SoVITS model version on character cards; v3/v4/v5 imports
Character cards show the version Anomalous_TTS reports (interface 15). The
v3/v4/v5 base models and vocoders are listed, counted and reminded of only
when a character or a dropped SoVITS file needs them; the import tells an
unreadable SoVITS file from a version an older node cannot use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:29:25 +08:00
DemonGatanjieu cdc8ad2f57 Merge branch 'feature/model-import' into feature/remote-access 2026-10-17 11:34:29 +08:00
DemonGatanjieu 3cbe5046d2 Merge branch 'main' into feature/model-import 2026-10-17 11:34:28 +08:00
DemonGatanjieuandClaude Opus 5.5 f35d68671f README: Chinese pictures from the 1.59 interface
The Chinese part's seven pictures are re-shot in the 1.59 interface on the
isolated demo ComfyUI (demo models only); the combo picture shows picked
nodes saved as a combo and put back, and its text says so. The old
Chinese title picture, no longer used, is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:34:21 +08:00
DemonGatanjieu 08b0c8b613 Merge branch 'feature/model-import' into feature/remote-access 2026-10-17 11:25:59 +08:00
DemonGatanjieu 4dcbd00692 Merge branch 'main' into feature/model-import 2026-10-17 11:25:58 +08:00
DemonGatanjieuandClaude Opus 5.5 169d455e1e README: Model Check finds renamed models by the fingerprints in the workflow
Without them it can only offer a file of the same name; the setting that
records them is named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:25:52 +08:00
DemonGatanjieu c6190d4974 Merge branch 'feature/model-import' into feature/remote-access 2026-10-17 11:20:33 +08:00
DemonGatanjieu 5d1243511f Merge remote-tracking branch 'origin/feature/model-import' into feature/model-import 2026-10-17 11:20:30 +08:00
DemonGatanjieu 765d3aff9e Merge branch 'feature/model-import' into feature/remote-access 2026-10-17 11:20:02 +08:00
DemonGatanjieu d084c2d74f Merge remote-tracking branch 'origin/main' into feature/model-import
# Conflicts:
#	CHANGELOG.md
2026-10-17 11:20:02 +08:00
Claude 0ebd2f0cb1 Merge branch 'feature/next' into feature/model-import 2026-10-17 02:03:48 +00:00
Claude 241f702655 v1.59.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:03:09 +00:00
DemonGatanjieuandClaude Opus 5.5 e05e4e24fa Combo models as cover cards, not rows as wide as the window
A combo saved from picked nodes shows each model as a card like the model
library's: the cover with its slot written on it, the name and node below,
a click on the card (or Change on the cover) picks another model. The
default combo's main models and LoRAs to pick from are cover cards side by
side instead of wide rows; its empty cover is a class, not inline styles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 03:21:44 +08:00
DemonGatanjieu db0067daea Merge branch 'feature/model-import' into feature/remote-access 2026-10-17 03:13:48 +08:00
DemonGatanjieu 1cb8c32312 Merge branch 'feature/next' into feature/model-import 2026-10-17 03:13:47 +08:00
DemonGatanjieuandClaude Opus 5.5 8681be0402 README: the combo picture shows saving picked nodes, then putting them back
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 03:13:47 +08:00
DemonGatanjieuandClaude Opus 5.5 348dd1dbe5 Call this line v1.60.1, not v1.61.0
Better scanning and cloud servers improve 1.60 without being a big release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 03:05:48 +08:00
DemonGatanjieu 9a0f319162 Merge branch 'feature/model-import' into feature/remote-access
# Conflicts:
#	README.md
2026-10-17 02:40:37 +08:00
DemonGatanjieu e38ae06386 Merge branch 'feature/next' into feature/model-import
# Conflicts:
#	README.md
2026-10-17 02:40:29 +08:00
DemonGatanjieuandClaude Opus 5.5 bf115de8b0 README: start from what it helps with; English pictures for the English part
Each language now opens with six everyday problems (unknown model files, a
shared workflow with red nodes, finding how a picture was made, the same
combo again, long prompts, talking characters) plus Activity's Undo, each
with a picture; install is three steps. The page table, tools, MCP, opening,
updating, network and backup text moved unchanged into folded sections.
The English part and the title picture use new English-interface pictures
from the isolated demo ComfyUI (1.59, demo models only); the Chinese part
keeps its pictures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:39:06 +08:00
DemonGatanjieu dd53fe3775 Merge branch 'feature/model-import' into feature/remote-access 2026-10-17 02:14:19 +08:00
DemonGatanjieu a64f28fa7d Merge branch 'feature/next' into feature/model-import
# Conflicts:
#	README.md
2026-10-17 02:14:18 +08:00
DemonGatanjieu f9ed678e4c Merge branch 'main' into feature/next
# Conflicts:
#	README.md
2026-10-17 02:14:08 +08:00
DemonGatanjieuandClaude Opus 5.5 2327a6df6e README: the new Bilibili videos and the Reddit post; no YouTube
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:13:40 +08:00
DemonGatanjieuandClaude Opus 5.5 6cba4c9e14 CHANGELOG: 1.61 without a headline, scanning and cloud servers apart
The owner closes the cloud-server work here (no more server tests) and
keeps adding the next changes to 1.61, so its heading names no theme yet.
The scan page and Civitai items get a Scanning section of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:53:39 +08:00
DemonGatanjieuandClaude Opus 5.5 886c49f104 Cloud guide: not fully tested, and a section for AI assistants
The guide, both README notes and the 1.61 changelog say it was tested on
one AutoDL server only, so users watch out. The guide ends with "For AI
assistants": what view only means, the two ways (SSH tunnel, the switch
file via the chip's Copy), how to check (the yellow chip, the start log
line, /anomalous/access), what to do when it did not work, and what to
tell the user about safety (no proxy or VPN software on the server, no
keys in the chat). A user can hand the link to their AI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:50:25 +08:00
DemonGatanjieuandClaude Opus 5.5 e7cd835635 Cloud servers: say how far support goes
Overseas cloud servers (RunPod...) are fully supported. Servers in
mainland China (AutoDL...) only in part: browsing, offline scans and
tidying work, but Civitai is mostly out of reach there, so lookups are
unreliable and covers rarely download. Said at the top of the cloud
guide, in both README notes and in the 1.61 changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:45:19 +08:00
DemonGatanjieuandClaude Opus 5.5 6374c914b5 Scan page: last counts at once, headers kept on disk, found models listed
With many models, or models on a network drive, every visit to the scan
page read every folder and, after a restart, every new model's header
again (79 s for 1041 models on an AutoDL model disk).

api/model_heads.py keeps what each file's header says by real path, size
and time in user/anomalous/model_heads.json (at most 50,000 files, the
longest unused dropped first, written a few seconds after the last
change, only once setup_routes called keep_on_disk); get_metadata reads
headers through it, so a header is read once per file version.

scan_summary keeps the last summary (memory, and scan_summary.json when
it changed); ?quick=1 hands it out at once and the page shows it with
"showing the last counts while they are updated", then the fresh ones.
Requests meanwhile await one count (_fresh). The models found on Civitai
(components apart, at most 5000 kept) are listed by GET
/anomalous/scan_matched; the page's "Found on Civitai" count opens them,
with Civitai's name, base model, a box to find one by name and Open.

Checked on the isolated ComfyUI reading the real loras and checkpoints
read-only (scans stubbed; no file there changed): the last counts showed
at once also after a restart, the fresh ones replaced them, the found list
had 68 models with names, the box filtered, Open and Back returned to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:43:44 +08:00
DemonGatanjieuandClaude Opus 5.5 142280e4ec Scans ask when Civitai cannot be reached; linked models keep outside info
Before an online scan (the scan page, a card's radar, after a download or
an import), scan_connection.js asks GET /anomalous/civitai_check, which
tries both of Civitai's addresses. When neither answers, a dialog says
why and offers Scan from the files (the offline scan, so nothing waits on
every model), Set a proxy (Settings → Models, Network) or Cancel. A check
that reached Civitai is trusted for five minutes. anomalousConfirm takes
okClass, so this choice is not drawn as a delete.

A model that is a link with no info beside it shows the info beside the
file it links to (metadata._info_base): A1111's Civitai Helper leaves
.civitai.info there. Only read there; covers, edits and scans stay beside
the link.

Checked on the isolated ComfyUI with the check and the scan request
stubbed in the page: the dialog's three choices (from files posts
offline_only, set a proxy opens the Models tab, cancel posts nothing),
an answering Civitai scans online without asking, and a second scan
skips the check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:21:57 +08:00
DemonGatanjieu c157928e53 Merge remote-tracking branch 'origin/feature/model-import' into feature/remote-access 2026-10-16 22:10:06 +08:00
DemonGatanjieu 5605ca010a Merge remote-tracking branch 'origin/feature/next' into feature/model-import 2026-10-16 22:09:56 +08:00
DemonGatanjieuandClaude Opus 5.5 e0ea150abe Chinese UI says 代理软件 instead of the slang 梯子
The proxy help, the SOCKS warning, the refused-connection reason and the
Civitai scan hint used the slang word 梯子. Plain 代理软件 says the same and
is safe to show in videos and screenshots on Chinese platforms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:46:58 +08:00
DemonGatanjieuandClaude Opus 5.5 07686cf026 CHANGELOG: linked models under 1.61, where the fix now lives
1.59 and 1.60 no longer carry it, so its line moves from the 1.59
Fixes into the 1.61 cloud-server section, with Tidy moving the link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:38:19 +08:00
DemonGatanjieuandClaude Opus 5.5 9a6643a03e Merge feature/model-import, keeping linked models on 1.61
feature/model-import's only new commits are the reverts that took the
linked-models fix out of 1.59 and 1.60 (0e81e2f, 80b42fd, 7eadc55). The
owner keeps that work in 1.61, so this merge records them and keeps this
branch's files as they were (2366d18): later merges from 1.59 and 1.60 do
not take the fix out here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:36:39 +08:00
DemonGatanjieu 7eadc553e5 Merge branch 'feature/next' into feature/model-import
# Conflicts:
#	api/model_folder.py
2026-10-16 21:34:44 +08:00
DemonGatanjieuandClaude Opus 5.5 80b42fd834 Revert the merge of "Models that are links: taken as named"
This reverts merge 25f1f6d (feature/next's 851a8cb and this branch's
adaptations of the scan choice, the editor's covers, Tidy's move and
move_into). The owner keeps the cloud-server work in 1.61
(feature/remote-access); 1.60 is back to 4bc559e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:34:05 +08:00
DemonGatanjieuandClaude Opus 5.5 0e81e2f456 Revert "Models that are links: taken as named, covers and info beside the link"
This reverts commit 851a8cb. The owner keeps the cloud-server work in
1.61 (feature/remote-access), so 1.59 ships as it was; the change lives
on there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:32:29 +08:00
DemonGatanjieu 2366d1880f Merge branch 'feature/model-import' into feature/remote-access
# Conflicts:
#	AGENTS.md
2026-10-16 21:20:15 +08:00
DemonGatanjieuandClaude Opus 5.5 25f1f6d7b7 Merge feature/next: models that are links, taken as named
1.60's own places that took a model file by name use the new helpers
too: the scan choice (model_file_within), the editor's covers, Tidy's
move (model_reference_within). Opening a folder keeps 1.60's "no file
name opens the folder". move_into tells a rename from a copy by where the
link is (lstat), so Tidy moving a linked model renames the link instead
of copying the model disk's file onto the server. The open-folder test
stubs through path_utils and also refuses "..".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:18:31 +08:00
DemonGatanjieuandClaude Opus 5.5 851a8cb2d5 Models that are links: taken as named, covers and info beside the link
On an AutoDL server every model in the models folder is a link into the
platform's shared, read-only model disk, named like 6b4679... without an
extension. Scans wrote the .info beside the link, but get_metadata
resolved the path and read beside the disk's file: a scanned model
showed as not scanned, under the disk's file name. Delete, edit, open
folder, MCP and saved-workflow lookups resolved the file too, so they
refused it as outside the models folder (or, for a link inside it, would
have acted on the file behind it).

path_utils.model_file_within joins a resolved folder with a bare name
(require_filename, which now refuses "." and ".." too);
model_reference_within resolves the subfolders of a relative path inside
the root, through links as before, and takes the file as named.
get_metadata reads beside the path it is given (abspath). Edits, renames
and deletes act on the link; nothing writes into a model file.

Checked with a link stood in by patching realpath (this computer cannot
make links): the info beside the link is read and the name is the
link's; "..", ".", "a/b" and a relative path leaving the root are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:12:15 +08:00
DemonGatanjieuandClaude Opus 5.5 6fb69294f2 Civitai: ask civitai.red when civitai.com does not answer
On an AutoDL server civitai.com timed out while civitai.red, Civitai's
other address with the same API, answered in about a second, so scans
there fell back to guessing from the files.

civitai_client.api_hosts gives both addresses, the one that answered
last first; the scan's lookups, import's and Model Check's
(download_sources._civitai_json) and the network test ask the other one
when the first does not answer at all. An HTTP error is Civitai's own
answer and is not asked again. The test answers which host answered,
and Settings says so when it was civitai.red.

The pictures stay on image.civitai.com, which such networks often cannot
reach either: after COVERS_DOWN_AFTER (2) covers in a row that would not
download, a scan fetches no more covers instead of waiting on each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:49:29 +08:00
DemonGatanjieu 0ed6ab2543 Merge branch 'feature/model-import' into feature/remote-access 2026-10-16 19:39:35 +08:00
DemonGatanjieuandClaude Opus 5.5 4bc559eaa4 Scan page: quick with many models or a network drive; says it is counting
On a cloud server with 1041 models not scanned yet, on the platform's
network model disk, GET /anomalous/scan_summary took 79 s, each time the
page opened, and the page stayed blank meanwhile: it read each new
model's header (for "looks like no drawing model") one after another.

The headers are now read 16 at a time (HEAD_READERS) and through
get_metadata, whose cache keeps each answer until the file changes, so
the page opened again reads none of them and the picker after it finds
them there. Opened afresh, a count slower than 400 ms shows "Counting
your models…" instead of a blank page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:39:15 +08:00
DemonGatanjieuandClaude Opus 5.5 df2c5dd6c3 Remote chip: the line to paste into the server's terminal, with Copy
Cloud users had to find the plugin folder and make allow_remote_changes
themselves. GET /anomalous/access now also answers another device with
"command": the line that makes the file (touch, or echo off > on Windows,
which works in cmd and PowerShell), or removes it when it is there, with
this server's real path, quoted. The chip's dialog shows it with a Copy
button; when the browser refuses the clipboard the line is selected for
Ctrl+C. The line only runs where someone pastes it, on the server.

anomalousAlert takes options.copy; copyText moved from ui_share.js to
ui_dialog.js so both use it.

Checked behind the stand-in gateway: without the file the dialog offers
the line that makes it, with the file the line that removes it; both
Windows lines tried in cmd and PowerShell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:29:51 +08:00
DemonGatanjieuandClaude Opus 5.5 4a070729b1 Remote chip: a span with a button's role; the start warning flushes
The header's button rules are !important, so a <button> chip lost its own
colours and size; it is a span again, with role="button", a tab stop and
Enter / Space opening its explanation. The start-up line about
allow_remote_changes is flushed at once, so it shows even when ComfyUI's
output goes to a file.

Checked on an isolated ComfyUI behind a stand-in gateway that adds
X-Forwarded-For (as a cloud platform's proxy does): without the file the
page is view only and changes answer 403; with it the chip reads "remote
changes on" in yellow, a scan choice is saved and reset, and a picked
offline scan runs to the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:07:39 +08:00
DemonGatanjieuandClaude Opus 5.5 5b97d8a5ab Cloud servers: an allow_remote_changes file lets other devices change things
ComfyUI on a rented cloud server (AutoDL, RunPod) is only ever opened
from another device, so since v1.58.1 the plugin was view only there.
An empty allow_remote_changes file in the plugin folder, made on the
server and read once at start, now makes local_guard.may_change true for
every request; the middleware and the routes that check for themselves
(import, placement, scan choice, recipe focus) use it. is_local stays for
import's search of this computer's Downloads and for MCP. The page cannot
make or remove the file, and the file is git-ignored.

GET /anomalous/access answers {local, changes}. The header chip is now a
button: "view only" says what is refused and the two ways for cloud users
(SSH tunnel, the file); with the file it reads "remote changes on", in
yellow, and says that anyone with the address may change things.

docs/guides/cloud-server.md explains both ways and the risk; README,
AGENTS.md rule 4, backend.md and the CHANGELOG (v1.61.0) say so too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:00:30 +08:00
DemonGatanjieu 797a7218d8 Merge branch 'main' into feature/next
# Conflicts:
#	CHANGELOG.md
#	README.md
2026-10-15 13:03:53 +08:00
DemonGatanjieu 5e9ca3e3ac Merge branch 'feature/next' into feature/model-import 2026-10-15 13:03:53 +08:00
DemonGatanjieuandClaude Opus 5.5 17026b3645 v1.58.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:03:07 +08:00
DemonGatanjieu 613034f3ec Merge branch 'feature/next' into feature/model-import
# Conflicts:
#	web/styles.css
2026-10-15 12:55:22 +08:00
DemonGatanjieuandClaude Opus 5.5 b2389de45b Structure check rejects :has() that matches the style attribute
The pattern behind issue #22 came back once already (removed in v1.57.2,
back with the icon rail in v1.58.0). tools/check_structure.mjs now fails
on any :has() in the web stylesheets whose argument matches [style...],
pointing at the line; AGENTS.md, docs/guides/testing.md and lessons.md
say why and what to do instead (a class on an ancestor).

Prepared in a cloud session (checkpoint cbfc782), committed now that the
issue #22 fix is on this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:53:51 +08:00
DemonGatanjieu 785b48bb1b Merge branch 'main' into feature/next
# Conflicts:
#	CHANGELOG.md
#	web/styles.css
2026-10-15 12:53:20 +08:00
DemonGatanjieuandClaude Opus 5.5 7ceb591cbf Fix the list column's :has() rule slowing every style change (#22)
The rule that hides the list column while a workspace is open matched
the workspace panel's style attribute inside :has(). Chrome then treats
every inline-style write on the page as a possible change and restyles
the whole page, so extensions that move their buttons every frame took
a large workflow's canvas down to 2-3 fps. The container now gets an
anomalous-workspace-open class from a MutationObserver on the panel's
style (every module opening a workspace sets its display), and the CSS
keys off that class.

Found, measured and fix proposed by Lindemellis in issue #22.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:52:49 +08:00
DemonGatanjieuandClaude Opus 5.5 396b2a1489 Move the node packs not used lately to Settings → Node packs
Node packs are ComfyUI's extensions, not material, so looking for them
under the models page's Tidy tab felt odd. Tidy keeps the models and a
line leading to the new Settings tab; both pages share the check and
its head (usage_view.js), ui_pack_usage.js draws the packs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:25:04 +08:00
DemonGatanjieuandClaude Opus 5.5 afd45520d4 Tidy: "I still use it" for node packs no record can judge
A pack used only through its panels or buttons leaves no record, so the
user can mark it as still in use (POST /anomalous/usage/keep, kept in
user/anomalous/usage_kept.json, only packs ComfyUI loaded). Marked packs
leave the groups for a list of their own; Unmark undoes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:59:50 +08:00
DemonGatanjieuandClaude Opus 5.5 55e9611b76 Tidy: say that only a pack's panel being used leaves no record
"Never opened" read as "this pack was never opened", while it meant
"in no opened workflow". The page-part group now says using only a
panel or a button leaves no record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:54:52 +08:00
DemonGatanjieuandClaude Opus 5.5 48e273f5c7 Tidy: opened workflows count as use; packs with a page part set apart
A node put on the canvas or a workflow opened and never run read as
"never used". The page now notes what each opened workflow holds
(usage_notes.js, POST /anomalous/usage/opened, kept apart from runs in
the run record) and the open tabs just before a check.

Packs whose nodes went unused but which also add to the page (buttons,
menus, monitors) get their own group: unused nodes do not say the pack
is. The packs count covers only packs that work through nodes alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:49:32 +08:00
DemonGatanjieu 8de2926a53 Merge branch 'feature/next' into feature/model-import 2026-10-14 21:34:34 +08:00
DemonGatanjieuandClaude Opus 5.5 40fc88cb2d Tidy: say what counts as use
"No sign of use" read as "never made an image". The node pack page now
says only what a run uses counts (a node put on the canvas, a panel
opened do not) and that "installed" is the folder's creation date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:34:34 +08:00
DemonGatanjieuandClaude Opus 5.5 f53a3dd743 Remove the Claude and Gemini entry files
AGENTS.md is the one set of rules; CLAUDE.md and GEMINI.md only pointed
to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:34:13 +08:00
DemonGatanjieuandClaude Opus 5.5 bf9c46d728 Tidy: a run record, careful judging and a node pack page
ComfyUI keeps no lasting note of what a run used, so AMB now notes each
queued run's node types and model files (on_prompt hook, user/anomalous/
usage_ledger.json). "Not used lately" reads it with the outputs, now
sounds too, and the saved workflows, and judges nothing that came in the
last 30 days; while the record is younger than the group shown, the page
says so.

Node packs get their own page: installed for how long, whether they add
to the page, which packs take what only they make, and a button opening
ComfyUI Manager's node list searched for the pack. Packs without nodes
are listed apart, as nothing can tell whether they are used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:18:37 +08:00
DemonGatanjieuandClaude Opus 5.5 f70d890e61 Tidy: models and node packs not used lately
A Bilibili user asked for a way to clear out models and node packs they
tried once and no longer use, without breaking other workflows.

api/model_usage.py (GET /anomalous/usage/check, this computer only, run
on a press): when each model and node pack was last used, from the PNG
outputs' prompts (image_search's cached records now keep node types) and
the saved ComfyUI workflows, recipes, combos and parameter sets; models
match by path, file name or scanned SHA-256; reading is capped and
nothing is deleted there.

ui_model_usage.js under Tidy's two steps: not used for 30 / 90 days or
never, biggest first, the last output's date, a warning when a saved
workflow still names a model; picked models go to the Recycle Bin. Node
packs are only listed, with a button that opens the installed ComfyUI
Manager's node list (Manager 4 or 3, probed by its own routes; without
one it says so).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:28:44 +08:00
DemonGatanjieu b6681ed0b9 Merge branch 'feature/next' into feature/model-import
# Conflicts:
#	web/modules/ui_settings_page.js
2026-10-14 20:14:06 +08:00
DemonGatanjieuandClaude Opus 5.5 d69527968a Settings: the downloads card's comment back above its own function
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:13:54 +08:00
DemonGatanjieu a0f7fa3f9c Merge branch 'feature/next' into feature/model-import
# Conflicts:
#	ARCHITECTURE.md
#	api/__init__.py
#	api/network_check.py
#	web/modules/locales.js
#	web/modules/ui_scan_lists.js
#	web/modules/ui_settings_page.js
2026-10-14 20:13:35 +08:00
DemonGatanjieuandClaude Opus 5.5 6f4347d88d Settings -> Network: a proxy for the plugin, and why Civitai cannot be reached
A Bilibili user's scan said Civitai could not be reached while their
browser reached it through a VPN app: the plugin's urllib requests only
use HTTPS_PROXY or the Windows proxy, not a PAC script or a browser-only
proxy.

net_proxy.py: the proxy address saved in api/config.json (NETWORK_PROXY)
that every request of the plugin goes through (Civitai, Hugging Face,
GitHub, translators), handed to the scan process as HTTP(S)_PROXY; empty
keeps the old behaviour. It also names why a request failed (timeout,
refused, dns, ssl, reset, proxy, http:<code>).

Settings -> Network has the address (SOCKS and malformed ones refused
with what to write) and Test connection, which calls 1.60's
/anomalous/civitai_check (brought here unchanged, now through the proxy
and with the failure's name). A scan that loses Civitai records why
(civitai_error) and the scan page and activity log say it with what to
try.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:10:34 +08:00
DemonGatanjieuandClaude Opus 5.5 103ee6a25d First-run setup on Home: check Civitai, optional key, scan everything, then the tour
New users did not know to scan first. Until anything is scanned, Home shows a card with four
steps and "Set up for me": GET /anomalous/civitai_check (api/network_check.py, local requests
only) says whether Civitai answers and whether a set key is refused; when it does not, the card
offers Retry (with a proxy) or an offline scan. The scan never renames files or refetches,
whatever the scan page was set to; the card follows its progress. Never a popup; "Later" hides it
per browser and Home's "New here?" line brings it back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:26:13 +08:00
DemonGatanjieuandClaude Opus 5.5 c59927e701 Read the LoRAs of popular third-party nodes wherever AMB reads a workflow's models
AMB read models only from ordinary drop-downs, so rgthree's Power Lora Loader (rows are
{on, lora, strength} objects) was invisible: Model Check never reported its missing LoRAs.
Now its switched-on rows are Model Check boxes (lora_nodes.js powerLoraRowBoxes: ComfyUI's LoRA
list as choices, a fix changes only the row's LoRA; downloads put files in the same way), saved
fingerprints and a picture's model search read them, the workflow summary lists them, and saved
model references include them and Easy-Use LoRA stack rows (nodeModelValues /
_model_reference_values, mirrored). Only these widely used nodes; ComfyUI's loaders stay the
reference.

recipe_parser.js was over 600 lines: which LoRAs a node loads moves to recipe_loras.js and the
box readers to recipe_widgets.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:57:36 +08:00
DemonGatanjieuandClaude Opus 5.5 06461c4f9a Combos put LoRAs in rgthree's Power Lora Loader or Easy-Use's LoRA stack
Stacking many LoRAs made a long chain of Load LoRA nodes. A default combo now says which LoRA
node it is laid out with (data.loraNode: ComfyUI's own chain, rgthree's Power Lora Loader, or
Easy-Use's LoRA stack + Apply LoraStack), chosen under New or in its editor; the third-party ones
are offered only when installed, and a missing one falls back to ComfyUI's own and says so.

Save as combo lost a Power Lora Loader's rows (object values were not stored): they are kept as
the node's rows, made again before the slots are filled, each row's LoRA a slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:36:08 +08:00
DemonGatanjieuandClaude Opus 5.5 38236d70f6 1.60 takes in param-compare: its What's-new step and the release title
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:29:01 +08:00
DemonGatanjieu 34f57b5c64 Merge branch 'feature/model-import' into feature/param-compare 2026-10-13 21:05:44 +08:00
DemonGatanjieuandClaude Opus 5.5 7d8cf3427f Description code blocks wrap instead of widening the details
A prompt quoted on one line in a Civitai description made the whole details column scroll sideways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:05:43 +08:00
DemonGatanjieu 1031c77569 Merge branch 'feature/model-import' into feature/param-compare
# Conflicts:
#	web/styles.css
2026-10-13 21:03:51 +08:00
DemonGatanjieuandClaude Opus 5.5 6d0f833b07 Details foot: a gap above its rule on long pages too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:02:49 +08:00
DemonGatanjieu 284ddc782a Merge branch 'feature/model-import' into feature/param-compare
# Conflicts:
#	ARCHITECTURE.md
#	web/styles.css
#	web/styles/03-gallery-recipes.css
2026-10-13 20:59:37 +08:00
DemonGatanjieuandClaude Opus 5.5 fba4de0f11 Details: path, Copy and Delete at the foot, no ⋯; the folder drawer no longer squeezes the page
The ⋯ menu opened over the path line and the description, and two of its three entries
(copy path, copy file name) were rarely needed. The path bar sat between the buttons and the
description. Now the file's path, Copy and Delete model are the quiet foot of the details, and
Folder's tooltip shows the path.

In a narrow window the folder drawer went back into the page's layout the moment it closed and
shrank from 270px, squeezing the page as it slid; it now stays positioned over the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:58:07 +08:00
DemonGatanjieu ca5f2fdb8d Merge feature/model-import: Combos page like the others, description headings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ARCHITECTURE.md
2026-10-13 20:34:37 +08:00
DemonGatanjieuandClaude Opus 5.5 7c8206b36a Combos page like the others; descriptions' headings kept to size; CHANGELOG
- The Combos workspace sat in a lighter, inset box with a shadow, a second "Combos" heading
  and a red close: it now fills the page in the page's colour like Workflows; its bar shows
  only in the editor, with "← All combos" (Esc presses it, the rail and Esc close the page).
  The explanation folds behind ? and shows by itself while there are no combos.
- A model's description (Civitai's HTML) keeps h1-h6 near the text size.
- CHANGELOG for these and the folder column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:33:15 +08:00
DemonGatanjieu 37d1feebe3 Merge feature/model-import: folder column follows the type, opens folders
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	web/styles.css
2026-10-13 20:28:15 +08:00
DemonGatanjieuandClaude Opus 5.5 e0abbd277a Folder column: the current type's folders, with 📂 to open one in Explorer
The list column showed every type's tree (raw folder names, [Root] rows with 0, nothing marking
where you are), repeating the type chips above it. Now (ui_folder_list.js) it follows the chips:
the type's heading with "📂 Open", All, its subfolders as a tree whose numbers count their
subfolders (empty ones dimmed, folding per session in owner.collapsedFolders), and "In the top
folder" for models directly in the type's folder; the listed one is marked.

A Bilibili comment asked for quick ways to open model folders: POST /anomalous/open_model_folder
without a file name now opens the folder itself (type root or subfolder; still by type, index
and subfolder, never a path, local only). Tests in tests/test_model_folder.py.

Gone: the every-type tree, Expand / Collapse all, expandedFolders and their locale keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:28:03 +08:00
DemonGatanjieuandClaude Opus 5.5 b08e4b02e1 CHANGELOG: Esc in a model's Outputs window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:51:17 +08:00
DemonGatanjieuandClaude Opus 5.5 8841efb785 Merge feature/model-import: CHANGELOG for the Outputs Esc fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:51:17 +08:00
DemonGatanjieuandClaude Opus 5.5 b2f4855422 Merge feature/next: CHANGELOG for the Outputs Esc fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:51:17 +08:00
DemonGatanjieuandClaude Opus 5.5 acb8214588 Merge feature/model-import: Esc closes a model's Outputs window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:50:59 +08:00
DemonGatanjieuandClaude Opus 5.5 681b8ec7d2 Merge feature/next: Esc closes a model's Outputs window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:50:59 +08:00
DemonGatanjieuandClaude Opus 5.5 4a0e75ae39 Esc closes a model's Outputs window, not the details under it
The Outputs window (showGeneratedGallery) sits inside the browser with only an id, so the Esc
rule did not see it as a layer: Esc pressed the details' back button and left the window open
over the list. Esc now hides it, as its close button does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:50:58 +08:00
DemonGatanjieu 3e4c5910eb Merge feature/model-import: model details, layout B
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ARCHITECTURE.md
#	web/styles.css
#	web/styles/03-gallery-recipes.css
2026-10-13 15:46:07 +08:00
DemonGatanjieuandClaude Opus 5.5 983e9b1166 Model details, layout B: name and actions beside the cover
From a Bilibili comment (buttons small and in five places, the file name twice, a title bar
of little use), layout B chosen by the user from three mockups:
- The title bar goes. The head (new model_detail_head.js) tops the right column: a crumb
  back (Models / LoRA, or where history leads; still .anomalous-detail-back, so Esc presses
  it), the name, the source line with the source links, and one action row: Add node (the
  one accent button), Edit, Folder, Outputs, and ... with Copy path, Copy file name, Delete.
- Delete asks in AMB's dialog instead of window.confirm, and is no longer beside Add node.
- model_location.js: one path line with Copy; Show in folder is exported for Folder.
- action_menu.js: the small menu the gallery card's right-click had, shared with the ...
- Narrow (no cover column), a 64 px cover sits beside the name.
- Gone: the jump-to-bottom button, the full-width Outputs button, the location card's file
  name row, and their locale keys (back, backToPrev, detailJumpToBottom,
  detailPhysicalFilename, detailFullPath).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:45:44 +08:00
DemonGatanjieuandClaude Opus 5.5 c7b5048f60 Merge feature/model-import: credit the PR #21 authors by account
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:28:02 +08:00
DemonGatanjieuandClaude Opus 5.5 1d2f3d034a Merge feature/next: credit the PR #21 authors by account
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:28:02 +08:00
DemonGatanjieuandClaude Opus 5.5 5b8ea4792a CHANGELOG: credit the PR #21 authors by their GitHub accounts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:28:01 +08:00
DemonGatanjieu 986d002385 Merge feature/model-import: no focus ring around the viewer's video
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	web/styles.css
2026-10-13 15:18:45 +08:00
DemonGatanjieuandClaude Opus 5.5 89b849159e Merge feature/next: no focus ring around the viewer's video
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:16:09 +08:00
DemonGatanjieuandClaude Opus 5.5 95eea6c4ce Gallery viewer: no focus ring around a video
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:16:09 +08:00
DemonGatanjieu 770d161bdf Merge feature/model-import: gallery videos play in their card, fill the viewer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	web/modules/ui_gallery_card.js
#	web/styles.css
2026-10-13 15:14:14 +08:00
DemonGatanjieuandClaude Opus 5.5 b82e18e26f Merge feature/next: gallery videos play in their card, fill the viewer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:14:00 +08:00
DemonGatanjieuandClaude Opus 5.5 338bc0a260 Gallery videos play muted in their card; the viewer fits a video to the window
- A video card plays its video muted while the pointer is on it, or while in view when
  Settings -> Video covers says always play (the model covers' rule). The <video> exists only
  while playing, so a page of videos decodes nothing at rest; it lets clicks and drags through.
- The viewer sizes a video to the window (keeping its shape): 480p outputs looked small.
- Double-click in the viewer toggles full screen (checked afterwards so a browser that does it
  natively does not toggle twice).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:14:00 +08:00
DemonGatanjieuandClaude Opus 5.5 4de23834c2 Merge feature/model-import: CHANGELOG wording for gallery videos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:34:56 +08:00
DemonGatanjieuandClaude Opus 5.5 0ce69d4a15 Merge feature/next: CHANGELOG wording for gallery videos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:34:56 +08:00
DemonGatanjieuandClaude Opus 5.5 47ed42104d CHANGELOG: a dragged video becomes a Load Video node; the switch's English labels
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:34:56 +08:00
DemonGatanjieuandClaude Opus 5.5 8e0e28bbe1 Merge feature/model-import: the gallery viewer stops a video at once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:33:18 +08:00
DemonGatanjieuandClaude Opus 5.5 8806c30ef9 Merge feature/next: the gallery viewer stops a video at once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:33:18 +08:00
DemonGatanjieuandClaude Opus 5.5 aa742b7a69 The gallery viewer stops a video at once when it closes
The dialog's close event came half a second late in testing: the sound went on, and a video
opened again in that time was stopped. The x, a click beside it and Esc (cancel) stop it now;
close only catches a close nothing else saw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:33:17 +08:00
DemonGatanjieu 3b157d22e5 Merge feature/model-import: videos in the gallery (PR #21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ARCHITECTURE.md
#	web/modules/ui_gallery.js
#	web/modules/ui_gallery_card.js
#	web/styles.css
2026-10-13 14:30:50 +08:00
DemonGatanjieu 208bcfdfdd Merge feature/next: videos in the gallery (PR #21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ARCHITECTURE.md
#	docs/architecture/frontend.md
#	web/modules/ui_settings_page.js
2026-10-13 14:29:19 +08:00
DemonGatanjieuandClaude Opus 5.5 4f0fd38dd2 Videos in the gallery: All / Pictures / Videos, formats in Settings, fixes
Follow-up to PR #21:
- The header shows All / Pictures / Videos beside the search (only once the output folder has
  a video) instead of twelve chips; the formats move to Settings -> Gallery, stored as hidden
  so a format added later shows.
- GET /anomalous/gallery_images lists pictures only unless media= says otherwise, so the recipe
  cover picker and the MCP image search are unchanged; hide= drops formats (jpg covers .jpeg);
  has_videos tells the gallery whether to offer the switch. .avi is left out (Chrome cannot
  play it), .avif is a picture format in the filter too.
- The viewer stops the video however it closes (Esc included).
- Video cards have no View parameters, star or right-click menu, and the workbench steps
  through pictures only.
- 图库, not 画廊; the 1.59 CHANGELOG entry credits cocorang and wkdtjs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:27:48 +08:00
DemonGatanjieuandClaude Opus 5.5 fb0ae67e52 Merge PR #21: videos in the gallery and a file-format filter
From cocorang and wkdtjs. The PR's own "v1.58.2" CHANGELOG section is left out (1.58.2 shipped
other changes); the 1.59 entry and the follow-up fixes come in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:20:07 +08:00
DemonGatanjieuandClaude Opus 5.5 0c3797d61f Merge feature/model-import: the default shortcut is Shift+B
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:08:07 +08:00
DemonGatanjieuandClaude Opus 5.5 2ad8cb87a2 Merge feature/next: the default shortcut is Shift+B
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:08:06 +08:00
DemonGatanjieu bcf2bb04d1 Merge main: the default shortcut is Shift+B
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	docs/architecture/frontend.md
2026-10-13 14:08:05 +08:00
DemonGatanjieuandClaude Opus 5.5 fcad1bf133 The default shortcut is Shift+B, not B
comfyui-browser toggles its own window on a plain b (hard-coded, no
setting), so with both installed one press opened one window and closed the
other. Shift+B keeps the B and leaves comfyui-browser alone (it checks for a
lowercase b). Tested with both plugins loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:07:53 +08:00
DemonGatanjieuandClaude Opus 5.5 01e6c2b4c9 Esc presses the rebuilt workflow details' back button too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:56:42 +08:00
DemonGatanjieuandClaude Opus 5.5 3406aeaaa6 Merge feature/model-import: Esc goes back from a detail and closes from a list; B opens the browser
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:54:39 +08:00
DemonGatanjieuandClaude Opus 5.5 33915e584a Merge feature/next: Esc goes back from a detail and closes from a list; B opens the browser
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:54:31 +08:00
DemonGatanjieu c4ffaa15f1 Merge main: Esc goes back from a detail and closes from a list; B opens the browser
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	docs/architecture/frontend.md
2026-10-13 13:54:23 +08:00
DemonGatanjieuandClaude Opus 5.5 942733fc22 Esc goes back from a detail and closes from a list; B opens the browser
The user's two wishes for v1.58.2:
- Esc on a page one level down (a model's details, one combo, one recipe)
  presses that page's own back button; on any other page, Workflows and
  Combos included (they are layers, but look like the other rail pages),
  it closes the browser. The model details' back button gets a class on
  the line that makes it, so ui_detail.js does not grow.
- The default shortcut is B, like ComfyUI's single keys for its side panels.
  Ctrl+Shift+M is taken by Chrome and Edge (switch profile) before the page
  sees it. ComfyUI keeps shortcuts the user set; typing in a text box is
  left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:54:03 +08:00
DemonGatanjieuandClaude Opus 5.5 d8e7002a54 Merge feature/model-import: the browser steps aside while a picture is dragged; inserted node follows the pointer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:42:59 +08:00
DemonGatanjieuandClaude Opus 5.5 09814f3171 Merge feature/next: the browser steps aside while a picture is dragged; inserted node follows the pointer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:42:58 +08:00
DemonGatanjieuandClaude Opus 5.5 a2ca3593df Merge main: the browser steps aside while a picture is dragged; inserted node follows the pointer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:42:48 +08:00
DemonGatanjieuandClaude Opus 5.5 0b212cc1b4 Gallery: the browser steps aside while a picture is dragged; inserted node follows the pointer
The user's two wishes for v1.58.2:
- Dragging a gallery picture fades the browser out, as for the other cards
  (anomalous-material-dragging), so the canvas and the node under the
  pointer take the drop; it comes back on dragend.
- Insert as a Load Image node closes the browser and the node follows the
  pointer until a click puts it down, Esc takes it off: a combo's Put on
  canvas (notebook_canvas.js's followPointer, now exported).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:42:47 +08:00
DemonGatanjieuandClaude Opus 5.5 f8d7906e48 Merge feature/model-import: v1.58.2, Insert as a Load Image node copies into input
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:34:47 +08:00
DemonGatanjieu 59391dc55c Merge feature/next: v1.58.2, Insert as a Load Image node copies into input
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	web/modules/locales.js
2026-10-13 13:34:46 +08:00
DemonGatanjieu 7f6812ee23 Merge main: v1.58.2, Insert as a Load Image node copies into input
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG.md
#	README.md
2026-10-13 13:34:26 +08:00
DemonGatanjieuandClaude Opus 5.5 f902f60b4d Insert as a Load Image node copies the picture into the input folder
A "tut01/name.png [output]" value ran, but ComfyUI's check before a run
only finds output pictures in this session's history, so an older picture
was flagged "a required media input has no file selected" (seen on the
user's library). The picture is now uploaded through /upload/image like a
file dropped on the node; ComfyUI keeps one copy of the same picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:34:04 +08:00
DemonGatanjieuandClaude Opus 5.5 fe27ee3ba7 v1.58.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:26:51 +08:00
DemonGatanjieuandClaude Opus 5.5 a1a2690dd4 Merge feature/model-import: Show in folder brings Explorer to the front
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:25:37 +08:00
DemonGatanjieuandClaude Opus 5.5 5f1703ec59 Merge feature/next: Show in folder brings Explorer to the front
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:25:36 +08:00
DemonGatanjieuandClaude Opus 5.5 cb63cb9828 Merge main: Show in folder brings Explorer to the front
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:25:34 +08:00
DemonGatanjieuandClaude Opus 5.5 4e21b21a72 Show in folder brings Explorer to the front
Windows keeps a background program (ComfyUI) from raising a window, so the
folder opened behind the browser and was easy to miss. After starting
Explorer, the route waits up to 3 s for its window (a new one, or the open
one Explorer reused, matched by the folder's name in the title) and raises
it, borrowing the foreground window's input queue for the moment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:25:33 +08:00
wkdtjs 8e4d184078 Update CHANGELOG.md 2026-10-13 13:25:53 +09:00
wkdtjs 72afbfa23a Update print statement to say 'Goodbye World' 2026-10-13 13:25:11 +09:00
cocorang 8a7a07d064 Gallery extension selection feature 2026-10-13 13:20:37 +09:00
DemonGatanjieu 3585088e42 Merge feature/model-import: Esc closes, Show in folder, insert a picture as a node
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ARCHITECTURE.md
#	web/modules/ui_gallery_card.js
#	web/styles.css
2026-10-13 01:14:05 +08:00
DemonGatanjieu 244af6a1e0 Merge feature/next: Esc closes, Show in folder, insert a picture as a node
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ARCHITECTURE.md
#	api/__init__.py
#	web/modules/locales.js
#	web/modules/ui_detail.js
2026-10-13 01:12:43 +08:00
DemonGatanjieu 1af47f255c Merge main: Esc closes, Show in folder, insert a picture as a node
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG.md
#	api/__init__.py
#	web/modules/browser_entry.js
#	web/styles.css
2026-10-13 01:11:13 +08:00
DemonGatanjieuandClaude Opus 5.5 e0129eaa2f Esc and the shortcut close the browser; Show in folder; insert a picture as a node
From a Bilibili comment (2026-10-13):
- Esc closes the top layer: a dialog or menu (the plugin's alert and confirm
  now close on Esc, as OK / Cancel), else the Workflows / Combos workspace,
  else the browser. One capture listener decides before the layers act, so a
  dialog closing on the same Esc still counts as above. The shortcut and the
  Extensions entry toggle the browser.
- A model's file card has Show in folder: POST /anomalous/open_model_folder
  (api/model_folder.py) takes type, folder index, subfolder and file name,
  never a path, and opens Explorer / Finder with the file selected; a POST,
  so local_guard keeps it to this computer. The card moves out of
  ui_detail.js into model_location.js.
- A gallery card's right-click menu (gallery_card_menu.js): Insert as a Load
  Image node reads the output file in place ("name.png [output]"), and View
  Parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:09:29 +08:00
DemonGatanjieu aa21a866fb Merge feature/model-import: English UI fixes (picture details, one / many)
param-compare's own English counts get one / many too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	web/modules/locales.js
2026-10-12 19:08:25 +08:00
DemonGatanjieu 064847e031 Merge feature/next: English UI fixes (picture details, one / many)
model-import's own English counts get one / many too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	web/modules/locales.js
2026-10-12 19:06:19 +08:00
DemonGatanjieu 4e0e49241f Merge main: English UI fixes (picture details, one / many)
next's own English counts get one / many too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	web/modules/locales.js
2026-10-12 19:03:42 +08:00
DemonGatanjieuandClaude Opus 5.5 a23c3ec69d English UI: no Chinese in picture details, one / many in counts
Found while recording in English: picture details showed "20 步" and
"定位模型", and the activity log said "Changed 1 things". Picture
details' last fixed Chinese strings (steps, Show model, copied JSON, two
empty notes) move to locales. formatTranslation learns {count|one|other},
and every English "{count} <plural noun>" uses it (49 strings; names like {engine} or {node} are left alone); two
sentences whose verb followed the count are reworded. The voice guides
name the TTS node by its English title, Character Speech (GPT-SoVITS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:01:12 +08:00
DemonGatanjieuandClaude Opus 5.5 82ef6c578b Merge feature/model-import: README network note
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:07:04 +08:00
DemonGatanjieuandClaude Opus 5.5 6e2bcc9a14 README: the new scan page's Offline mode, and online falls back by itself
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:06:54 +08:00
DemonGatanjieuandClaude Opus 5.5 627aaca01d Merge feature/next: README network note
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:06:53 +08:00
DemonGatanjieuandClaude Opus 5.5 8b8e9c855e README: downloads need the network too; Hugging Face has a China mirror
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:06:38 +08:00
DemonGatanjieuandClaude Opus 5.5 bb49b79dd9 Merge main: README network note
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:06:37 +08:00
DemonGatanjieuandClaude Opus 5.5 d2b6c40921 Merge the README network note into main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:06:30 +08:00
DemonGatanjieuandClaude Opus 5.5 594cb70dee README: a word on the network (Civitai, Google Translate, offline scan)
Users in mainland China often have no proxy: say which parts need the
network and that an offline scan works without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:06:29 +08:00
DemonGatanjieuandClaude Opus 5.5 68448642d4 Merge feature/model-import: arrows in picture details after ticking a checkbox
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:57:06 +08:00
DemonGatanjieuandClaude Opus 5.5 f77793cbeb Merge feature/next: arrows in picture details after ticking a checkbox
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:56:58 +08:00
DemonGatanjieu aee8fb32da Merge main: arrows in picture details after ticking a checkbox
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG.md
2026-10-12 11:56:57 +08:00
DemonGatanjieuandClaude Opus 5.5 3f5774a724 Picture details: arrows switch pictures again after ticking a checkbox
The key handler ignored every <input> as a text field, so once a node's
checkbox under All parameters was ticked it kept the focus and the arrows
and A / D did nothing until a click elsewhere. Only text entry (text inputs,
textareas, contenteditable) keeps the keys now. In since 2026-09-04.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:56:44 +08:00
DemonGatanjieu 3cfb0cd2d9 Merge feature/model-import: a node combo's back button gets its own click
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	web/styles.css
2026-10-12 02:18:44 +08:00
DemonGatanjieuandClaude Opus 5.5 0278f6a68f Merge feature/next: a node combo's back button gets its own click
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:18:20 +08:00
DemonGatanjieuandClaude Opus 5.5 cd7e496d29 docs: lessons.md says the local check landed in v1.58.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:18:11 +08:00
DemonGatanjieuandClaude Opus 5.5 1dc64da9f8 A node combo's "‹ All combos" gets its own click on a scrolled page
On a combo saved from canvas nodes, once the page was scrolled a little,
"‹ All combos" opened the model picker: Chrome hit-tested the model covers
(overflow: hidden) that had scrolled under the header as still there. The
combo editor's scroll box gets clip-path: inset(0), like the recipe details
on 2026-10-09. lessons.md records it as the second time, with the sweep that
finds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:18:01 +08:00
DemonGatanjieuandClaude Opus 5.5 6010713493 Merge feature/model-import: README says Anomalous_TTS installs from ComfyUI Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:52:39 +08:00
DemonGatanjieuandClaude Opus 5.5 4078fa5b69 Merge feature/next: README says Anomalous_TTS installs from ComfyUI Manager
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:52:30 +08:00
DemonGatanjieuandClaude Opus 5.5 0b54251808 README: Anomalous_TTS installs from ComfyUI Manager too
It has been listed there since 2026-10-11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:52:11 +08:00
DemonGatanjieuandClaude Opus 5.5 826928bb42 Focus values take the local check from local_guard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:43:10 +08:00
DemonGatanjieu c1b7408456 Merge branch 'feature/model-import' into feature/param-compare 2026-10-12 01:42:11 +08:00
DemonGatanjieuandClaude Opus 5.5 a3a580b449 Docs: the import route names local_guard.is_local
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:42:05 +08:00
DemonGatanjieuandClaude Opus 5.5 4a0ea14b40 Merge feature/next: only this computer can change things (v1.58.1)
Import, Tidy and scan choices take the local check from local_guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:41:56 +08:00
DemonGatanjieuandClaude Opus 5.5 a44c93bf44 Merge main: only this computer can change things (v1.58.1)
MCP now uses local_guard.is_local (one local check). A backup upload
request that is no upload answers 400 instead of 500. tests/test_local_guard.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:40:28 +08:00
DemonGatanjieuandClaude Opus 5.5 f326018d8d Current node says which set was last put into the node
A line on top of the parameters: the set last put into this node and when,
what changed since (with Put them back), and a "last put in" mark on its
card and column. Remembered in this browser per workflow and node (the
newest 100), forgotten when the receipt undoes it, and dropped from view
once none of the values it changed is left. Without a record, the line says
which saved set the values match or come closest to (at most 3 apart).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:04:35 +08:00
DemonGatanjieuandClaude Opus 5.5 2777d86831 Current node: where saved values came from, their picture, side by side
Each saved set for the selected node is a card with the picture made with
its values (or the kept picture it came from) and a line per source: a
workflow's parameter set (with how many pictures it made, Open shows that
set), a kept picture (See it), or saved here, with the day. Changes are
drawn: numbers as two bars with an arrow, choices as old -> new; what stays
says why; Show all lists every value. Compare opens the whole workflow
against the canvas. With two sets or more, a side-by-side table (picture on
top of each column, changed values marked, Use N under each).

Backend: materials/by_node_type tells each record's origin, kept picture and
preview; parameters/by_node_type gives each set's signature; new GET
/anomalous/parameter_pictures counts the pictures of many sets in one pass,
reading each picture's signature once per file version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:41:23 +08:00
DemonGatanjieu 20ac73a46f Merge branch 'feature/model-import' into feature/param-compare 2026-10-11 16:26:20 +08:00
DemonGatanjieuandClaude Opus 5.5 4f46676452 Model editor redone, with a source link; inferred information says so
The editor is rebuilt (it was inline styles everywhere): the cover large
with its choices under it (the current one, Civitai's own when the user
replaced it, the newest pictures, upload, the whole gallery), Name, Source
link and Notes with a word each, Reset / Cancel / Save at the foot. Nothing
is written before Save; fields left as they were are not sent. The source
link (asked for in the "not found" list's hint, but nowhere to type it) is
kept in the user layer; update_metadata refuses one that is no http(s)
address. GET /anomalous/model_covers tells the editor Civitai's kept cover.

A model Civitai does not know: its card reads "推断 <base>", its details
open with a note on what was read from the file and a button to add a name
and link (instead of the English placeholder description), and the scan
page says an online scan infers what Civitai does not find.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:26:14 +08:00
DemonGatanjieu 1ab5d75ebe Merge branch 'feature/model-import' into feature/param-compare 2026-10-11 15:53:39 +08:00
DemonGatanjieuandClaude Opus 5.5 58d5432014 Scan page: covers instead of the ring
The scan card opens on two rows of the models' covers, newest first, with an
empty dashed frame for each model not scanned yet (they pulse while a scan
runs): what a scan does, in a picture. The ring, which said the same as the
line under it and the state bar, is gone; narrow, nothing sits alone above
the card any more. The scan summary carries up to 48 covers of scanned models
(components left out) for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:53:33 +08:00
DemonGatanjieu 57bb52a092 Merge branch 'feature/model-import' into feature/param-compare 2026-10-11 15:33:03 +08:00
DemonGatanjieuandClaude Opus 5.5 949645bc9d Scan page: a scan icon, quiet links for what is left out, narrow by width
The scan button gets a scan-frame icon (the radar read as a clock). The
models set to never be scanned and the files in other formats are quiet
links under the state chips, so "worth a look" only appears for damaged
files and voice-like models. The scan card stacks the ring above the
rest by the page's own width (docked in the sidebar too), and the More
arrow turns when unfolded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:32:53 +08:00
DemonGatanjieu 5de3691b81 Merge branch 'feature/model-import' into feature/param-compare 2026-10-11 15:26:38 +08:00
DemonGatanjieuandClaude Opus 5.5 97ae88bc0b Scan page: one card on top, quieter below, settings as tiles
The scan card answers "what now": a ring of the models found on Civitai,
what is left in one line, the scan button, "pick models to scan" and an
online / offline switch beside them, and what the scan does in one
sentence (Details unfolds the counts); a yellow line when a switch that
changes files is on. Below it the models' state as a bar with a chip per
state and the components' chip, then, only when there is something, what
needs a look (one row each, with its icon), the settings as tiles (the
two that change files folded under More) and the last scan in one line.
Icons are scan_icons.js. The cards, chips and rules the page no longer
draws are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:26:38 +08:00
DemonGatanjieu 326a96c4d3 Merge branch 'feature/model-import' into feature/param-compare
# Conflicts:
#	web/styles.css
2026-10-11 13:27:46 +08:00
DemonGatanjieuandClaude Opus 5.5 22edc20d0f Scan page: pick beside the button, settings in place, components apart
"Pick models to scan" sits beside the scan button; the picks are named
under it and the button scans them, "clear" goes back to everything, so
the all/picked switch is gone. Under the button the page says what this
scan will do, each line with how many of its models are components.

The settings card (ui_scan_settings.js, holding the scan options and the
Civitai key prompt) unfolds in place instead of opening its own page.

The summary counts and lists models in component folders (VAE, text
encoders, CLIP, CLIP Vision, upscalers) apart. The counts at the top and
the not found / not scanned lists leave them out; a card and a list of
their own say that many are not on Civitai and that this is no fault.
The not found list looks up again just the models it shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:27:24 +08:00
DemonGatanjieu 6ca0ed1a0a Merge branch 'feature/model-import' into feature/param-compare
# Conflicts:
#	web/styles.css
2026-10-11 13:01:08 +08:00
DemonGatanjieuandClaude Opus 5.5 899a1d9ac1 Model picker: folders in order, aligned columns, offline wording
The picker's models folders are sorted by type, each named with where it
is when the folder is not named after its type (Diffusion · unet) or the
type has two; models follow their folder. A row's state, base model and
size sit in fixed columns. Offline, the settings page no longer counts
models last scanned without a connection as "not on Civitai": they wait
for the next online scan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:00:44 +08:00
DemonGatanjieu 75780601be Merge branch 'feature/model-import' into feature/param-compare
# Conflicts:
#	web/styles.css
2026-10-11 12:50:35 +08:00
DemonGatanjieuandClaude Opus 5.5 2af526c6b9 Scan settings say what a scan will do; a new model picker
"All models" now lists, from the scan summary, how many models a scan
looks up, keeps, leaves for "look up again" and leaves out, following
the offline and fetch-again switches. "Only ones I pick" names the picks
with their size and says when found ones would only be read again.

The picker (ui_scan_picker.js, GET /anomalous/scan_pickable) lists the
.safetensors files a scan reads with the scan page's states, folder,
base model and size, as a list or covers, narrowed by folder, state,
search and picked-only; "pick these" picks what is shown, shift-click a
run. Picks go to the scan as targets. The old inline-styled selector and
its batch_select route are gone; setWidgetValuePath keeps its own file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:50:20 +08:00
DemonGatanjieuandClaude Opus 5.5 20416bc409 Merge feature/model-import: question card layout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:40:48 +08:00
DemonGatanjieuandClaude Opus 5.5 5e34186445 The scan page's question about voice-like models sits in one row
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:40:48 +08:00
DemonGatanjieuandClaude Opus 5.5 d0880b9fc2 Merge feature/model-import: models you never want scanned
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:39:34 +08:00
DemonGatanjieuandClaude Opus 5.5 908ad72ec2 Models you never want scanned: asked, marked, left out; not on Civitai no longer warns
- The scan summary marks new models whose safetensors header reads but names no drawing model
  kind (a voice model such as F5-TTS) as not_drawing; the scan page asks: don't scan them,
  scan them, or see each. The choice lives in <model>.anomalous.json ("scan": "skip" / "scan",
  model_identity.scan_choice), saved by POST /anomalous/scan_choice (api/scan_choice.py, this
  computer only, at most 2000 models, only readable .safetensors layers).
- "skip": a scan of a whole folder leaves the model out (picked by hand it is still scanned),
  the summary counts it apart (excluded) with its own list to change it back, the card and the
  details say "不扫描"; the details have the same button.
- The unmatched count warns only for damaged files or lookups still to do; not-found rows say
  the fingerprint is kept for repairing workflows.
- model_kind.inspect_file now gives format None when the header cannot be read, as its text said.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:38:29 +08:00
DemonGatanjieuandClaude Opus 5.5 1f14953b4d Merge feature/model-import: damaged rows show their advice whole
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:41:11 +08:00
DemonGatanjieuandClaude Opus 5.5 3706d9db83 Merge feature/next: damaged rows show their advice whole
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:41:11 +08:00
DemonGatanjieuandClaude Opus 5.5 0ad2bbd0cf Damaged model rows show their advice whole
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:41:10 +08:00
DemonGatanjieuandClaude Opus 5.5 2b62f68eec Merge feature/model-import: header check fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:39:01 +08:00
DemonGatanjieuandClaude Opus 5.5 5607668ce4 Header check uses model_kind's header limit (and imports struct) after the merge
On this line the header reading moved to model_kind.py, which took model_identity's struct import with it; the damaged-header check from feature/next needed both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:39:01 +08:00
DemonGatanjieuandClaude Opus 5.5 4c83b14657 Merge feature/model-import: damaged model files are named as such
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:36:18 +08:00
DemonGatanjieuandClaude Opus 5.5 45353ffea1 Scans name damaged model files instead of calling them unknown
A model Civitai has no record of now has its safetensors header checked (model_identity.
safetensors_damage: too short, header longer than the file, not JSON, or a tensor ending past
the file's end; only the header is read). Such files get the unmatched reason "damaged": a red
badge on the card, a line in the details, a warning on the scan page (the summary counts them)
and first place in the unmatched list, with advice to download the model again.

Tests: header cases, a scan telling a damaged file from a whole one; the scan report fixtures
are whole safetensors files now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:35:23 +08:00
DemonGatanjieuandClaude Opus 5.5 ca02bdb385 Merge feature/next: damaged model files are named as such
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:35:23 +08:00
DemonGatanjieuandClaude Opus 5.5 9fdbaa847b Focus stars answer at once and save light
A star turns at once with a little bounce, the value fades in (one unmarked fades out), a failed save turns it back; quick presses queue instead of being refused. Focus values save alone through POST /anomalous/recipe_focus (this computer only, size-capped read, only focusParams written, no version), instead of a full recipe update that kept a version and re-read every model each time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:11:31 +08:00
DemonGatanjieuandClaude Opus 5.5 227063cce3 Merge feature/model-import: offline-found models keep their name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:05:19 +08:00
DemonGatanjieuandClaude Opus 5.5 b60756c06c Test: models found offline keep their name across scans
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:04:23 +08:00
DemonGatanjieuandClaude Opus 5.5 ff82d489a5 Merge feature/next: offline-found models keep their name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:04:23 +08:00
DemonGatanjieuandClaude Opus 5.5 5b19d85a5a Merge main: offline-found models keep their name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:03:47 +08:00
DemonGatanjieuandClaude Opus 5.5 b323d82220 Focus values: mark what matters most in a workflow
- Every node value under the details' "every node's values" has a star; marked values show at
  the top of the workflow's own values and of each set as tiles (node, value, a note, and on a
  set the default when it differs). Set rows list them first; the comparison window leads its
  key values with them and their note.
- Kept in the recipe's params as focusParams [{node, type, widget, note}]; the server refuses
  any other shape (at most 12, short strings, notes up to 120 characters). Kept while the node
  of that id and type is there, also when the workflow is updated from the canvas.
- compareParams' isKey now also receives the node ids, so a mark holds for one node only.
- Tests: tests/recipe_focus.mjs, focus values in test_recipe_roundtrip.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:03:40 +08:00
DemonGatanjieuandClaude Opus 5.5 1f25de145e Merge feature/model-import: the Models page no longer comes back empty
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:36:32 +08:00
DemonGatanjieuandClaude Opus 5.5 2360e09256 Merge feature/next: the Models page no longer comes back empty
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:35:36 +08:00
DemonGatanjieuandClaude Opus 5.5 c51d25696b Models page no longer empty after the browser sat closed
After 90 seconds closed the model grid is released. Reopened onto Current node or Prompts (a node selected on the canvas), nothing redrew it, so pressing Models showed an empty page. Showing the models now redraws an empty grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:35:36 +08:00
DemonGatanjieuandClaude Opus 5.5 16ebb90cdb Merge feature/model-import: the blank Workflows page fix, now listed under v1.58.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:55:15 +08:00
DemonGatanjieuandClaude Opus 5.5 392406de1d Merge feature/next: the Workflows page no longer goes blank after opening a workflow
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:53:21 +08:00
DemonGatanjieuandClaude Opus 5.5 0f0c98270f Merge main: the Workflows page no longer goes blank after opening a workflow
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:53:20 +08:00
DemonGatanjieuandClaude Opus 5.5 2ef6555fe8 Workflows: no blank page after opening, safer card buttons, tips, narrow details, new save dialog
- Opening a workflow on the canvas only hides the browser; the Workflows list stays, so opening
  the browser again no longer shows an empty page (the old close hid the workspace without
  restoring the page under it).
- Card Open and more are two small round buttons in the cover's corner, so a press meant for the
  details does not open the canvas.
- Workflow details: the models' fold says whether all are here; folds open as the viewer last
  left them; a tip when a workflow has no set (with Save the canvas) and one pointing at compare
  on a set until used. Narrow (docked) details turn the left column into a row of pills.
- Save dialog rebuilt in ui_recipe_save_dialog.js: cover on the left from the canvas, the current
  cover or recent pictures as thumbnails; fields on the right; Cancel and Save always in view; the
  name starts as the canvas's saved workflow name; Esc and Enter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:48:50 +08:00
DemonGatanjieuandClaude Opus 5.5 2901a6664c Workflow details built around the values; compare pictures inside a workflow
The details open on the workflow's values instead of an overview. A bar (Back, the name
edited in place, the models' state, Open, ⋯ edit and export); a left column
(ui_recipe_sets.js): the workflow's own values, the saved sets unlike them (each with its
key values and difference count), Save the canvas, New, then all pictures and versions;
the chosen view on the right (ui_recipe_set_view.js): Apply to canvas, ⇄ with the default
values or the canvas, ⋯ copy / rename / delete, the main values, prompts (role menu,
folded when long), the pictures made with these values, and folded models and nodes.
ui_recipe_overview.js gives way to ui_recipe_info.js (name, notes, tags, cover, models'
state); ui_recipe_parameters.js keeps prompt roles, node values and the set editor, and
loses its 600-line tab renderer.

The pictures (ui_recipe_gallery.js) load as thumbnails and each has ⇄: the pick bar from
ui_param_compare.js now goes wherever the pictures are and can offer the workflow's own
values as a side. Also: applyRecipeToCanvas used anomalousAlert without importing it.

The old tab, hero, bento, console and gallery-dialog rules are pruned (about 1,000 lines).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:23:16 +08:00
DemonGatanjieuandClaude Opus 5.5 c11e8eb334 Workflow details to match the new Workflows page
The details' bar (Back and the tabs) loses its inline styles and heavy band. The overview
is rebuilt: cover, name, the models' state as one line (no banner), Open and ⋯ through
ui_popup_menu.js (the old More dropdown added a document click listener on every open
and never removed it), the numbers as small tiles, notes and tags, prompts with their
role and Show all for long ones (styles/29-workflow-detail.css).

Parameter sets: buttons on hover, names on two lines, the sidebar's hint and the console's
explanation removed; a new set is named after the recipe with the next number
(nextSetName) instead of a long suffix; raw values saved without names show their node
type's widget labels (comfyWidgetLabels) instead of 控件 N.

The rules for the old hero, banner, More dropdown, metric tiles and prompt boxes are
pruned from the older stylesheets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:05:03 +08:00
DemonGatanjieuandClaude Opus 5.5 617a7a3bc4 Workflows page: one bar, quieter cards
The page's three rows (kind pills, search, tag select, count and close; view, share and
save; the drag tip strip) become one bar: search, a Filter menu (kind and tag), the count,
grid or list, share and import, save the canvas. Cards keep the picture, the name, a line
of steps, sampler, LoRAs and size, and the model; the models' state is a dot (words only
when some are missing), 功能模块 marks a partial workflow, and Open and ⋯ (edit, export,
delete) show on hover. ui_popup_menu.js is the small menu both use.

The page's styles move to styles/28-workflows.css; the card and bar rules spread over four
stylesheets and the ~240 lines of CSS injected from ui_recipe_cards.js are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:50:53 +08:00
DemonGatanjieuandClaude Opus 5.5 a0aee2ec38 Recipe card: the parameter set count no longer overlaps the specs
The card's height is fixed, so the chip's own line pushed into the specs line. It is now
a compact ▤ N first in that line (the full wording in its tooltip).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:38:32 +08:00
DemonGatanjieuandClaude Opus 5.5 53c9b3f777 Compare the canvas with the newest picture
The Gallery and Activity pages get "Canvas vs. newest picture": the comparison window on
the newest output PNG and the canvas, so hand edits since the last run show too (the
activity log only knows Anomalous's own canvas writes) and the picture's values can go
back onto the canvas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:59:23 +08:00
DemonGatanjieuandClaude Opus 5.5 5c0448f009 Key parameters and parameter sets in view
Star a value in the comparison (key_params.js: node type + widget name, a hidden ComfyUI
setting) and it stays at the top of every comparison; equal values can be listed to be
starred. Recipe cards show 参数方案 N (sets unlike the recipe, from the new read-only
GET /anomalous/parameters/signatures) and open the Parameters tab; each set there shows
its key values, how many values differ from the recipe, and ⇄ to compare
(ui_param_set_keys.js). The window also compares saved workflows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:56:14 +08:00
DemonGatanjieuandClaude Opus 5.5 ffe0cb4f40 Compare: say which side is which
A and B each get their own colour, shown on the side cards, the column heads and every
text line. Prompts list the tags only A has and only B has (each once) instead of
removed/added; long texts take one line per side with the differing part marked; values
wrap whole instead of ending in an ellipsis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:37:01 +08:00
DemonGatanjieuandClaude Opus 5.5 b920a711d1 Compare parameters: two pictures, or a picture and the canvas
The gallery card's ⇄ picks a picture, a second picture's ⇄ or Compare with the canvas
completes the pair; the picture details' ⇄ compares with the canvas. The window says
whether both use the same nodes and lists only the values that differ (prompts as tags
removed and added with their role, seeds folded), pairing nodes by id, then by a type
each side has once. Ticked picture values go onto the canvas as one Ctrl+Z step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:26:09 +08:00
DemonGatanjieuandClaude Opus 5.5 cf117381a4 Merge feature/next: keeping a picture no longer changes the canvas
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:20:43 +08:00
DemonGatanjieuandClaude Opus 5.5 a60c33fbf4 Merge main: keeping a picture no longer changes the canvas
Adds tests/detached_graph.mjs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:20:39 +08:00
DemonGatanjieuandClaude Opus 5.5 7538afce2e Merge feature/next: v1.58.0 fixes and docs cleanup from main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:05:22 +08:00
DemonGatanjieuandClaude Opus 5.5 7be055af9f Merge main: v1.58.0 fixes and docs cleanup
The picker, combo-save and activity fixes now ship in v1.58.0, so their entries
move from v1.59.0's Fixes to v1.58.0's (the code here already had them; the
picker keeps this branch's theme-aware border colour). Outdated docs/plans and
docs/audits are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:04:00 +08:00
DemonGatanjieuandClaude Opus 5.5 81fcc6ebc4 Merge feature/next: picker scroll, combo save burst, activity long-text fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:36:15 +08:00
DemonGatanjieuandClaude Opus 5.5 99576e60bd Fix picker jumping to the top, combo saved per tag, unreadable long-text activity
- Current node's model picker redrew the whole grid to mark the chosen card, so
  choosing one further down scrolled back to the top. The cards are repainted in
  place (paintCard) instead.
- The classic combo editor saved when its bilingual tags were shown and again for
  each tag's translation as it arrived: a long prompt wrote the combo dozens of
  times in a second (34 note_save entries in one user's log). Rendering no longer
  saves and the translations share one save half a second after the last. Its
  prompt placeholder moves into locales.js (no inline language branch).
- Activity values are capped at 400 characters from the start, so an edit late in
  a long prompt logged identical before and after. A long before/after pair is now
  cut around its first difference (test included).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:36:15 +08:00
DemonGatanjieuandClaude Opus 5.5 cfcb6b3183 Merge feature/next: Star on GitHub entry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:48:00 +08:00
DemonGatanjieuandClaude Opus 5.5 a7c39df7a5 Add a "Star on GitHub" entry to Home and Settings
Home's feedback band ends with "⭐ Star on GitHub" and Settings -> Help and feedback
has a "Star it on GitHub" row; both open the project page in a new tab
(feedback.js openRepoPage). No pop-up, no reminder and no request is made: the
page opens only when pressed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:47:56 +08:00
DemonGatanjieuandClaude Opus 5.5 2cce43e1e2 Merge feature/next: security and resource rules in AGENTS.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:12:13 +08:00
DemonGatanjieuandClaude Opus 5.5 172ae79bc5 Merge feature/ui-shell: security and resource rules in AGENTS.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:12:03 +08:00
DemonGatanjieuandClaude Opus 5.5 4a109c4df8 Edit emotions: search a character's clips by line or file name
A search box above the clips (ui_tts_clip_search.js) finds clips by part of their
line or file name and lists the first 30 with their lines, to hear, use as the main
voice or add as an emotion (the new row gets the clip and its line). The lines come
once per dialog from Anomalous_TTS's GET /anomalous_tts/reference_lines (interface
14, by character name only); with an older node the request fails quietly and the
search covers file names. Picking a clip now fills its line from the same lines,
so the per-clip request is gone. Lines reach the page as text only (textContent).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:03:46 +08:00
DemonGatanjieuandClaude Opus 5.5 769e46a5eb Edit emotions: fill in a picked clip's line, and show the line in use
With Anomalous_TTS interface 14 (GET /anomalous_tts/reference_text), picking a clip
that is not yet in use fills in the line the node finds for it (.txt / .lab /
annotation list); an older node gives no answer and the box stays blank as before.
The answer is used only while the box still holds that clip and is still blank, and
is dropped with the dialog. A blank line box shows the line the node uses for its
clip as greyed placeholder, so the settings are not changed by opening the dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:50:48 +08:00
DemonGatanjieuandClaude Opus 5.5 a3ee55dfd2 Edit emotions: list every clip, and let the line follow the clip
The audio boxes are a native datalist, which lists only files whose names contain
the box's text, so a filled box showed one or two clips. Pressing a box now empties
it to list all of them (the file shows as the placeholder) and puts the file back
when nothing is picked; Backspace or Delete really clears it.

Picking another clip kept the old clip's line next to it. The line box now follows:
the line it had for the first clip, the line the node already reads for a clip in
use, or blank, which makes the node read the clip's own .txt / .lab / list line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:39:15 +08:00
DemonGatanjieuandClaude Opus 5.5 bd9afedb68 Merge feature/next: clicks on a scrolled combo or recipe page reach the header again
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:28:57 +08:00
DemonGatanjieuandClaude Opus 5.5 0f44d3fb2f Merge feature/ui-shell: clicks on a scrolled combo or recipe page reach the header again
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:27:54 +08:00
DemonGatanjieuandClaude Opus 5.5 ebbe82bda4 v1.60.0 is not a beta
CHANGELOG heading and README badge drop "Beta". The recipes architecture note and
AD-013 said recipes stay beta until a stability decision; that decision is this
release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:54:54 +08:00
DemonGatanjieuandClaude Opus 5.5 0298fd4f0e Merge feature/next: v1.58.0 and v1.59.0 notes; model-import's notes as v1.60.0 Beta
- CHANGELOG: v1.59.0 Beta and older taken as they are on next; model-import's own
  sections under a new v1.60.0 Beta heading (model import, Tidy, base-model filter,
  version labels, settings tabs; the scanner's wider offline base-model guess moves
  under the base-model filter). Stale copies of entries the v1.58.0 release reworded
  are dropped.
- README badge v1.60.0 Beta.
- What's new guide (new id 2026-10-model-import-and-tidy): import, Tidy, base-model
  filter with version labels, settings tabs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:36:41 +08:00
DemonGatanjieuandClaude Opus 5.5 d2b4b03e32 Merge feature/ui-shell: v1.58.0 Beta release; next's notes as v1.59.0 Beta
- CHANGELOG: ui-shell's v1.58.0 Beta block kept as it is there; next's own sections
  move to a new v1.59.0 Beta heading (model downloads, backup, share codes, recipe
  packages, node combos, MCP, light theme, opening by node, header search, LoRA
  insertion, DeepL key, tests; "Ctrl+Z after a scan" under Fixes).
- README badge v1.59.0 Beta.
- What's new guide (new id 2026-10-downloads-and-backup): downloads from Model Check,
  backup and share codes, node combos, light theme, MCP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:33:54 +08:00
DemonGatanjieu 4cc7d1aa46 Merge feature/next: security floor and the review lesson 2026-10-09 15:51:51 +08:00
DemonGatanjieuandClaude Opus 5.5 a2622188aa docs(lessons): the ComfyUI-Manager review of Anomalous_TTS and where AMB stands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:51:51 +08:00
DemonGatanjieu 2379b52088 Merge feature/ui-shell: security floor in AGENTS.md 2026-10-09 15:51:50 +08:00
DemonGatanjieu bb0587ccdb Merge feature/next: folder rules comment 2026-10-09 15:18:37 +08:00
DemonGatanjieu b8f9efa177 Merge feature/ui-shell: folder rules comment 2026-10-09 15:18:36 +08:00
DemonGatanjieu 3717503537 Merge feature/next: ask GPT-SoVITS again before refusing an import
# Conflicts:
#	CHANGELOG.md
2026-10-09 15:13:41 +08:00
DemonGatanjieu 0394bcdcb7 Merge feature/ui-shell: ask GPT-SoVITS again before refusing an import
# Conflicts:
#	CHANGELOG.md
2026-10-09 15:12:35 +08:00
Claude 9c6831c1f0 docs: point to the node's INTERFACE.md only
The Claude project mirror anomalous-tts-interface.md is no longer kept.
2026-10-09 06:35:38 +00:00
Claude 2d339eb770 docs: point to the node's INTERFACE.md only
The Claude project mirror anomalous-tts-interface.md is no longer kept.
2026-10-09 06:35:38 +00:00
Claude ca2cdecf7e fix(models): import upload and Tidy moves only from this computer
PUT /anomalous/import/upload and POST /anomalous/placement/move wrote
into the model folders for any caller, while place and identify already
answered only the computer ComfyUI runs on. With ComfyUI started with
--listen, another device could upload model files or move models
between folders. Both now check mcp_server._is_local first and answer
403 not_local, which the import and Tidy screens already show.

Tests drive both routes through a proxy header and check nothing was
written or moved; they fail without the check. Full suite: structure
check, every tests/*.mjs and 234 Python tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018P2T2R7TFbJsc3C9maCD8B
2026-10-08 20:35:57 +00:00
Claude 1b8839d2c6 Merge feature/next: Anomalous_TTS interface 13, no local paths
Brings stage 2 into stage 3: the gallery drag fix and the change that
follows Anomalous_TTS interface 13. No conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018P2T2R7TFbJsc3C9maCD8B
2026-10-08 20:23:55 +00:00
Claude def279f434 Merge feature/ui-shell: Anomalous_TTS interface 13, no local paths
Brings stage 1 into stage 2: the gallery drag fix and the change that
follows Anomalous_TTS interface 13 (no server path picker, no storage
change or move, folders shown with the node's settings file).

Conflicts: the picker CSS, the storage-change activity label and its
locale keys are dropped; stage 2's model download and backup entries stay.
tests/tts_setup_api.mjs now checks the pretrained download route instead
of the removed storage and library routes, and that no export sends a
path on the computer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018P2T2R7TFbJsc3C9maCD8B
2026-10-08 20:23:29 +00:00
DemonGatanjieuandClaude Opus 5.5 98148481bc Models page tabs, base model filter, Tidy tab redone with covers
- The models page has tabs: All models, Tidy (with its count) and Sources;
  the type bar keeps the type chips and Import.
- Base model chips under the type chips (All, Illustrious, SDXL, Unknown...),
  one per family the listed models have (model_bases.js; SDXL 1.0 = SDXL);
  models not scanned are listed by their header's guess (metadata base_guess).
- Tidy is cards with the model's cover and name in two steps: models in a
  folder whose loader cannot read them (from -> to, one plain reason, Move
  there, Change place), and identical files side by side with the copy to
  keep marked (the one in the right folder; click another to change) and one
  button for the extra copies. Models that load anyway and extra copies of a
  well-placed file are not in step 1. Same-size files up to 256 MB are
  compared at once; larger ones on Compare.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:02:00 +08:00
DemonGatanjieuandClaude Opus 5.5 552809c541 Tidy view: models in the wrong folder and identical copies
The models page's Tidy chip (with a count) reads every model's header
(model_kind) and lists files whose type does not match their folder - such
as an Anima/Flux/Wan diffusion model in checkpoints, which Civitai labels
"Checkpoint" - with why it matters, where it belongs (same subfolder, same
drive) and Move there, taking its cover, info and notes along; never over a
file. A whole checkpoint in diffusion models is marked as loading anyway.
Identical files are grouped by SHA-256 (scan hashes; same-size unscanned
files on Compare); each copy can go to the Recycle Bin. Moves are logged
(model_move). Also: opening the Tidy or Sources view stops cards of an
earlier load from being drawn into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:02:14 +08:00
DemonGatanjieuandClaude Opus 5.5 118f2f5178 Tell Anima/Cosmos models and merged checkpoints apart
Anima (Cosmos-Predict2 with a Qwen3 adapter, with or without "net." names) was
taken for Wan or not recognized; it is a diffusion model with base "Anima". A
merged checkpoint that kept a LoRA's keys was taken for a LoRA: whole models are
now told before LoRAs. Found by reading the headers of a real library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:50:39 +08:00
DemonGatanjieuandClaude Opus 5.5 f7447b2222 Import dropped model files into the right folders; version labels; settings tabs
- Models page: "+ Import" chip and model files dropped on the grid open an import
  window. model_kind.py tells a file's type (LoRA, checkpoint, VAE, text encoder,
  diffusion model, ControlNet, embedding, CLIP Vision, upscaler) and base model from
  its safetensors / GGUF header only; .ckpt/.pt are never opened. api/model_import.py
  finds the dropped file in Downloads/Desktop (name, size, time) and moves it, or
  takes an upload; checks it against Civitai and the scanned models; never replaces
  a file (" (2)"). New files are scanned after.
- Model cards show the Civitai version name (metadata version_name).
- Settings page in tabs (Look, Models, Workflows, Prompts, Backup, Help); the
  download folder is a choice of ready-made folders or typed by hand; default is now
  by base model ({base}).
- The scanner's offline base model uses model_kind (LoRAs, Qwen, Wan, Chroma...).
- AD-019: no Civitai browser inside the plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:07:43 +08:00
DemonGatanjieuandClaude Opus 5.5 4ab08a36e4 Combos keep every picked node, not only model and text nodes
Save as combo now keeps all picked nodes but subgraphs, with the links
between them. Model drop-downs and text boxes stay the slots; nodes
without slots are listed in the editor under "Nodes put back as saved"
with their values and go back exactly as saved. Inputs fed by links from
unpicked nodes are stored in structure.open, and the save dialog, the
editor and the card say which ones to wire after putting it down. Cards
of combos without models name their nodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 02:39:44 +08:00
DemonGatanjieuandClaude Opus 5.5 c7fb34a8b3 Fix gallery drags to the canvas and the thumbnail rail jumping
Chrome adds a dragged <img> as a file, and ComfyUI prefers that file to
the link: since the gallery shows WebP thumbnails, a drop on the canvas
loaded the thumbnail (no workflow) as a Load Image node. The card is the
drag source now, so ComfyUI fetches the original PNG.

Picking a picture near the end of the details rail loaded the next page
and rebuilt the rail, which scrolled down again from the top. The new
thumbnails are appended instead, and clicks read the current index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:49:15 +08:00
DemonGatanjieuandClaude Opus 5.5 430a83af00 Make light theme text clearer
Grey secondary and hint text is darker in the light theme. Labels and
buttons laid over pictures (model card names, recipe cover labels, the
gallery's View parameters button) keep the dark tokens, including
ComfyUI's palette variables, so their text no longer turns dark on a dark
backing. Prompt role badges, the red close crosses and the voice import
button get colors that read in every theme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:25:01 +08:00
DemonGatanjieuandClaude Opus 5.5 7b363bdac8 Light theme: follow ComfyUI, dark, light or Abyssal Scarlet
Settings -> Appearance -> Theme (and the ComfyUI setting
Anomalous.ModelBrowser.Theme, which replaces the Abyssal Scarlet switch)
picks Follow ComfyUI (default), Dark, Light or Abyssal Scarlet. Following
ComfyUI watches the dark-theme class ComfyUI sets on <html> for a dark
palette and repaints at once; an older Abyssal switch reads as Abyssal.

The light theme is html.amb-theme-light with its own --amb-* values. Hard
coded colors were turned into tokens whose dark values are the colors they
replace (checked: every stylesheet resolves to the same dark values):
rgba(var(--amb-ink-rgb), a) for tints, status shades, tooltip, float,
dialog, sunken, scrim and note tokens. Older rules and inline styles that
wrote a dark color now write var(--amb-lt-<role>, <that color>); the
--amb-lt-* tokens exist only in the light theme. Inside the plugin's
windows ComfyUI's palette variables (--bg-color, --fg-color ...) follow the
plugin's theme when the two differ. Surfaces over images stay dark, and the
image viewer gets the dark tokens back. The ready banner on a recipe reads
green instead of pink. Stylesheet cache versions are bumped.

Checked on a test ComfyUI in light and dark: home, models, gallery and its
viewer, workflows and a recipe's tabs, combos and a combo, prompts and the
docked drawer, voices, audio library, settings, scan, Model Check, current
node and its model picker, a model's detail with notes, share, save
recipe, update guide, versions, GPT-SoVITS settings and the prompt dialog;
light with a dark ComfyUI, and Abyssal Scarlet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:27:02 +08:00
DemonGatanjieuandClaude Opus 5.5 b78747c9a8 Prompts is a page like the others; "Beside the canvas" docks it
The rail's Prompts entry used to fold the browser away and open Prompt
Studio in a drawer by the canvas, the one page that left the window. It is
now the browser's Prompts page (owner.promptPanel): the rail, the 7 key,
opening the browser with a text node selected, Current node's "Edit in
Prompt Studio" and a kept prompt all go there, and reopening the browser
returns to it. "Beside the canvas" on the page moves the studio into the
drawer as before (side and width remembered, browser folded away); the
drawer has "Back to the window" and Close.

Only one studio is shown at a time. The page's studio stops when another
page is shown, a workspace opens or the browser closes (its 400 ms poll
with it) and is made again when the page is shown; closing a workspace
opened from the Prompts page returns to it.

Checked live on a test ComfyUI: a CLIP Text Encode opens the page aimed at
it; docking, Back to the window and Close; 3 then 7; Workflows opened and
closed from the page; reopening the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:06:27 +08:00
DemonGatanjieuandClaude Opus 5.5 ef0e21ef94 Remove Prompt Studio's own shortcut
The "Open Prompt Studio shortcut" (Ctrl+Shift+L by default) goes: its row
in ComfyUI's settings, its command and Extensions menu entry, its default
keybinding and the keydown fallback that backed it. The command goes too,
so a binding a user recorded for it no longer opens anything. Prompt
Studio opens from the rail, with 7 while the browser is open, or by
opening the browser with a text node selected. The browser's own shortcut
(Ctrl+Shift+M) stays. Unused key-matching helpers are removed with it.

Checked on a test ComfyUI: the settings page lists only the browser's
shortcut, the command list has Open and Reset position, Ctrl+Shift+L does
nothing, and no console errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:50:19 +08:00
DemonGatanjieuandClaude Opus 5.5 80805b88d3 Open where the selected node is; number keys for the rail's pages
Opening the browser (the trigger, the action-bar button, the menu or its
shortcut) now follows the node selected on the canvas: a node with prompt
boxes and no model drop-down opens Prompt Studio, which targets that node;
any other node (a KSampler, a model loader) opens Current node; nothing
selected reopens the page used last. Settings -> Opening and window can turn
it off. Prompt Studio closing, the doctor banner and MCP actions still open
the browser as it was.

While the browser is open, the plain keys 1-9 go to the rail's pages from
the top. They are ignored in text fields, while an input method is
composing and while a dialog is open; ComfyUI binds no plain digits (W is
its Workflows key, so W/S were not used). The rail's tooltips name each
page with its number.

The rules are in shell_open_rules.js with tests/shell_open_rules.mjs.
Checked live on a test ComfyUI: a CLIP Text Encode opens Prompt Studio on
it, a KSampler opens Current node, 1/3/4/5/7/8 switch pages, digits typed
into the search box stay there, and the setting turns the rule off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:43:53 +08:00
DemonGatanjieuandClaude Opus 5.5 7c140f59ac Settings: a DeepL key for prompt translation
Settings -> Translation -> DeepL key saves the user's own DeepL API key;
translation then asks DeepL first, else Google's free endpoint, else
MyMemory. Saving a key translates one sentence and says whether it works or
why not (a refused key, a used-up month, busy). Only the key's tag changes,
so the page keeps its scroll place. The key stays on this computer: backups
leave it out like the Civitai key.

DeepL stopped taking the key as an auth_key parameter in 2025, so a key put
in config.json by hand no longer worked. It now goes in the Authorization
header with a JSON body; targets are ZH-HANS and EN-US. When DeepL fails,
the answer carries deepl_error beside the other provider's translation.

Checked live: a made-up key is refused by DeepL's server and the dialog
says so; removing it switches back to Google.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 03:19:25 +08:00
DemonGatanjieuandClaude Opus 5.5 93cf8f122a Recipe packages: tell the receiver about the cover; unstretched cover preview
The import dialog said "with 1 picture" for a package with a cover and one
model thumbnail: the cover lives inside recipe.json as a small image, not as
an asset file. Inspect now returns has_cover and model_pictures, and the
dialog says "a cover, N model thumbnails".

The save dialog's cover preview stretched the chosen image to the dialog's
width; it now keeps its proportions (object-fit: contain).

Checked live: a recipe with an output-image cover and a LoRA thumbnail,
exported and imported with the output image moved away, shows both on the
new card and its detail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 01:14:29 +08:00
DemonGatanjieuandClaude Opus 5.5 da38e2d6ad feat(recipes): reopen recipe packages, imported through share and import
By the user's decision, recipe packages are open again: the card's
export button and the detail's More menu make a .zip named after the
recipe (workflow, notes, tags, parameters, cover, model thumbnails;
versions only when ticked). Dropping one into the Workflows page's
share and import dialog inspects it and keeps it as a new recipe card,
optionally opened on the canvas.

An import never replaces a recipe any more: the "replace" path, which
erased the old recipe for good, is gone; a taken name gets " (2)".
Assets and versions move in before the recipe file, and a failure
removes only this import's new folders. The release gates and their
503 answers are removed. Tests: new card and renaming, nothing left
after a failed import, tampered and escaping packages refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:32:05 +08:00
DemonGatanjieuandClaude Opus 5.5 875715e4a0 feat(share): short AMB2 share codes, one dialog to share and import
share_code.js: a workflow's lean form (node id, type, widget values,
position to 10 px, only what differs from a fresh node; node-pack nodes
keep their ports), links by slot, groups and the anomalous_* extras,
deflated and spelled in Chinese characters (14 bits each) or base64url.
Encoding rebuilds and compares; subgraphs, native reroutes or any
difference share the whole workflow. AMB0/AMB1 still decode. About 4x
shorter than AMB0 on ComfyUI's default workflow (657 vs 2521), 13x on
an Easy-Use/rgthree graph.

ui_share.js replaces the share code dialogs that lived in main.js: the
code is made on opening with both copy buttons and the fingerprint
notes, and pasting a code or JSON, or dropping/choosing a workflow file,
an image or a backup .zip, imports it. tests/share_code.mjs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:22:11 +08:00
DemonGatanjieuandClaude Opus 5.5 d77655fb76 feat(backup): models' scan results, and Civitai covers on request
A backup now keeps each model's scan file (.info, small; on by default)
so names, trigger words and base models survive Civitai removing a
model, and on request Civitai's covers (stored once as the civitai_bak
copy; they can be hundreds of MB). Putting back writes a scan file only
where the model has none (replaced only with "replace") and Civitai's
cover only where it has no cover, with its civitai_bak copy so it stays
Civitai's and a later user cover still wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:11:51 +08:00
DemonGatanjieuandClaude Opus 5.5 1efa20fd91 feat(backup): download the models a new computer lacks
A backup also keeps a model list (each model's folder, path, size,
SHA-256 and link). Inspecting it names the models this computer lacks
(two copies of one file are one download); "Find downloads..." looks
them up like Model Check (Civitai by hash, the saved link, Hugging
Face), shows size, source and free space, and downloads the ticked ones
one after another into the path they had, each checked against its hash
and scanned after. A file found by name only starts unticked. Verified
on the isolated ComfyUI: an 18 MB Civitai LoRA came back to its folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:23:37 +08:00
DemonGatanjieuandClaude Opus 5.5 797a52105e feat: Settings -> Backup, export and import the user's data
Export packs the stores (recipes, combos, materials, parameters, and
optionally ComfyUI's saved workflows), the plugin settings without the
Civitai key, and each model's user layer and own cover into one .zip to
keep on a cloud drive. Import lists what is new, the same or different
and the models it finds again (by SHA-256, else folder, path and size),
adds only what is missing by default, replaces differing files only on
request with this computer's going to the Recycle Bin first, merges a
model's name and notes field by field and puts the user's cover in
place of a Civitai one. Unsafe or foreign zips are refused. Both are
activity entries. tests/test_backup.py covers the rules; testing.md
names a single test file the way unittest finds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:12:45 +08:00
DemonGatanjieuandClaude Opus 5.5 7bb46ba82f docs: lessons move from the private .agents notes into the repository
The recurring traps (a :has() rule slowing every canvas frame, fixes
left on an unpushed local main, release checks, imports lost in a module
split) are now docs/architecture/lessons.md, so the links from
ARCHITECTURE.md, AGENTS.md and the docs indexes open for everyone.
AGENTS.md: tests live in tests/, .agents stays a local folder for
screenshots and logs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:54:20 +08:00
DemonGatanjieuandClaude Opus 5.5 7b09909e5b test: publish the plugin's tests, one command to run them
tests/ (Python backend tests, .mjs frontend rule tests) is no longer
ignored. tools/run_tests.mjs runs the structure check, every
tests/*.mjs and the Python tests with $PYTHON, the portable
python_embeded or python. docs/guides/testing.md says how to run and
write them; README links it. Developer notes no longer point at 18 test
files that were removed earlier (AGENTS.md table, backend, frontend and
update-guide notes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:47:35 +08:00
DemonGatanjieuandClaude Opus 5.5 211f8b9981 Merge feature/ui-shell: README pictures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:43:35 +08:00
DemonGatanjieuandClaude Opus 5.5 0abfd0a775 feat(combos): node names, prompt translation peek, multi-model nodes
Each slot names its node (and node pack when not ComfyUI's own). A small
"译" button beside a prompt shows its translation in a read-only panel
(ui_translation_peek.js). On nodes with several models, saved values go
to the row with the same number (strength_02 under lora_02) and more
than four fold behind "+N"; empty optional rows (None) start unticked in
the save dialog so a ten-row LoRA stack does not make ten empty slots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:50:58 +08:00
DemonGatanjieuandClaude Opus 5.5 3995e34eb4 feat(combos): node combo page grouped in flow order, covers and saved values
Models, LoRAs and prompts in their own groups, in the order they flow
(LoRA 1 next to the model, positive before negative); made-up names
renumbered along the chain. Covers looked up for every model, a whole
row opens the picker, names wrap to two lines, each model node's saved
values (strength, CLIP strength, type) shown read-only, prompt boxes
grow with their text. New captures keep nodes in flow order. The combo
editor's sticky top bar sat 20 px down with the page showing above it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:32:37 +08:00
DemonGatanjieuandClaude Opus 5.5 4c6a4806d2 feat(combos): right-click "Save as combo" on the canvas
The canvas's node menu (the frontend's getNodeMenuItems hook) offers
"Save as combo" for the picked nodes, or the right-clicked node when it
is not among them, when any of them is a model or text node; it opens
the same save dialog without opening the browser first, and the browser
shows the new combo afterwards.

Values other than the slots stay as they were saved (agreed): a combo
always puts down exactly what was saved; other values mean another combo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:04:03 +08:00
DemonGatanjieuandClaude Opus 5.5 0dcc67f394 feat(combos): combos from the nodes picked on the canvas
A combo can now hold its own node structure: pick nodes on the canvas and
New -> From the nodes picked on the canvas keeps the model nodes (a
drop-down of model files) and the text nodes (a multiline text box by the
node's definition) with the links between them; other picked nodes and
links to them are left out, their ports stay open on the canvas.

Slots are per box, so a node with several model drop-downs has several; a
box fed by a link is no slot. Each model slot's folder is the one whose
files hold all its drop-down's model-file options (ComfyUI's /models
lists), never guessed from names. Every other box is stored by name and
put back untouched, in order (a count box adds its rows first).

The save dialog shows what is kept and dropped and lets slots be renamed
or unticked. The editor lists the slots: a picker of that folder's models
with covers, filtered by base model, and text boxes. Put on canvas makes
the nodes (missing node types and their packs are named before anything
is placed), fills the slots by name, links by port name and type, as one
Ctrl+Z step; a box or link that cannot be restored is reported, never
guessed. New -> Same structure as ... starts another combo from a saved
structure. The default combo is unchanged. MCP list_combos lists node
combos by slot and place_combo places them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:19:09 +08:00
DemonGatanjieuandClaude Opus 5.5 ca003d20d2 feat: Hugging Face first with the mirror, model card covers, text encoders shown, header search
- Downloads: with the China mirror on, a Civitai match whose very file (same
  SHA-256) is on Hugging Face too, by the workflow's link or ComfyUI-Manager's
  list, is fetched from Hugging Face; Civitai's name, base model and page stay.
- A file downloaded from Hugging Face gets its model card's first example
  image as cover and its base model, trigger words and example prompts as
  notes, with the repository as link (api/hf_card.py), never over what the
  user or a scan set.
- Text encoders, CLIP vision and upscalers are shown (and scanned) by
  default, so shared workflows carry their fingerprints; older configs get
  them turned on once (folder_defaults_version 2).
- The models page's search sits in the header (ui_model_search.js) instead
  of inside the folder list; it searches the whole type by name, file name,
  folder, trigger words, base model and notes, every word; Esc clears it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:37:17 +08:00
DemonGatanjieuandClaude Opus 5.5 9624ae1356 feat(model-download): find base models in ComfyUI-Manager's model list
Models not on Civitai (Flux's VAE, T5 / CLIP text encoders, upscalers,
ControlNets...) are looked up by file name in the model-list.json of the
ComfyUI-Manager installed here (api/manager_catalog.py), keeping entries
that fit the node's folder type. With the workflow's fingerprint only the
listed file with that SHA-256 counts (same-name entries are each asked);
without one, a single entry is offered as "Download by name" with a
warning, never in Download all or the MCP's download_missing_models.

A site that stops answering is skipped for the rest of a lookup while the
others are still asked, so Civitai failing in mainland China no longer
stops Hugging Face through its mirror. GitHub raw file links count as
sources; a Hugging Face file behind a login offers its repository page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:07:17 +08:00
DemonGatanjieuandClaude Opus 5.5 224d339afe feat(mcp): AI apps can download models
Three MCP tools: download_missing_models (Model Check's Download all for
the open workflow, through the page so the placement rules and the put-in
afterwards are the UI's own), download_model (one model from a Civitai
version link, a Hugging Face / GitHub file link or a Civitai hash, into a
folder of its type) and download_status. The default models folder of a
type is shared by the dialog and the tools.

A Civitai version link without a matching file name now takes the
version's primary model file (people rename their files); a hash, when
there is one, still decides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:53:49 +08:00
DemonGatanjieuandClaude Opus 5.5 a04f1525d4 feat(model-download): Hugging Face through the China mirror
Settings > Model downloads can send Hugging Face downloads through
hf-mirror.com, on by default for the Chinese interface. The lookup and the
download use the chosen host whichever one the link names; when the mirror
sends a network outside China back to huggingface.co, the lookup follows
once for the size and SHA-256. A failed download from Hugging Face itself
suggests the mirror.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 01:46:09 +08:00
DemonGatanjieuandClaude Opus 5.5 698a256a16 feat(model-check): download missing models, to the folder you choose
Model Check offers Download (and Download all) for a missing model whose
source names one exact file: Civitai by the workflow's fingerprint, or a
Civitai version / Hugging Face / GitHub file link the workflow carries.
A dialog shows each file's size, source and full destination, with the
models folder (when there are several) and subfolder changeable; Settings
> Model downloads sets the default: where the workflow says (the node
needs no change) or a set folder, {base} sorting by base model and
matching an existing folder in any spelling.

Downloads run one at a time into <file>.part, continue after an
interruption, are checked against the SHA-256 before they get their name,
never replace a file, and send the Civitai key to Civitai only. Finished
files are put into their nodes (one Ctrl+Z step each), scanned for info
and covers, and recorded in the activity log.

A change of fingerprints alone (after any scan) no longer takes a Ctrl+Z
step that seemed to do nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 01:28:51 +08:00
DemonGatanjieuandClaude Opus 5.5 fa11c55293 feat(lora): one way to insert a LoRA, for Current node and MCP alike
Current node's "Insert LoRA before / after" refused the most common workflows: a
checkpoint whose CLIP feeds both prompts (one link per output only) and UNet workflows
with their own CLIP loader (MODEL and CLIP had to come from one node).

- graph_splice.js: planLoraInsertion decides the links (after a node: every link of its
  MODEL output; before one: only its own MODEL input) and, for LoRAs that train the text
  encoder, the CLIP line (same node, a UNet workflow's one CLIP source, or before a
  sampler the source its prompts share); spliceLora wires it, all or nothing. The old
  analyzeModelChainInsertion / spliceModelChainNode / spliceAfterOutputs are gone.
- api/lora_info.py: GET /anomalous/lora_info reads the LoRA's safetensors header; the
  picker asks it on confirm and inserts a model-only loader when there are no
  text-encoder weights. The MCP add_lora uses the same helper.
- Insertion messages speak of the MODEL line; the fan-out ones are removed.
- tests/graph_splice.mjs (local) covers checkpoint, UNet + CLIP loader, before a
  sampler, and the rollback.

Verified on 8199 through the Current node picker: after a checkpoint feeding two prompts
(full LoRA, both prompts moved) and before a sampler (model-only Flux LoRA).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:00:32 +08:00
DemonGatanjieuandClaude Opus 5.5 0529502421 fix(mcp): add_lora wires CLIP too when the LoRA trains the text encoder
A model-only LoRA loader was used for every UNet workflow, so a LoRA with text-encoder
weights lost them; and inserting after a checkpoint whose CLIP feeds both prompts was
refused (one link per output only).

- api/mcp_actions.py reads the LoRA's safetensors header: lora_te / te1 / te2 /
  text_encoder keys mean it trains the text encoder.
- Such a LoRA becomes a LoraLoader taking MODEL from the model line's end and CLIP from
  the same node (checkpoint, full LoRA loader) or a UNet workflow's one CLIP source;
  others stay model-only.
- graph_splice.js: spliceAfterOutputs puts a node after outputs on different nodes and
  moves every link each output had. The picker's spliceModelChainNode is back to its
  committed behavior (the `types` option is gone).

Verified on 8199: UNet + CLIP loader with a text-encoder LoRA, a model-only one after it
and another text-encoder one; a checkpoint feeding two prompts; Ctrl+Z after each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:47:14 +08:00
DemonGatanjieuandClaude Opus 5.5 80f41f98e9 feat(mcp): AI apps can act on the open workflow, scan and speak
The second batch of MCP tools: they change things, through the same code the UI uses.

- Canvas, done by the open ComfyUI page: describe_canvas, set_prompt, set_model,
  add_lora, place_combo, check_workflow_models, fix_workflow_models, run_workflow,
  open_in_anomalous. api/mcp_bridge.py sends the action over ComfyUI's websocket and
  waits; web/modules/mcp_bridge.js claims it (visible tab first), does it with
  Anomalous's canvas code and answers. Each change is one Ctrl+Z step, shows a toast and
  is logged as the AI's (activity entries carry via: "mcp", with their undo).
- This server: scan_models (new / look up again / picked models, via /anomalous/scan_all),
  list_voices and speak (Anomalous TTS through /prompt and /history).
- graph_splice.js takes the chain's slot types, so a LoRA can go into a model-only (UNet,
  Flux) chain as LoraLoaderModelOnly; the default (MODEL and CLIP) is unchanged.
- Nothing deletes or renames files; acting tools are not marked read-only, so AI apps
  ask before calling them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 22:13:28 +08:00
DemonGatanjieuandClaude Opus 5.5 358c2bc4d3 feat(mcp): AI apps can read the library through /anomalous/mcp
An MCP endpoint (Streamable HTTP) inside ComfyUI's server, so Claude, Cursor, Cherry
Studio and other MCP apps can ask about the user's library without anything extra
installed and without memory or VRAM while nobody is connected.

- api/mcp_server.py: one JSON-RPC message per POST, answered with JSON. Serves the
  2026-07-28 stateless protocol (headers must match the body) and the earlier
  initialize-based versions; no sessions. This computer only: loopback peer, no proxy
  headers, loopback Host and Origin (DNS rebinding).
- api/mcp_tools.py: 11 read-only tools: library overview, model search and details
  (optional cover), scan status, output-image search and generation settings
  (optional thumbnail), combos, workflow recipes, saved prompts, generated audio,
  activity log. Ids resolve inside their roots; tool failures come back as isError.
- docs/guides/mcp.md: setup for each app, what can be asked, privacy note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:26:10 +08:00
672 changed files with 98173 additions and 12249 deletions
+4 -4
View File
@@ -1,5 +1,6 @@
# Local configuration and credentials
config.json
allow_remote_changes
.env
.env.*
!.env.example
@@ -77,10 +78,9 @@ AI_SYSTEM_TEMPLATE.md
# AI Agents Directory
.agents/
# Test files
test_*.js
test*.mjs
tests/
# Scratch test files at the top level (the plugin's tests live in tests/, see docs/guides/testing.md)
/test_*.js
/test*.mjs
# Local diagnostic/test artifacts
grep_doctor.txt
+19 -13
View File
@@ -8,10 +8,10 @@
以下几条每次提交都要做到,没有“这次改动小”的例外;其余各节是怎么做好的指引。
1. **结构和文档同步。** 按下表更新文档,与代码放在同一次提交里。新增、删除、改名、拆分源码文件一律算改了模块职责:职责清单同时是文件目录,每个源码文件都要有一行。
2. **跑结构检查。** `node tools/check_structure.mjs`,失败不提交。它检查每个源码文件都登记在架构文档里、每个前端模块都能从入口引用到、每个前端文件都能按浏览器的方式解析(`node --check` 对 .js 文件会漏掉浏览器报错的写法),并执行规模阈值(第 2 节):新文件不能一开始就超线,已超线的文件比上次提交只能变短或持平。
2. **跑结构检查。** `node tools/check_structure.mjs`,失败不提交。它检查每个源码文件都登记在架构文档里、每个前端模块都能从入口引用到、每个前端文件都能按浏览器的方式解析(`node --check` 对 .js 文件会漏掉浏览器报错的写法)、CSS 里没有按 `style` 属性匹配的 `:has()`(会让拖动画布掉帧,见 [lessons.md](docs/architecture/lessons.md)),并执行规模阈值(第 2 节):新文件不能一开始就超线,已超线的文件比上次提交只能变短或持平。
3. **不留旧实现。** 被取代的模块、样式、文案 key、文档说明同一次提交删掉(第 6 节)。
4. **按风险验证**(第 7 节),只提交本任务的文件,没有明确同意不推送。收尾时工作区干净,或在交付说明里列出每个有意留下的未提交文件。
5. **测试产物不进仓库。** 截图、录屏、日志、原始测试数据、审计附件放在 `.agents/`(已忽略),不用 `git add -f` 绕过忽略规则。提交的文件里不写本机路径、用户名或 `file:///` 链接,文档之间用相对链接。
5. **测试产物不进仓库。** 测试代码放 `tests/`;截图、录屏、日志、原始测试数据、审计附件放在 `.agents/`(已忽略,只留本机),不用 `git add -f` 绕过忽略规则。提交的文件里不写本机路径、用户名或 `file:///` 链接,文档之间用相对链接。
6. **交付说明**写清:改了什么和为什么、验证结果和没验证到的地方、遗留问题、提交号;用了规范例外就写原因和影响。
| 改了…… | 更新 |
@@ -20,8 +20,8 @@
| 数据流、接口、持久化格式、安全边界、跨系统规则 | 对应的 `docs/architecture/` 专题;系统图或阅读地图变了才改 `ARCHITECTURE.md` 入口 |
| 用户能感觉到的行为 | `CHANGELOG.md`;使用方法变了同步 `README.md` 或 `docs/guides/` |
| 长期有效的产品或架构取舍 | `docs/decisions/` |
| 反复出现的坑、严重故障 | `.agents/logs/ai_lessons.md` |
| 开发规范本身 | 只改本文件;`CLAUDE.md`、`GEMINI.md` 只做阅读入口 |
| 反复出现的坑、严重故障 | `docs/architecture/lessons.md` |
| 开发规范本身 | 只改本文件 |
普通修复、样式微调不写进架构文档;没有变化的边界不写“本次未改变”。
@@ -29,7 +29,7 @@
1. 查看 `git status` 和相关差异,保留已有工作;不覆盖、回退或提交他人的无关修改。
2. 阅读 [ARCHITECTURE.md](ARCHITECTURE.md),再按阅读地图打开与任务有关的专题,避免每次加载全部文档。
3. 搜索目标函数的调用方、相关状态、CSS 选择器及已有辅助函数,再确定修改位置。
3. 搜索目标函数的调用方、相关状态、CSS 选择器及已有辅助函数,再确定修改位置。界面上的按钮或文字,先用 `node tools/ui_map.mjs find <屏幕上的字>` 查它在哪个文件、样式在哪、调哪个接口、后端哪个函数;每块界面的用途和统一叫法见 [ui-flows.md](docs/architecture/ui-flows.md)。改了界面文字或流程,顺手更新那里(结构检查会指出对不上的地方)。
4. 简短说明本次行为变化和负责它的模块。一个修复优先落在能解释问题的最小责任范围内。
UX 请求默认允许调整布局、交互和文案,不代表允许改变文件存储位置、接口协议、工作流语义、模型识别依据或用户数据。
@@ -107,8 +107,8 @@ ComfyUI-Manager 收录审核检查的就是这几类口子(2026-10 审 Anomalo
1. **不执行下载来的或用户文件里的代码。** 数据不用 `pickle`、`eval`、`exec`、动态导入读;配置文件当数据解析。模型文件只读 safetensors / GGUF 的文件头,`.ckpt` / `.pt` / `.pth` / `.bin` 不打开,界面提醒它们可能带代码。
2. **下载要能核对。** 只从允许的 https 站点下载,先写 `.part`,核对 SHA-256 后才改名放入;没有可核对的指纹时在界面上说明。下载的东西只当数据用。
3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。
4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机)。用 `--listen` 开放到局域网时,别的设备只能查看。由 `api/local_guard.py` 的中间件统一把关:`/anomalous/` 下 GET 以外的请求默认只接受本机,确实只读的 POST 列进 `OPEN_POSTS`,不只是读的 GET(备份文件、联网、本机文件夹)列进 `LOCAL_GETS`;新接口要想清楚属于哪一类,不要为了方便放进 `OPEN_POSTS`。
3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。模型文件本身可以是链接(云服务器上模型文件夹里全是指向平台模型盘的链接):文件夹部分照样用 `resolve_within`,文件用 `model_file_within` / `model_reference_within` 按名字取,不跟着链接走出去,封面、信息和改动都在链接旁边。
4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机;ComfyUI 只监听局域网地址时,本机用这个地址打开也算本机)。用 `--listen` 开放到局域网时,别的设备只能查看。由 `api/local_guard.py` 的中间件统一把关:`/anomalous/` 下 GET 以外的请求默认只接受本机,确实只读的 POST 列进 `OPEN_POSTS`,不只是读的 GET(备份文件、联网、本机文件夹)列进 `LOCAL_GETS`;新接口要想清楚属于哪一类,不要为了方便放进 `OPEN_POSTS`。唯一的例外是云服务器用户在服务器上的插件文件夹里放了 `allow_remote_changes` 文件(启动时读一次,网页建不了也删不掉):这时 `local_guard.may_change` 也放行别的设备;接口里要自己判断时用 `may_change`,只有“浏览器就在这台电脑上”才成立的事(比如在本机的下载文件夹里找原文件)才用 `is_local`。MCP 始终只接受本机。
5. **用户的文件删除一律进回收站,没有回收站就拒绝;从不覆盖**(同名另存为“名字 (2)”或拒绝)。插件自己产生的临时文件(`.part`、暂存区)可以直接删。
6. **只读的接口也按第 3 条来。** “只是读”不是例外;能按名字、编号取的,就不收路径,由服务端在自己登记的范围里找到文件。
7. **读用户文件要有上限。** 限制读取的大小和条数;读不了或超大的文件跳过并在结果里说明,不让一个坏文件拖垮整个请求。读到的内容进页面只当纯文本(本节第二条)。
@@ -122,6 +122,13 @@ ComfyUI 的内存、显存和 CPU 是留给出图的。插件闲着的时候应
3. **数据跟着界面走。** 界面打开时才读,关掉就释放;跨次使用的缓存要小、有上限,写清楚何时失效。长列表只渲染看得到的或前几十条。
4. **图片用缩略图。** 网格和列表用缓存的缩略图,原图只在看大图或拖到画布时读取;浏览器里不常驻大量原图。
### 功能原则
几个功能对用户的承诺。改这些功能时不能破坏;确需例外,先问维护者:
1. **模型检查(模型医生)从不自己改工作流。** 检查只读;只有用户按了“换上”或“下载”才动节点,而且只换成同一个文件(SHA-256 指纹一致)。只是同名、没法核对的候选,标明“按文件名”由用户决定。下载的来源(C 站按指纹、工作流带的链接、ComfyUI-Manager 模型清单)在界面上写明。完整的判断规则(三档可信度、基础组件只认指纹、下载来源与核对)见 `docs/guides/model-check-rules.md`(英文版 `model-check-rules.en.md`,README 的按钮指向这两份),改模型检查前先读、改了规则两份一起改。
2. **搭配只记住,从不修改参数数值。** 存的时候是什么,放回画布就是什么:强度、CLIP 层数、提示词等一律原样,不用节点默认值顶替,也不替用户“调”。想要别的数值,在画布上调好再存一个新搭配。放上画布只新放一组节点,从不改画布上已有的节点。(理由见 `docs/decisions/README.md` AD-021;模型检查的见 AD-005、AD-006、AD-020。)
## 6. 清理旧实现
- 替换一条路径时搜索它的导入、导出、调用、字段、事件和选择器;确认无用后一起移除。
@@ -137,11 +144,10 @@ ComfyUI 的内存、显存和 CPU 是留给出图的。插件闲着的时候应
| 修改范围 | 至少验证 |
| --- | --- |
| 导入、导出、模块拆分 | JS 语法、模块链接,以及受影响按钮的真实执行路径 |
| 工坊状态与生命周期 | `node --experimental-vm-modules tests/prompt_ui_lifecycle.mjs` |
| 词卡读取、分页 | `node tests/prompt_material_source.mjs` |
| 翻译桥接 | `node tests/translation_service_contracts.mjs`,异步节点写入另跑工坊生命周期测试 |
| 素材应用、笔记隔离 | `node --experimental-vm-modules tests/material_workspace_flow.mjs` |
| 素材提示词协议、配方筛选、拖拽清理 | `node --experimental-vm-modules tests/studio_contracts.mjs` |
| 提示词工坊、词块 | `node tests/prompt_tags.mjs`、`node tests/prompt_boxes.mjs` |
| 素材提示词、节点参数 | `node tests/material_prompt_cross_node.mjs`、`node tests/node_material_actions.mjs`、`node tests/node_parameter_sets.mjs` |
| 搭配、往画布插节点、撤销 | `node tests/combo_slots.mjs`、`node tests/graph_splice.mjs`、`node tests/canvas_undo.mjs` |
| 提交前全部跑一遍 | `node tools/run_tests.mjs`(说明见 `docs/guides/testing.md`) |
| CSS、布局、交互 | 实际浏览器验证受影响状态;涉及宿主行为时验证 ComfyUI |
| 文件修改、导入、存储 | 对应 Python 测试,至少覆盖成功、冲突和中途失败保留旧数据 |
| 仅文档 | 检查链接、命令、实际代码一致性和 Git 差异 |
@@ -150,7 +156,7 @@ ComfyUI 的内存、显存和 CPU 是留给出图的。插件闲着的时候应
- 修复故障时,优先补充能复现原故障的行为测试。不能只通过源码字符串“存在某段代码”来证明修复。
- UX 明确替换旧交互时,更新测试到新的用户流程,并保留原本要保护的行为;不要仅删除失败断言或重新塞回废弃功能。
- 新增应随仓库交付的回归测试要检查 `.gitignore`,确认确实进入版本控制。
- 历史本地 Python 测试可能不在 Git 中。使用合适环境运行相关测试,例如 `python -m unittest discover -s tests -p 'test_*.py'`,核对实际发现的数量;零项测试不算通过。
- Python 测试要在 ComfyUI 的 Python 环境里跑(`node tools/run_tests.mjs` 会自己找),核对实际发现的数量;零项测试不算通过。
- 无法运行真实宿主、网络服务或完整测试时,准确说明缺口;不要将模拟画布、语法检查或已有通过数量称为本轮宿主验证。
## 8. 文档与交付
+826 -85
View File
File diff suppressed because it is too large Load Diff
+885
View File
@@ -1,5 +1,890 @@
# 📈 Anomalous Model Browser Changelog
## v1.63.0 (AI Ways and Skills, Saves, Official Example Workflows, Privacy Mode) — 2026-11-10
- 画布左上角的立方体入口不再压在 ComfyUI 自己的菜单上面:以前它在最顶层,打开左上角的 C 菜单时会盖住“新建”那一行。现在它在画布上面、顶栏和菜单、对话框下面。README 的安装第 2 步也配上了图,写明装好后入口在哪、看不到时怎么办。
- The cube entry at the canvas's top left no longer sits over ComfyUI's own menus: it was on the very top layer and covered the "New" row of the C menu. It is now over the canvas and under the top bar, menus and dialogs. Step 2 of the README's install has a picture of where the entry is after installing and what to do when it can't be seen.
- 在设置里切换语言后,AI 工作台右边的展台、留言框和画布下方的存档条也马上换成新的语言。以前它们要刷新页面才变。
- After switching the language in Settings, the AI workbench's stage and message box and the saves bar under the canvas change at once too. They used to wait for a page reload.
- 浏览器里同时开着两个 ComfyUI 页面时,AI 干活不再时好时坏:以前两个页面会各抢一半动作,一个页面开了任务的标签页,另一个页面接着去跑却找不到它("标签页关了")。现在任务的动作先交给上次干活的那个页面,它关掉了别的页面才接手。
- With ComfyUI open in two browser tabs the AI no longer fails now and then: the two pages took half the actions each, one opened the task's tab and the other then could not find it ("the tab is closed"). Now the page that did the last action gets the next first, and another takes over only when it is gone.
- AI 用 `run_workflow` 时可以直接写图库里一张图的名字:一步打开这张图的工作流、改好提示词或种子、跑出来;同一张图再跑就接着用那个标签页。打开图的工作流时换一个新的工作流编号,原来那个标签页还开着也不会串;打开后的标记在加载之前就写进去,不会被 ComfyUI 收尾时冲掉(以前偶尔会说"标签页关了"或"没打开")。
- With run_workflow the AI can name a picture of the gallery: its workflow opens, gets the new prompt or seed and runs, in one call, and a run of the same picture again reuses that tab. A picture's workflow opens with an id of its own, so the tab it ran on, still open, is not mixed up with it, and the tab's mark goes in before it loads, so ComfyUI finishing the tab does not drop it (it said now and then that the tab was closed or did not open).
- 偶尔打开 ComfyUI 时 AMB 整个没出来(刷新一下又好了)的问题修好了。ComfyUI 会同时加载插件的全部 js 文件,碰巧某个文件先跑时,会用到另一个还没准备好的变量,整个插件就加载失败;重启或更新后第一次打开最容易碰上。新加的测试会把每个文件都当成第一个加载试一遍。
- AMB is no longer missing now and then when ComfyUI opens (a reload brought it back). ComfyUI loads every js file of the plugin at once, and when one file happened to run first it used a value of another file not made yet, and the whole plugin failed; the first open after a restart or update met it most. A new test runs each file first.
- AI 看得到工具的回答了:AI 软件收到太长的回答(Antigravity 大约 4,000 字节以上)会存成文件不看,AI 就什么也没拿到,只好缩小范围重查(考试里为挑一个 LoRA 搜了 8 次)。现在搜模型的回答只留有用的(同类模型的加载节点只写一次,触发词列前四个,LoRA 带上作者示例图的权重),读大画布时后面的节点写成一行,其他列表放不下的列出名字,告诉 AI 怎么查剩下的。
- AI apps get to read the tools' answers: an app files an answer past its size (about 4,000 bytes in Antigravity) away unread, so the AI had nothing and searched again with smaller limits (eight searches for one LoRA in the exam). Model search now keeps what is used (a type's loaders said once, four trigger words, a LoRA's weight from its author's pictures), a big canvas gives its last nodes a line each, and any other list names what did not fit and says how to get it.
- 画布是空的时候,AI 跑图或读画布会被告知先打开一个工作流(存的、示例,或者图库里某张图自带的),不再让它去"加一个保存节点"。AI 查一张图的参数时,也会知道这张图的工作流能直接打开。
- On an empty canvas the AI is told to open a workflow first (a saved one, an example, or a gallery picture's own) instead of being told to add a save node. Reading a picture's settings, the AI learns that its workflow can be opened as it was.
- AI 发来只写着"test"这类试探的问题,不再放到你面前等回答,AI 会收到正确的问法。任务上的"出图 N 张"只数真正跑出来的图,AI 找到给你看的旧图不算。
- A question that only says "test" or the like no longer waits in front of you: the AI is told how to ask. A task's picture count counts the pictures its runs made, not old ones the AI found to show you.
- AI 想改图库里的某一张图时,可以直接打开那张图自带的工作流(`open_workflow` 写图片名),不用再打开原来的工作流卡,把换过的模型、LoRA 和提示词一样一样设回去。打开的是新标签页,你开着的那页不动。Gemini 实测,这类题从 28 次调用降到 21 次。
- When the AI changes a picture from the gallery, it can open the workflow that picture carries (open_workflow with the picture's name), instead of opening the saved card and putting back the model, LoRA and prompt that had been changed one by one. It opens in a new tab and leaves yours as it was. In a real Gemini run such a task went from 28 calls to 21.
- 在设置里切换语言后,AI 工作台的任务列表和时间线马上跟着换。以前有一部分要刷新页面才变。
- Switching the language in Settings now changes the AI workbench's task list and timeline at once. Part of them used to wait for a page reload.
- 任务上的“出图 N 张”不再把同一张图算两次。以前 AI 汇报时附上刚跑出的那张图,会被多数一次。
- A task's picture count no longer counts one picture twice. When the AI's report showed the picture its run had made, it was counted again.
- AI 看工作流清楚多了(Gemini 实测发现的):列表以前一次两万多字,AI 软件直接存成文件不看了,现在卡片多时只给名字、路线和备注。每张卡的路线说明以前一直是空的,现在有了;按模型找卡片也恢复了。官方示例总会列出名字,用中文(图生图、两段放大)也搜得到。
- AI apps see your workflows better (found in a real Gemini run): the list was over 20,000 characters, which an AI app files away unread, and is now names, routes and notes when there are many. Each card's route line was always empty and now says the route, and finding cards by model works again. Official examples are always named, and found by their Chinese names too.
- 新任务里先写了留言的话,任务列表就用这句话当标题,不再都叫“接手任务”。
- A new task you wrote to takes its name from what you wrote, instead of the AI's "take the task".
- 两个 Anima 两段放大示例写明了:要多张图就多跑几次,别调批次。Anima 的 VAE 会把一批图当成视频帧,第二段只剩一张。
- The two Anima two-pass examples now say to run several times for several pictures, not to raise the batch: Anima's VAE takes a batch for video frames, and the second pass is left with one.
- 当前节点的提示词卡片现在都标着“正面提示词”或“负面提示词”:从图片存下来的也按它原来接在采样器哪个口上来认,以前只有工作流来源的才标,图片和工作流同时有时标注还会丢。
- Current node's prompt cards all say positive or negative now: values kept from a picture are told by what they were wired to in that workflow. Before, only a workflow's said so, and the label was lost when a picture held the same text.
- 回到一个存档时,输出节点上的图也跟着换:画布是哪个存档,节点上就是哪个存档跑出来的图。跑了几次就都排在节点里,和批量出图一样,新的在前,最多 16 张。这个存档没有图时节点空着,不留别的存档的图,免得看错。只对这个版本之后跑的图有效。
- 回到存档时数字有时不亮、撤销多出一步的问题修好了:画布上有 Preview Image 这类节点时,每隔一次就会这样。
- Going back to a save brings its pictures back too: the output nodes show what that save made. Several runs sit on the node together as a batch does, the newest first, up to 16. A save with no picture leaves the node empty rather than showing another save's. This works for runs from this version on.
- Going back to a save no longer loses the lit number or leaves an empty undo step every other time, as it did with a node like Preview Image on the canvas.
- 发版前把每个页面按中英文、深浅色和 480 窄宽走了一遍,修了一批小问题。浅色主题下,模型整理页的标题和文件名、任务上“接着做”的标签原来几乎看不见。窄窗口下,任务的“接着哪个任务”标签伸出窗口、× 点不到,设置页的主题按钮把页面撑出横向滚动,模型页的搜索框压住标题,工作流详情的“新预设”被切掉。
- 英文界面的列表和冒号不再是中文标点,“1 saves”这类单复数对了,左边栏的 AI 工作台写作 “AI”,一条漏翻的提示补上了。深色主题下浅紫色按钮和角标上的字改成深色,看得更清楚。
- A round through every page in both languages, all themes and at 480 fixed small things. In the light theme, the Tidy page's headings and file names and a task's "goes on in" chip could hardly be read. In a narrow window, a task's "goes on from" chip ran out of the window with its × out of reach, the theme buttons in Settings pushed the page sideways, the Models search sat over the title, and "New preset" was cut off.
- The English window no longer shows Chinese punctuation in lists and after labels, "1 saves" and the like have the right plural, the rail says "AI" for the AI workbench, and one line that was left in Chinese is translated. In the dark themes, text on pale accent buttons and badges is dark now, so it reads better.
- 侧栏下面的工具按常用程度排了:当前节点在最上面,然后是模型检查,模型扫描在最下面。
- The rail's tools go by how often they are used: Current node first, then Model Check, with the scan last.
- 当前节点的参数卡清爽了:来源不再一条一行,改成按工作流、搭配、图片、这里存的分开的小标签,同一渠道几条合成一个,点标签直接过去,几条的话先列出来。
- 当前节点现在也列出搭配里同类节点的数值,可以对比和套用;普通搭配给提示词框,节点结构搭配给每个框。
- Current node's cards are easier to read: where a set came from is a chip per channel (workflow, combo, picture, saved here) instead of a line per source, a press goes there, and several in a channel are listed first.
- Current node now lists the values combos hold for that node type, to compare and put in: a plain combo's prompt on a prompt box, every box of a node structure.
- 给 AI 挑模型时和模型页一样好找了:类型用平常的叫法,有基础模型的分类条,版本和名字分开写,同一模型的几个版本合成一张卡,点版本就能换。
- Picking models for the AI works like the Models page: types by their usual names, base model chips, the version apart from the name, and a model's versions on one card, changed from its version.
- 工作流页多了“官方示例”:文生图、两段放大、图生图、局部重绘,打开时自动选一个你已有的模型。两段放大有 SDXL / SD 1.5、Anima 和推荐的 Anima · Tile 锁图三个版本,缺节点包或模型的版本会写明缺什么。新装的人和 AI 都有现成路线可用;AI 没有合适的已存工作流时会用它们。
- The Workflows page has "Official examples": text to picture, two passes, picture to picture and repainting an area, each opening on a model you already have. Two passes comes for SDXL / SD 1.5, for Anima, and as the recommended Anima held by Tile; a version says what pack or model it lacks. New installs and the AI have routes to start from; the AI gets them when no saved workflow fits.
- 可以自己选做法了:在“做法”里点“这次用它”,只对当前任务生效。AI 会收到,这一类活 AMB 直接按你选的搭,AI 想用别的也不行。
- You can pick the way yourself: "Use it this time" in Ways, for the current task only. The AI is told, and Anomalous builds your pick for that kind of job, whichever one the AI asks for.
- 点“做法”马上出来,不再每次重画:打开 AI 工作台时就提前读好,再点时停在你上次看的位置,展开的详情也还在;开关一个做法也不会跳回顶部。
- Ways opens at once and no longer draws itself twice: it is read when the AI workbench opens, and comes back where you left it, details still open; turning a way on or off no longer jumps to the top.
- 新任务的页面清爽了:只留一句发给 AI 的话和一个复制按钮。要先交代的话和图都放下面的留言框,不再有重复的两行。连过 AI 以后也不再看到“第一次用?”;从没连过时,先告诉你怎么连。
- The new task page is plain now: one sentence for the AI and a Copy button. What to say first and pictures go in the message box below, with no second place for them. Once an AI has connected, "First time?" is gone; until then, how to connect comes first.
- AI 工作台的任务列表:一条线上的任务排在一起,后续任务缩进挂在最早的任务下面,一眼看出谁接着谁。每个任务行上多了 ⋯,改名、隐私模式、从列表移除不用先打开任务。移除中间的任务时,线不会断。
- The AI workbench's task list keeps a line of tasks together: follow-ups hang under the first task, so you see what goes on from what. Each task row has a ⋯ for rename, privacy mode and remove, without opening the task first. Removing a task in the middle does not break the line.
- 存档条和存档页上的按钮按一下就管用:刚在提示词框里打完字、刚改完次数,或者运行还在跑的时候,第一下不再被吃掉;运行途中正在输入的次数也不会被清掉。改完次数马上点运行,也一定按新的次数跑。次数没存上时会提示,框里退回实际的次数。按住存档页的图时刚好出了新图,这一下也算数,新图等松手再排进来。
- The controls of the saves bar and the Saves page answer the first click: right after typing in a prompt box or changing a count, or while runs go, the click is no longer lost, and a count being typed stays. Run right after changing a count goes by the new count. A count that could not be saved says so, and the box goes back to the real one. A press on a picture on the Saves page counts even if a new picture arrives during it; the new one joins after you let go.
- **插入 LoRA 可以一次选多个**:选择窗口里点卡片加入,再点一下取消,卡片上的数字就是接上去的顺序,线自动连好,Ctrl+Z 一次全部退回。默认用 ComfyUI 自带的节点串成一串;装了 rgthree 或 Easy-Use 时可以改放进一个 Power Lora Loader 或 LoRA 堆叠节点,会记住上次的选择。
- 在采样器前面插入 LoRA 时,如果前面已经有一个 LoRA,新的 LoRA 也能接上 CLIP 线了。
- **Insert LoRA takes several at a time**: in the picker, click cards to add them and again to drop one; the number on a card is the order they go in. They are wired for you, and one Ctrl+Z takes them all out. They chain as ComfyUI's own nodes by default; with rgthree or Easy-Use installed they can go in one Power Lora Loader or LoRA stack, and the choice is remembered.
- A LoRA inserted before a sampler now gets the CLIP line even when another LoRA already stands there.
- **操作手感更顺了**:打开和关闭窗口、切换页面都有短暂的过渡,左边栏的高亮会滑到新页面,切换侧边停靠和全屏时窗口平滑过去,不再每一帧重排页面。系统开了“减少动态效果”时这些都关掉。
- **Moving around feels smoother**: the window and its pages come and go with a short transition, the rail's highlight slides to the new page, and going between docked, floating and full window glides there instead of laying the page out on every frame. All of it stays off when the system asks for reduced motion.
- **侧边停靠的宽度可以拖了**:按住窗口右边缘左右拖,最窄 480,最宽给画布留 240,宽度会记住,双击边缘恢复原来的 660。窗口变小时也会给画布留位置。各页面都按 480 宽走过一遍:模型页的关闭按钮不再被挤出去,工作流的搜索框放不下时换到第二行,图库拖宽后自动加列。
- 窗口窄时提示词页不再只占上半截,词卡和提示词框上下排好。
- **The docked window's width can be dragged**: hold its right edge and drag, from 480 up to leaving 240 of the canvas. The width is remembered, and a double-click on the edge returns to the usual 660. A smaller browser window leaves room for the canvas too. Every page was gone through at 480: the Models page keeps its close button, the Workflows search moves to a line of its own when there is no room, and the gallery adds columns as it gets wider.
- In a narrow window the Prompts page no longer fills only its top half: the cards and the prompt boxes stack.
- **工作流卡的“参数方案”改叫“预设”**,详情页也重新排了:左边那一栏去掉,从上往下是 **流程图**、一排 **预设标签**、选中那个预设的内容。流程图只画结构,格子都很小:最下面一排按出图顺序画出每一段(出底图、接着画、放大重画),后面接 ColorMatch 这类后处理和保存;上面几排是底模、LoRA 和 Tile 锁图这类控制模型,每个横跨在它管的那几段上面,一眼看出哪个模型给哪几段用。这次不跑的一段画成虚线,写着“不跑”;没用上的模型也是虚线。步数、强度这些数字放在鼠标停上去的提示里;点一格,图下面列出这个节点的全部参数,改了的写成“默认 → 这个预设”,虚线的那格会告诉你哪个预设会用上它,点一下就切过去,× 收起。图下面的标签:默认参数、每个预设(写着和默认有几处不同)、“+ 新预设”(把当前画布存成新预设,或照着正在看的这个新建),最右边是全部出图和版本;选中的标签连着下面的内容,往下翻时这排标签一直停在顶上。选一个预设,它和默认参数不一样的格子在图上标成 **橙色**(只是不跑的那段换了模型不算改动)。“交给 AI”挪到了预设旁边,交的就是你正看着的那个预设。AI 也能用预设了:`list_workflows` 会列出每张卡的预设,写成“工作流名 / 预设名”;`run_workflow` 带上它,AMB 先打开工作流,再像点“应用到当前画布”一样套上那个预设,然后填题材、种子出图。AI 只要选哪个预设,改数值的事由 AMB 做。
- **A workflow card's parameter sets are now presets**, and its details are laid out anew: no left column; from the top, the **flow**, a row of **preset tabs**, and the preset chosen. The flow is small and shows only the structure: a bottom row of the stages in the order they run (base picture, goes on, enlarge and redraw), then finishing nodes such as ColorMatch and the saves; above them a row each for the models, the LoRAs and control models such as a Tile LLLite, each a bar over the stages it feeds, so which model works on which stage shows at a glance. A stage these values do not run is dashed and says "not run"; an unused model is dashed too. Steps, strength and the like are in a box's tooltip; a click lists the node's values under the flow, changed ones as "default → this preset", and a dashed box names the presets that use it, one click away (× closes it). The tabs: the default values, each preset (with how many values it changes), "+ New preset" (save the canvas as one, or copy the one shown), and at the end all pictures and versions; the tab chosen joins the panel under it, and the row stays in view as you scroll. Pick a preset and what it changes from the default turns **orange** in the flow (a model swapped only in a stage that does not run is no change). "Hand it to the AI" now sits beside the preset and hands the one shown. AI apps can use presets too: `list_workflows` lists each card's as "workflow / preset"; with that name `run_workflow` opens the workflow, applies the preset as its Apply button does, then fills in the subject and seed. The AI only picks the preset; Anomalous changes the values.
- 放大做法从 6 个合成 3 个:**放大**(1.25 倍轻轻重画)、**Tile 锁图放大**、**分块放大**(2 倍)。Tile 锁图放大合并了原来的平铺放大、Anima Tile 放大细化和双模型放大:放大那一段有自己的一份模型,标题是 “Upscale model”,默认和底图同一个模型、LoRA 一起带上(AMB 把画布上的模型和 LoRA 那一串复制一份);想换第二个模型(更稳、换皮肤质感)只要换这一格,LoRA 照样在。Anima 上走你验证过的那条路线:潜空间放大 1.22 倍,Tile LLLite 锁图,降噪 0.85。迭代放大去掉了(要 Impact Pack,和上面几个重叠)。局部重绘对 AI 只算一项,AMB 按你装的节点包挑裁切缝合、LanPaint 或官方节点;AI 也不再看到全部做法的名字,只按用途要。怎么用写在按需 skill **分阶段出图的思路** 里(见下面)。
- The upscale ways went from six to three: **Upscale** (1.25× with a light redraw), **Upscale held by Tile** and **Tiled upscale** (2×). Upscale held by Tile takes in the tile upscale, the Anima Tile detail upscale and the second-model upscale: the redraw has its own copy of the model, titled "Upscale model", the base's model with its LoRAs by default (Anomalous copies the canvas's model line); a second model (steadier, other skin or texture) is one swap of that loader, the LoRAs staying. On Anima it is your tested route: a 1.22× latent enlargement, a Tile LLLite holding it, denoise 0.85. The iterative upscale is gone (it needed Impact Pack and overlapped the others). Repaint counts as one for the AI, Anomalous picking crop-and-stitch, LanPaint or the built-in nodes by the packs you have; the AI no longer sees every way's name, only the uses. How to use them is in the on-demand skill **Drawing a picture in stages** (below).
- 新增 **半途换模型接着画** 做法:AI 把画布上的采样器拆成两段,前面的步数用原模型定构图,后面换另一个模型画完(默认第 21 步换,共 35 步的 60%)。Anima 上直接用验证过的采样设置(dpmpp_2m_sde_gpu + beta);er_sde 拆成两段在两个 Anima 模型上都出了颗粒,AI 会避开(在一个 SDXL 模型上试过是干净的)。SDXL 上也实际跑通过(Nova Anime XL 换 MiaoMiao Harem v2.0)。**换模型精修** 也能用在 Anima 上了:Anima 只换模型文件,文本编码器和 VAE 沿用画布上的。两个都在 6 GB 显卡上实际出过图。
- New way **Switch models mid-way**: the AI splits the canvas's sampler in two, the first steps on the canvas's model for the composition, the rest on another model (by default from step 21, 60% of 35). On Anima it uses the tested sampling (dpmpp_2m_sde_gpu, beta); split er_sde came out grainy on two Anima models, so the AI avoids it there (on one SDXL model it split cleanly). It ran on SDXL too (Nova Anime XL to MiaoMiao Harem v2.0). **Refine with another model** now works on Anima too: only the model file changes, the text encoder and VAE stay the canvas's. Both made pictures on a 6 GB card.
- 新的按需 skill **分阶段出图的思路** 取代原来的「Anima 插画·底图与 Tile 细化」:只讲一张图分几步画的几种路子(同一张图前后段换模型;锁住原图放大重画,可以在这一步顺手换成同类的第二个模型,不用放大完再精修一轮;画完换模型接着修,接几个都行),什么时候必须是同一类模型(沿用前一步提示词和 VAE 的都要同类;自带加载器的单文件换模型精修可以跨类,但要按新模型的习惯改写提示词),以及你选的画风 LoRA 要不要一起挂到第二个模型上(想保持画风就挂,想换画风才不挂)。先出底图给你挑时,AI 只跑第一段,你挑中的那张就是第二步用的底图(见下面)。不写具体数值,数值在做法里;你存过的路线卡和搭配优先原样用,你没选的画风和 LoRA 不加。
- New on-demand skill **Drawing a picture in stages** replaces "Anima illustration: base and Tile detail": the ways to make one picture in passes (different models for its early and late steps; a locked redraw while enlarging, which may switch to a second model of the same family in that same redraw, with no extra refine pass after it; model after model, as many as wanted), when the models must be of one family (any switch reusing the first stage's prompts and VAE; the single-file refine with a loader of its own may cross families, its prompts rewritten for the new one), and whether your style LoRA goes onto the second model too (kept look: yes; a new look: no). The AI shows you the first pass alone, and the base you pick is the one the second pass grows from (below). No 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.
- **修手·先放大再修** 多了一轮 SAM3:放大重画之后,先用 SAM3 按文字找手(一张图最多 8 只),只在贴着手形的轮廓里重画,再用手部检测框补修一遍(参考作者就是这样两轮)。SAM3 的节点是 ComfyUI 自带的,模型(sam3.1,1.7 GB)在做法面板里一点就能下载,下载完核对 SHA-256;没有它时这个做法照旧只跑手部检测那一轮。
- **Fix hands: enlarge, then fix** gains a SAM3 pass: after the light redraw, SAM3 finds the hands by the word (up to 8 in a picture) and redraws each inside its own outline, then the hand detector mends them in their boxes, as the reference author does in two passes. SAM3's nodes come with ComfyUI; its model (sam3.1, 1.7 GB) is one click in the Ways panel, checked by its SHA-256, and without it the way runs the detector's pass alone as before.
- **半途换模型接着画** 可以在换模型那一步顺手放大(1.5 倍):后面的步数在大尺寸上画细节,省掉画完再放大重画的那一轮;AI 加一个放大节点、改两个设置就行,前后用同一个模型也可以。放大前,前一段会先把画面收干净:带着剩下的噪声直接放大,整张图会变成彩色方块。skill“分阶段出图的思路”补了一句。
- **Switch models mid-way** can enlarge at the switch (1.5×): the later steps draw the detail at the larger size, in place of an upscale pass after; the AI adds one node and changes two settings, with the same model or another. The first part finishes its picture before it is enlarged: enlarged with its leftover noise, the picture turned into coloured blocks. The skill "Drawing a picture in stages" says so in one sentence.
- 新增 **存档**:在画布上改任何东西(参数、模型、节点连接),右键节点或画布空白处选“存档当前工作流”,就把整个工作流存成一个存档,画布下方出现存档条,之后按条上的 + 接着存。存档条放在画布区域里,不会盖住底部日志,收起来是贴在画布底边的小把手;条上只放最新的 6 个,更早的在存档页。存档跟着工作流走,切标签就换成那个工作流的;刷新、重启都还在,备份也带上它们。点存档的数字回到那一步,就在当前标签页,Ctrl+Z 能回到回退之前,也可以接着改出新的分支。每个存档设好跑几次,种子选 **随机**(每次换新,不管节点原来是固定还是随机)或 **锁种**(每轮一个种子,所有存档共用,公平对比),按 **运行**:AMB 先让 ComfyUI 检查每个存档,有问题(断线、模型没了)就一次都不排,并说明是哪个存档;没问题就按顺序全部排进队列。跑的是副本,你的画布和上面的种子都不动,可以接着改。条上的 **看图** 打开 **存档页**(“工作流”页顶上的 **存档**):所有存档按画布分块,标签关了、文件删了的也在,默认只展开当前画布。每个存档一张卡,左边是和上一个比改了什么(改了参数还是节点变了),右边是它出的图,放不下的折叠成最后一格 **+N**,点开是这个存档的图库,操作和图库页一样。点图看大图,**查看参数** 打开参数页,左右键在这个存档的图里切换。块顶上的 **运行** 按顺序跑这个画布的存档,再点别的块就排在后面,画布关了也能跑;每个存档也能单独 **运行**,多跑几张,每次换新种子。**收藏为工作流** 把存档存成一张新的工作流卡,之后互不影响;⋯ 里能回到这里、在新标签页打开(画布开着算一条新分支,画布没了这组存档接着用)或删掉(进回收站,图不受影响)。
- New **saves**: change anything on the canvas (values, models, how nodes connect), right-click a node or the empty canvas and choose "Save this state", and the whole workflow is kept as a save in a bar under the canvas; the bar's + keeps the next ones. The bar sits in the canvas's area, clear of the logs under it, and folds to a handle on its bottom edge; it holds the newest six, the rest are on the Saves page. Saves go with their workflow: another tab shows its own, and they stay through a reload or a restart and go into backups. A save's number goes back to that state on the same tab, Ctrl+Z returns to where you were, and you can branch from it. Set each save's runs, pick **Random** seeds (new every run, whatever the nodes are set to) or **Locked** (one seed a round, shared by all saves, for a fair comparison), and **Run**: ComfyUI checks every save first, and a broken link or a missing model stops it before anything is queued, naming the save; else all of it is queued in order. Copies run, so your canvas and its seeds stay as they are and you can work on. **Pictures** on the bar opens the **Saves** page (**Saves** at the top of the Workflows page): every save, by canvas, those of closed tabs and deleted files too, only the open canvas's unfolded. Each save is a card, what changed from the save before (values or nodes) on the left, what it made on the right, those that do not fit folded into a last **+N** tile that opens the save's pictures as gallery cards, working as on the Gallery page. A picture opens large, and **View Parameters** opens its parameters, the arrow keys stepping through that save's pictures. **Run** at the top of a part runs that canvas's saves in order, and another part's goes in after it, closed canvases too; a save can also **Run** alone for more pictures, a new seed each. **Keep as a workflow** makes a new workflow card of a save, and the two go their own ways; ⋯ goes back to it, opens it on a new tab (a branch while its canvas is open; with the canvas gone the saves go on there) or removes it (to the Recycle Bin; its pictures stay).
- 侧边停靠和窄窗口下,AI 工作台不再是一排看不懂的图标:任务上方是一排带字的标签,时间线、展台、节点结构、决策、素材、成果、做法,一点就切。“在右侧看”这类放不下侧栏时说不通的字也改了。任务列表是从左边盖上来的抽屉,后面露出当前任务,一看就知道是会话记录;按 Esc 先收起它,再按才关窗口。
- 提示词页在宽窗口下铺满:左边词卡更宽,正面和负面两个框并排,占满高度,不再是两条细框加一大片空白。工作流的存档页在宽窗口下卡片并排,顶上写着有几个画布、几个存档。
- With the browser docked or in a narrow window, the AI workbench no longer hides behind a row of icons: labelled tabs over the task lead to the timeline, Stage, Node map, Decisions, Materials, Results and Ways, and words such as "on the right" went where there is no right side. The task list is a drawer over the task shown, so it reads as the records of conversations; Esc puts it away first, and closes the window after that.
- The Prompts page fills a wide window: wider cards on the left, the positive and negative boxes side by side and full height, instead of two thin boxes over empty space. The Saves page puts its cards side by side there, and says how many canvases and saves there are.
- **AI 工作台的标题栏清爽了**:固定两行,常用的只留运行状态、改副本和切到副本,改名、隐私模式、接着哪个任务、移除都收进 ⋯;侧栏打开时也不再挤成四五行。时间线上面的阶段和本次成果并成一行,时间线多露出一截。出过错的任务在列表里不再整行标红,AI 试错的那一步也不再是一片红字。**新任务** 一点就开,不再先弹一个输入框。修好“做法”面板里显示成内部代号的标题。
- **The AI workbench's task head is tidier**: two lines however wide, keeping the run status, the canvas choice and the way to the copy; rename, privacy mode, what it goes on from and remove are in ⋯, and an open side column no longer squeezes it into four or five lines. The stages and what the task made share one line over the timeline. A task with a failed step is no longer red in the list, and a step the AI got wrong is a mark, not a wall of red. **New task** opens at once, with no box to fill in first. A heading in the Ways panel no longer shows its internal name.
- 你给任务挑了两个主模型时,AI 不用自己琢磨怎么分工:AMB 准备好几种用法问你(比如“第一个出底图,第二个用 Tile 放大重画”“第一个画前面,第二个接着画完”“只用第一个一次出图”),你存过同时用这两个模型的卡时,它排第一并标推荐。你选好后,AMB 在新标签页里把整条路线搭好,LoRA 也挂到每个模型上,AI 只写提示词。AI 看模型时还能看到这个模型在你哪些卡里用过、每张卡是什么路线。
- 关卡里新增 **新路线**:AI 不用现成的卡和做法、自己往画布上放节点时,默认提醒一次“这条路线还没验证过,跑完看看效果,好的话存成卡”;选“先问我”,每个任务要先等你点头。
- With two main models picked for a task, the AI need not work out who does what: Anomalous prepares the ways to use them and asks you ("the first draws the base, the second redraws it enlarged under Tile", "the first draws the early steps, the second finishes", "only the first, in one pass"), a card you saved with both on top and recommended. Once you choose, it builds the whole route on a new tab, the LoRAs on each model, and the AI only writes the prompt. The AI also sees which of your cards use a model and each card's route.
- **New routes** in the Gates window: when the AI places nodes itself instead of using a card or a way, it reminds you once that the route is untried: see how the run turns out and save a card if you like it; with "Ask me first" it waits for your OK once a task.
- 新增做法 **出图**:空画布也能起步。AI 说要出图,AMB 就用你给任务挑的模型(没挑就用 AI 指定的)搭好出图的节点:SDXL 一类是一个 checkpoint;Anima 是模型、文本编码器、VAE 三个文件,AMB 自己在你的模型文件夹里找到后两个。设置用验证过的那套(Anima:35 步、dpmpp_2m_sde_gpu、beta、CFG 4.4、832×1216,就是你两段放大卡的底图那一段)。画布上已经有出图流程时不会再叠一套。之后要放大或换模型,再按用途加做法。
- New way **Make a picture**: an empty canvas is a start too. When the AI wants a picture, Anomalous builds its nodes on the model you picked for the task (else the one the AI names): one checkpoint for SDXL and its kin; for Anima the model, its text encoder and VAE, the last two found in your model folders. With the tested settings (Anima: 35 steps, dpmpp_2m_sde_gpu, beta, CFG 4.4, 832×1216, the base pass of your two-pass card). A canvas already making a picture gets no second one. Enlarging or another model after it are ways by their use.
- AI 看你存的工作流卡时,每张卡多一行“路线”:分几段、每段用哪个模型、在哪放大、有没有 Tile 这类控制、最后接了什么(比如“2 段:出图 35 步(Harem 1.5)→ 放大 1.22 倍,用 Tile 在 Realskin 1.3 上重画 → ColorMatch”),不打开卡也知道它是一段出图还是两段放大,不会拿单段卡去做两段的活。
- When an AI lists your saved workflow cards, each now has a route line: its passes, the model of each, where it enlarges, control such as a Tile, and what comes after ("2 passes: base 35 steps on Harem 1.5 -> enlarge 1.22x, redraw on Realskin 1.3 with Tile -> then ColorMatch"), so it tells a one-pass card from a two-pass one without opening it.
- **先出几张底图给你挑**:分几段出图时,AI 可以只跑第一段(底图),一次几张,每张换个种子。这些底图只是预览,不存进输出文件夹,你画布上的工作流也一点不动。跑完 AI 工作台里自动弹出 **挑一张底图**,你点 **就选这张**,只有这一张存进输出文件夹(和卡平时存底图的地方一样),种子也记住。AI 接着跑整张卡时:像“两段放大”这种底图画完再交下去的卡,第二段直接读你挑的那张图,底图不再重画(6 GB 显卡上整张从约 190 秒变成约 110 秒);“半途换模型”交接的是画到一半的东西,就按记住的种子重画,跑完 AMB 再拿它和你挑的那张比一比,对不上就告诉你和 AI。预览存在 ComfyUI 的临时文件夹,ComfyUI 重启就清掉:没挑之前重启了,点的时候会告诉你那张的种子,让 AI 重画。
- **Bases to pick from**: for a picture in passes, the AI can run the first pass (the base) alone, a few at once, each with its own seed. They are previews only, nothing goes into the output folder, and the workflow on your canvas stays as it is. When they are done, **Pick a base** opens in the AI workbench; **Take this one** keeps only that one in the output folder (where the card saves its bases) and holds its seed. When the AI then runs the whole card, a card that hands on the finished base (the two-pass upscale) has its second pass read the picture you picked, and the base is not drawn again (on a 6 GB card the run went from about 190 s to about 110 s); the mid-way switch hands on an unfinished picture, so its base is drawn again from the held seed, and when that run saves its base Anomalous compares it with yours and tells you and the AI if they differ. Previews live in ComfyUI's temp folder, emptied when ComfyUI restarts: picked after a restart, the panel tells you the seed to draw it again.
- 跑完的图下面新增 **效果好 / 不太行** 两个按钮,点了直接告诉 AI(点“不太行”它就换做法、设置或提示词再跑),再点一下收回。**关卡** 里新增 **第三方节点**:选“只用做法里的”,AI 自己加节点时只能用做法里搭好的组合和你给任务挑的,ComfyUI 自带的节点照常能用。等不到回答时设成按推荐的继续,挑图面板里 AI 推荐的那张也会替你选上。
- Under a finished run's pictures, **Turned out well / Not quite** tells the AI at once (with "Not quite" it changes the way, a setting or the prompt before running again); a click again takes it back. The **Gates** window has a new **Custom nodes** row: with "Only the ways'" the AI may add only the nodes of the ready-made ways and what you picked for the task; ComfyUI's built-in nodes stay allowed. With "go on with the recommended" for unanswered questions, a picks panel takes the picture the AI recommended too.
- 新增 **隐私模式**:任务标题栏的 **🔒** 一点,这个任务的**图和提示词原文**都不会发给 AI。图一张都不给(放大看图、图片信息、跑完的预览、你递给它的图、AI 软件里任务卡片上的图都一样),它只知道出了几张、成没成功;提示词和图片文件名换成 **[P1]**、**[img1]** 这样的代号:画布上、图片信息里、提示词库、搭配、模型的触发词和介绍、你自己改画布的记录里都一样(只有节点的“文字输入”被换掉,模型名、数字、选项照常给,第三方节点也一样)。AI 拿代号去跑图、写提示词、放挑图面板,AMB 再换回原文,所以它照样能干活,你那边看到的也都是原样。要新的提示词时,AI 会放一个 **提示词框**:你在里面写几条提示词、反向提示词,可以从图库选或拖进参考图,原文只存在 AMB 里;框上方还会按画布主模型的大小提醒你(小模型手容易画坏,可以选简单动作或藏手,只是可选)。新增 skill“隐私模式·怎么干活”,隐私任务里的 AI 开工时会被指过去;隐私任务里 AI 只拿得到这一个 skill,**做法** 面板的 skill 一栏也只显示它,关掉隐私后才换回普通的 skill。**关卡** 窗口里设新任务默认开不开;打开、关掉都记在时间线上并告诉 AI。所有工具的结果出门前都过同一道关,测试会在每个工具的结果里埋“暗号”提示词和图片名,确认一个都漏不出去。
- New: **privacy mode**. The **🔒** in a task's header keeps that task's **pictures and prompt text** from the AI. No picture goes (a closer look, picture info, a run's previews, pictures you hand over, the task card in the AI app alike): it hears how many came out and whether they did. Prompts and picture file names become tokens like **[P1]** and **[img1]**: on the canvas, in picture info, saved prompts, combos, models' trigger words and descriptions, and your own canvas changes (only the nodes' text inputs are swapped; model names, numbers and choices stay, third-party nodes too). The AI runs, writes prompts and shows pick panels with the tokens and Anomalous puts the texts back, so it still works and you see everything as it is. For new prompts the AI shows a **prompt box**: you write prompts and a negative one and add reference pictures from the gallery or by dropping them; the texts stay in Anomalous, and above the box a word on what suits the canvas's model (a smaller model often gets hands wrong: a simple pose, or hands hidden, as an option). A new skill, "Privacy mode: how to work", is where a private task's AI is pointed; it is the only skill a private task's AI gets, and the only one the **Ways** panel lists for it, the usual skills coming back once privacy mode is off. The **Gates** window sets it for new tasks; turning it on or off is on the timeline and told to the AI. Every tool's result passes one check on its way out, and a test plants canary prompts and picture names in every tool's result to make sure none gets through.
- AI 可以一次排一串提示词:跑图时给几条提示词(最多 8 条),AMB 按顺序一条条填进去排队,其余设置(比如同一个种子)每条都一样,算同一次跑图,图都在时间线的同一步里。以前要一条条跑、每次一个来回。哪一条排不上就停在那里,前面排上的照常跑完。
- An AI can queue a list of prompts: a run given several prompts (up to 8) fills them in and queues them in order, the other settings (the same seed, say) the same for each, as one run whose pictures share one step of the timeline, instead of a round trip per prompt. One that cannot be queued stops the rest; those queued before it run as usual.
- 同一个模型的几个版本放在一起:扫描认出来自同一个 Civitai 模型页的文件,在模型列表里收成一张卡片,卡片右边有一条带数字的箭头,点一下其余版本就从它右边滑出来、并排挨着,同一个模型的卡片顶上是同一道橙色边;最后一张右边的箭头点一下又收回去。展开后每张卡片的版本名旁边有个图钉,点哪个,收起时就显示哪个版本(封面、名字、插入节点都跟着换)。展开过的模型下次打开还是展开的;版本多的话,类型下面那排最右边的 **展开全部版本** 一下全展开,再点一下全收起。只认 Civitai 的模型编号,文件名再像也不会合在一起;没扫描、Civitai 上找不到的模型照旧各自一张。模型详情里名字下面一排列出这里有的几个版本,点一下就过去;再下面的 **Civitai 上的全部版本** 点了才联网,列出这个模型页的每个版本(底模、日期、大小),这里有的可以直接打开,没有的点 **下载** 放到这个模型旁边,下载完按 SHA-256 核对;名字和已有文件重了,就在名字后面加上版本名,不覆盖。
- Versions of one model sit together: files a scan found on the same Civitai model page fold into one card in the model list with a numbered arrow on its right edge: a click slides the other versions out beside it, side by side, the cards of one model under one orange top edge, and the arrow on the last folds them back. Open, each card has a pin by its version name: the pinned version is the one the folded card shows (its cover, name and Add node too). A model left open stays open; **Open all versions**, at the right end of the row under the types, opens them all, and again folds them all. Only Civitai's model id groups them, never a similar file name; models not scanned or not on Civitai keep a card each. A model's details list the versions here under its name, one click each, and **All versions on Civitai**, asked only when pressed, lists every version of the model page (base model, date, size): the ones here open, the others **Download** next to this one, checked by their SHA-256; a taken file name gets the version's name added, nothing is replaced.
- 新增 **skill**,和做法并列:做法是 AMB 替 AI 搭好的节点,skill 是 AI 遇到某类活时自己去取的建议,不搭节点。第一个是“写提示词·动作适合模型”,按画布主模型的大小给建议:AMB 读模型文件头数出画图部分的参数量(压缩过的 GGUF 也一样,不看文件大小),100 亿参数以上的大模型(Flux.1、Flux.2、千问图像这类)手和复杂动作画得好,AI 照画面需要写,不藏手;小模型(SD1.5、SDXL、Illustrious、Anima 这类)没指定姿势就选画得清楚的动作,手不是重点时可以自然遮挡或构图不露手,但这只是可选,不是规定;认不出模型时两种情况都告诉它。你指定的姿势和你给的提示词照原样。AI 开工时只看到 skill 的名字和一句话,要写提示词时才去取全文,工具说明里只留一句提示,给 AI 的工具清单不变长。AI 工作台右边的**做法**面板最下面列着 skill,每个有开关,关掉的 AI 就拿不到。AI 取了哪个 skill、按哪一档给的建议,时间线上都有一行 **Skill**;之后那次跑图下面也写着“这次跑图前参照了:……”。
- New: **skills**, beside the ways. A way is nodes AMB builds for the AI; a skill is advice the AI fetches when a kind of job comes up, and it builds nothing. The first: "Writing prompts: poses the model can draw", by the size of the canvas's main model: AMB counts its drawing model's parameters from the file's header (a compressed GGUF alike; the file size is not the yardstick). A large one, 10 billion or more (Flux.1, Flux.2, Qwen-Image and the like), draws hands and hard poses well, so the AI writes what the picture wants with no hiding; on a smaller one (SD 1.5, SDXL, Illustrious, Anima…), with no pose asked for, it picks one the model draws cleanly, and hands that are not the point may be hidden naturally or framed out, as an option, not a rule; a model it cannot tell gets both cases. Poses you ask for and prompts you give stay as they are. At the start of a task the AI sees only each skill's name and a line, and fetches the advice when it writes a prompt; the tool descriptions only point at it, so the tool list AI apps hold does not grow. The skills are at the bottom of the **Ways** panel on the right of the AI workbench, each with a switch: the AI can't fetch the ones turned off. Each skill the AI fetches is a **Skill** row on the timeline, with the form its advice came in, and the run after it says "Went by before this run: …".
- 当前节点里的 **⇄ 对比** 只比这个节点了:以前会把整个工作流的参数都列出来。现在只列这个节点存下的值和画布上现在的值哪里不一样(提示词按标签对比),每张参数卡片都能对比。
- **⇄ Compare** in the current-node panel now compares that node alone: it used to list the whole workflow's values. It shows how the saved values differ from the node's values on the canvas (prompts tag by tag), on every saved set.
- **更换模型** 时弹出的模型卡片和模型页的一样了:封面(视频封面先显示第一帧,指着才播放)、底模角标、版本、好认的名字和文件名。以前大多是空白卡片、只有文件名。同一模型的几个版本仍然各占一张卡,不折叠。
- The model cards in **Change model** are now the models page's: the cover (a video's first frame until pointed at), the base model badge, the version, the readable name and the file name. Most used to be blank with the file name only. Versions of one model still get a card each, not folded.
- 修正点击穿透:当前节点 →「查看全部」的历史生成图库往下滚以后,点“关闭图库”会点到滚上去的图片。
- Fixed a click passing through: in the generated-pictures gallery (current node → View all), once scrolled, “Close gallery” opened the picture scrolled up under it.
- 修正 AI 工作台在**侧边栏模式**下标题栏放不下:右上角的放大、停靠、关闭按钮被挤出窗口。现在按 AMB 窗口自己的宽度(不再按整个浏览器的宽度)收起标签文字,只留图标。
- Fixed the AI workbench's title bar when **docked**: the maximize, dock and close buttons were pushed out of the window. Its tabs now fold to icons by the width of the Anomalous window, not of the whole browser.
- 修正模型页 **整理** 的几处误判:装了 ComfyUI-Anima-LLLite 节点包时,放在 controlnet 里的 Anima LLLite 不再算放错(这个节点包就从 controlnet 读,挪走反而用不了;没装才建议挪到模型补丁);带 LoRA 层的 IP-Adapter(比如 Anima IP-Adapter)不再被认成 LoRA;同一个文件在 controlnet 和 model_patches 各一份、两边各有节点在读时,不再算“重复、白占硬盘”,也不再建议删一份,只在下面写明两处都在用。
- 模型类型识别又查了一遍:拿 ComfyUI-Manager 模型清单里 315 个模型的真实文件头逐个核对,修掉了会认错的几种:混元 DiT 整合包被当成 VAE、LBM 打光模型被当成 VAE、SUPIR 被当成 ControlNet、XLabs 的 Flux LoRA 和 IP-Adapter 被当成扩散模型、完整 CLIP(文字+图像)被当成文本编码器、PhotoMaker 被当成 LoRA、只存差值的 LoRA 被当成扩散模型、去掉 VAE 的整合包被当成扩散模型。两种加载器从不同文件夹读的文件(SUPIR、完整 CLIP)只标“不确定”,放哪边都不报放错。只有模型主体的文件放在 checkpoints 里也不再算放错:ComfyUI 的大模型加载器会把它当扩散模型读进来。新认得:diffusers 格式的扩散模型(IC-Light、可图、Lotus)、Stable Cascade、SDXL 的 T2I-Adapter、RCAN / SPAN / 小型 Real-ESRGAN 放大模型。核对后 315 个里 301 个和清单一致,剩下的要么是有意标的“不确定”,要么认不出(LTX 的潜空间放大、Stable Cascade 的 VAE 这类),认不出的照旧不下结论。扫描页和模型卡片上原来写“看起来不是绘图模型”,其实只是认不出,现在改成 **认不出类型**;规则改了以后,存下的识别结果会自动重读一遍。
- Model types were checked again, against the real headers of 315 models in ComfyUI-Manager's list, and the layouts it misread are fixed: a Hunyuan-DiT checkpoint and LBM's relighting model taken for VAEs, SUPIR for a ControlNet, XLabs' Flux LoRAs and IP-Adapter for diffusion models, a whole CLIP (text and vision) for a text encoder, PhotoMaker for a LoRA, a LoRA of weight differences and a checkpoint saved without its VAE for diffusion models. Files two loaders read from different folders (SUPIR, a whole CLIP) are only "not sure", never misplaced in either. A lone drawing model in checkpoints is no longer misplaced: ComfyUI's checkpoint loader reads it as a diffusion model. Newly told: diffusers diffusion models (IC-Light, Kolors, Lotus), Stable Cascade, SDXL T2I-Adapters, RCAN / SPAN / compact Real-ESRGAN upscalers. Checked again, 301 of the 315 agree with the list; the rest are "not sure" on purpose or not told (LTX latent upscalers, Stable Cascade's VAEs), which still get no verdict rather than a wrong one. The scan page and model cards said "looks like no drawing model" when it only could not tell: now **Kind not told**. Answers kept from older rules are read again.
- The models page's **Tidy** misjudged a few files: an Anima LLLite in controlnet is no longer misplaced while the ComfyUI-Anima-LLLite pack is loaded (it reads them from there; moving one broke it; without the pack it still suggests model patches); an IP-Adapter carrying LoRA layers (the Anima IP-Adapter) is no longer taken for a LoRA; one file in both controlnet and model_patches, each read by a node, is no longer "identical, wasting space" with a copy to delete: a note says both are in use.
- 跑图前的尺寸提醒放宽一档:6GB 显卡采样到约 420 万像素才提醒(以前 210 万),12GB 约 840 万。今天在 6GB 的笔记本显卡上实测,400 万像素一次约 100 秒,并不吃紧;以前的门槛会让 AI 动不动就问你要不要停。解码大图的提醒不变。
- The size warning before a run is less eager: on a 6 GB card from about 4.2 megapixels (2.1 before), 8.4 on 12 GB. A 6 GB laptop card sampled 4 megapixels in about 100 s today; the old line had the AI ask you to stop runs that were fine. The warning for decoding a large picture is as before.
- 新做法 **修手·先放大再修(备选)**:你递给 AI 一张手画坏的图,AMB 先把整张图放大到约 400 万像素(原图越小放得越大:1280×832 这种放 2 倍,已经很大的图不放,最多 2 倍)轻轻重画一遍,让手变大、模型有地方画手指,再自动找出每只手、放大重画后贴回去。这是照着参考作者的做法,在 6GB 的笔记本显卡上一组组试出来的:只修手不先放大,大约一半能成;先放大 1.5 倍再修,大约 4 张里 3 张能成;放大 2 倍,同样的种子手指更清楚。Anima 模型上有 Anima Turbo LoRA(做法面板里可以下载)时只跑 10 步,1280×832 的图约 100 秒;没有也能用,慢三倍左右。AI 会一次出 4 张,先自己放大看手,把手指数不对、粘在一起、大小不对的去掉,都不行就换种子再来,最多两轮,再让你在只放大手那一块的面板里挑。整张图会有些细小变化(脸、水花),构图不变。实测不如 Codex 用 FLUX-Fill 每只手单独裁一块重画的办法,所以只作备选:AI 手里有那个办法时先用那个。
- New way **Fix hands: enlarge, then fix (fallback)**: from a picture with bad hands, Anomalous enlarges the whole picture to about 4 megapixels (the smaller it is, the more: 2× for 1280×832, not at all for a large one, at most 2×) and redraws it lightly, so the hands grow and the model has room for fingers, then finds every hand and redraws it larger. Taken from the reference author's workflows and tried step by step on a 6 GB laptop card: the hand pass alone fixed about half the tries, enlarging 1.5× first about 3 in 4, and 2× gave sharper fingers in the same seeds. On an Anima model with the Anima Turbo LoRA (downloadable in the ways panel) it is 10 steps, about 100 s for 1280×832; without it, about three times slower. The AI makes 4 tries, checks the hands itself first, drops wrong finger counts, fused fingers and wrong sizes, tries new seeds at most twice more, and lets you pick in a panel zoomed on the hand. Small things change over the picture (faces, splashes); the layout stays. In the user's tests it did worse than Codex's FLUX-Fill redraw of one crop per hand, so it is a fallback: the AI uses that one when it has it.
- **修一处(比如一只手)更容易成**:从 Codex 这几天修手的试验看,重画的力度太大时,新画的手会撑满你圈的范围,于是手越修越大。现在“局部重绘”默认力度降到修东西用的 0.45(官方节点那块 0.5),AI 也会被告知:圈得贴着手、保持低力度、一次出 4 张,再用一个**只放大手那一块**的挑选面板给你挑,挑完用同样放大的前后对比给你看。展台上的“挑一张”和前后对比滑块都能只显示每张图的同一小块。
- **Fixing one spot (a hand) works more often**: Codex's hand tests showed that with a strong repaint the new hand fills the whole marked area and grows each time. The repaint block's strength is now 0.45 by default, a fix (0.5 for the built-in one), and the AI is told to mark tightly, keep it low, make 4 tries and let you pick in a picks panel **zoomed on the hand**, then show before and after zoomed the same. The stage's picks and slider can show the same small part of every picture.
- **AI 交给“加载图像”的图不再被误报“没选图片”**:AI 拿输出文件夹里的图去重画时,AMB 以前在节点里写“名字 [output]”,ComfyUI 重启、运行记录清空后,新版前端会把它标成“缺少图片”,其实图还在。现在和图库右键“放到画布”一样,先把图复制一份进 input 文件夹(同一张图只存一份,透明部分保留)再填进去。ComfyUI 页面上标红的“缺少图片/视频”,AI 用 get_situation 看现状时也能看到,并知道怎么补。
- **Pictures the AI gives a Load Image are no longer flagged missing**: for an output picture Anomalous used to write "name [output]", which ComfyUI's newer page marks as a missing file once its run history is gone (after a restart), though the file is there. Now it is copied into the input folder first (one copy per picture, transparency kept), as the gallery's "Put on the canvas" does. Files ComfyUI's page marks missing also reach the AI in get_situation, with how to fix them.
- **让 AI 一次出几张时,每张都不一样**:以前种子设成“固定”时,AI 说一次出 4 张,ComfyUI 会把 4 次当成同一个任务,只出 1 张。现在一次出几张时,固定的种子会一张一张往上加(123、124、125…),出完画布上的种子还是原来的数、还是“固定”。修手、修脸这种看运气的活,可以一次出 4 张让你挑。
- **A batch of tries from the AI gives different pictures**: with a seed set to "fixed", asking for 4 used to queue the same prompt 4 times and ComfyUI made 1 picture. Now a fixed seed counts up for the batch (123, 124, 125…) and is as it was afterwards, still fixed. Hands and faces, which depend on luck, can come 4 at a time for you to pick from.
- 跑图前的尺寸提醒多算了一步:放大模型之后**最后要存多大**。要存出超过约 6400 万像素的图(比如 5120×3328 再放大 4 倍,存出来 20480×13312、PNG 约 300 MB),时间线上会出现黄色提醒,AI 也会收到,建议放大倍数小一点或者从小一点的图开始放大。
- The size check before a run now also counts **what is finally saved** after an upscale model: a picture past about 64 megapixels (5120×3328 upscaled 4x is 20480×13312, about 300 MB as PNG) gets a yellow warning on the timeline, and the AI is told, with the advice to upscale less or start from a smaller picture.
- **超大图不再卡住图库**:几亿像素的图(比如把大图再放大 4 倍,存出来几百 MB),以前缩略图做不出来时,AMB 会把整张原图当缩略图发给浏览器,图库和 AI 工作台一翻到它就卡。现在缩略图在后台单独的进程里做一次就存下来(2.7 亿像素的图约 6 秒),不占 ComfyUI 出图的内存;大到连这样也做不了的,卡片上显示尺寸和大小,不再加载原图;截不出第一帧的视频(文件坏了、格式解不开)也一样,卡片显示文件大小,不再把整个视频发给浏览器。卡片角上标“超大图 · 324 MB”。点开看大图、图片详情、AI 工作台里放大看的,都是缩到 4096 像素的预览,下面写着原图多大,要看原图点“打开原图”(在浏览器新标签页里打开)。图片详情读参数也不再把整张图下载下来。
- **Very large pictures no longer freeze the gallery**: for a picture of hundreds of megapixels (a big one upscaled 4x, hundreds of MB), a card image that could not be made used to be the whole original, sent to the browser every time the gallery or the AI workbench showed it. Now it is made once in a separate background process and kept (about 6 s for 272 megapixels), outside ComfyUI's own memory; one too large even for that shows its size instead, never the original, and so does a video whose first frame cannot be read (a broken file, no decoder). Its card says "Very large · 324 MB". The large view, the image details and the AI workbench's larger pictures show a 4096 px preview with a note saying how large the original is, and "Open the original" opens it in a new browser tab. Reading a picture's settings in its details no longer downloads the whole file.
- 做法按用途合并成 7 个:局部重画、换背景、放大、换模型精修、照着画、修脸修手、图片处理。AI 只说"用途 + 想要哪种"(比如"放大,2x"、"照着画,姿势"),AMB 按你装了什么、画布上是什么模型,自动挑能跑的、效果最好的那一块;没装的照样按节点包列出,AI 能告诉你该装什么。开新任务时 AI 只看到 7 行,做法再多也不会变长。
- The ways are grouped into 7 uses: repaint, new background, upscale, redraw with another model, follow a picture, faces and hands, picture work. The AI names a use and the kind it wants ("upscale, 2x", "follow, pose") and Anomalous picks the best block this ComfyUI can run, by what is installed and the canvas's model; the missing ones are still listed by pack, so the AI can say what to install. A new task's directory is 7 lines, however many ways there are.
- 新做法 **换画风·构图不变**:你递给 AI 一张图,AMB 读出它的轮廓线(ComfyUI 自带的 Canny),用 Union 控制网钉住构图,再按提示词里的新画风整张重画:人物、姿势、位置不变,画法换掉(比如水彩、厚涂、平涂)。实测强度 0.8 只是变亮一点,1.0 才真正换了画风,轮廓线照样守住构图,所以默认 1.0。RTX 4050 6GB、SDXL 832×1216 约 41 秒。
- New way **New style, same layout**: from a picture handed to the AI, its outlines (ComfyUI's own Canny) hold the layout through the union ControlNet while the whole picture is redrawn in the prompt's new style (watercolour, painterly, flat colours): who, pose and place stay. Tried: at denoise 0.8 it only got lighter, at 1.0 the style changed and the outlines still held the layout, so 1.0 it is. About 41 s for SDXL 832×1216 on an RTX 4050 6 GB.
- 新做法 **换模型精修**:出完图以后,换另一个单文件模型(SD1.5、SDXL 各家族之间都能换,配它自己的 LoRA;Anima 这种分三个文件的还不行)把整张图轻轻重画一遍(强度 0.4),另存一张(文件名以 refined 开头)。一个模型的构图加上另一个模型的画风和细节。新模型有自己的提示词节点,先照抄原来的提示词。实测 RTX 4050 6GB、Illustrious 出图 → NoobAI 精修,832×1216 整次约 66 秒。
- New way **Refine with another model**: after a picture is made, another single-file checkpoint (any SD 1.5 or SDXL family, with its own LoRAs; not yet a three-file model like Anima) redraws it lightly (0.4) and saves it beside it (prefix refined): one model's composition with another's style and detail. The new model has prompts of its own, copied from the first. About 66 s for a whole run, Illustrious then NoobAI at 832×1216, on an RTX 4050 6 GB.
- 新做法 **放进另一张图**:你递给 AI 两张图,一张有人物、一张是背景。AMB 抠出人物,把背景缩放裁切成同样大小,贴上人物,再把人物以外的地方轻轻重画一遍(强度 0.5),让脚下的影子、地面和光线接上,背景的样子基本不变。实测 RTX 4050 6GB、SDXL 832×1216 约 40 秒。做法现在可以接第二张图了(`add_nodes` 的 `scene`)。
- New way **Put a character into a picture**: from two pictures handed to the AI, a character and a place, the character is cut out, the place scaled and cropped to its size, the character pasted on, and everything else lightly redrawn (0.5) so shadow, ground and light meet it while the place keeps its look. About 40 s for SDXL 832×1216 on an RTX 4050 6 GB. A block can take a second picture now (`add_nodes`' `scene`).
- 新做法 **换场景·人物不变**:你递给 AI 一张图,AMB 自动抠出人物(inspyrenet-rembg),只重画人物以外的地方,AI 在提示词里写新场景;人物边缘留 6 像素一起重画,接缝更自然。实测 RTX 4050 6GB、SDXL 832×1216 约 30 秒。人物身上的光还是原图的光,换到光线差别很大的场景时看得出来(以后可以加重新打光)。AI 递给做法的图也认 `名字.png [output]` 这种写法了。
- New way **New scene, same character**: from a picture handed to the AI, the character is cut out (inspyrenet-rembg) and only the rest is drawn anew from the prompt's new scene; 6 px of its edge are redrawn too for the seam. About 30 s for SDXL 832×1216 on an RTX 4050 6 GB. The character keeps the old picture's light, which shows in a very differently lit scene (relighting may come later). A picture handed to a block may also be written `name.png [output]`.
- AI 有了现成的做法可以照着用(参考做法):从擅长这些活的工作流里提炼出来的几块节点,AI 说一声用哪块,AMB 就在画布上搭好、接进你的工作流里,一次调用,不用 AI 一个个节点地搭。AI 开工时会收到这份清单,每块一行。
- **局部重绘·裁切缝合**:把圈出的地方连同周围一圈一起裁出来,放大到 1024 重画,再缝回原图,别处一点不动。AMB 顺手把采样强度调到 0.45(修东西的力度:再高,新画的东西会撑满你圈的范围,手会变大),并提醒 AI 圈得贴着要修的东西、把提示词改成只写这块地方(比如“一只手,五根手指”),不然它会把整张图的内容(比如一整个人)画进这一小块里;手和脸一次出 4 张让你挑。需要节点包 ComfyUI-Inpaint-CropAndStitch。
- **局部重绘(官方节点)**:没装上面那个包时用它,只用 ComfyUI 自带的节点。
- **部位细化(脸和手)**:出完图以后找出脸和手,各自放大重画再贴回去,细化后的图另存一张(文件名以 detailed 开头)。需要 Impact Pack 和 Impact Subpack,以及 models/ultralytics/bbox 里的检测模型。
- **局部重绘·LanPaint**:把采样器整个换成 LanPaint 的(步数、CFG、种子照搬原来的),它每一步都多想几遍,新画的部分和周围融合得最好,任何模型都能用,不用专门的重绘模型;代价是慢好几倍。需要节点包 LanPaint。
- **放大**:用放大模型放大到 1.25 倍,再用同样的设置轻轻重画一遍(强度 0.35),细节是真画出来的而不是糊的,另存一张(文件名以 upscaled 开头)。只用 ComfyUI 自带的节点,自动挑你装的放大模型。参考工作流作者也只用 1.25 倍,6GB 显存的卡上 1.5 倍就太吃力了。
- **分块放大**:放大 2 倍,按 1024 的小块逐块重画,多大的图都放得下,比上一个慢。需要节点包 ComfyUI_UltimateSDUpscale。
- **照着姿势画**、**照着构图画**:你递给 AI 一张图,AMB 读出它的姿势(OpenPose)或深度(Depth Anything V2),用控制网引导出图。控制网模型由 AMB 按画布上底模的类型自动挑(SDXL 的配 SDXL 的,同底模的优先),参考图自动复制一份到输入文件夹(只新建,不覆盖)。需要节点包 comfyui_controlnet_aux。
- **迭代放大**:分 3 小步放大到 1.3 倍,每步轻轻重画,比一步到位更稳。用 Impact Pack。
- **双模型放大**:和"放大"一样,但重画换一个更稳的模型(底模放大时容易走样就用它);新加的模型加载器先是同一个模型,AI 按你的意思换。只用自带节点。
- **背景移除**:把一张图的背景抠掉,存成透明 PNG(文件名以 nobg 开头)。需要节点包 comfyui-inspyrenet-rembg。
- **读图出标签**:你递给 AI 一张图,WD14 打标器读出它的 Danbooru 标签,AI 照着写提示词。需要节点包 ComfyUI-WD14-Tagger,第一次用时会下载打标模型(约 1.2GB)。AI 运行后,节点输出的文字(比如这些标签)现在也会带回给它。
- 装了 KJNodes 时,几种放大都会在最后把颜色对齐到原图(参考工作流的做法),防止放大后颜色跑偏;没装也照常能用,面板上写着"装了 KJNodes 会更好"。
- 照着姿势画改用 DWPose 读姿势:二次元人物也认得准,连脸和手指都标(原来的 OpenPose 认二次元常认错)。人物挤成一团、画风很浓的图可能认不出,读出的骨架图或深度图会作为预览给 AI 看,空的它就知道换照着构图画。第一次用时会下载 DWPose 的模型(约 350MB)。
- **平铺放大**:放大到 1.25 倍,重画时用平铺控制网把画面钉住(强度 0.6,到 80% 的步数,参考作者的参数),所以重画强度可以调到 0.5,细节多得多,构图也不跑。SDXL 用 Union 控制网,NoobAI/Illustrious 优先用 NoobAI 的平铺控制网,Anima 用 Anima 的修复 LLLite(较慢)。
- **线稿上色**:你递给 AI 一张黑白线稿,AMB 把线条交给线稿控制网(Union 的线稿模式),AI 在提示词里写颜色和画风,按线稿画出彩色图。
- Union(万能)控制网用之前,AMB 会告诉它这次是姿势、深度、平铺还是线稿。还没扫描过的新控制网按它所在的文件夹(SDXL_1.0、NoobAI……)认底模。做法说缺某个模型时,AMB 先把 ComfyUI 的文件列表刷新一次再找:刚下载到子文件夹里的模型,ComfyUI 自己不重启是看不见的。
- 放大类的块优先用"够用的最小倍数"的放大模型(放大到 1.25 倍用 2 倍模型,比 4 倍模型再缩回来省事)。
- 做法会按画布上的底模换搭法:画布上是 Anima 模型时,照着构图画、照着姿势画改用 Anima 专用的 LLLite 控制网,局部重绘·裁切缝合会加上 Anima 的重绘 LLLite。AI 照样只说用哪块。需要节点包 ComfyUI-Anima-LLLite 和 models/controlnet 里的 anima-lllite 模型。Anima 的 LLLite 比 SDXL 的控制网温和,提示词要和参考图说的一致(几个人、在干什么),AI 会被这样提醒。
- 画布上有好几个采样器时,AI 要指明用哪一个,AMB 不乱猜。
- AI 工作台右边多了**做法**面板:按"要做的事"分组列出所有做法,每块写着能干什么、从哪学来的。缺节点包或缺模型的变灰,写明缺什么,点缺的节点包会打开 ComfyUI-Manager 去搜它。每块有个开关:关掉的,AI 就看不到,硬要用也会被拒绝。AI 开工时只收到开着又能用的做法,其余的只附一行"装了什么还能用什么"。
- 做法面板上每块写着怎么测过的:在哪张卡上(目前都是 RTX 4050 笔记本,6GB 显存)、多大的图、整次运行用了多久;参数也是按这张卡定的(比如放大只到 1.25 倍)。AI 用某块时也会收到这句,能提前告诉你大概要等多久。
- AMB 维护一份做法用到的模型清单(api/block_models.json):每个模型的下载链接(HuggingFace、Civitai、GitHub)、大小、SHA256、许可证和该放的文件夹。每块做法列出它推荐的模型,面板上已有的打勾(改过名的也认得出),缺的点一下就用 AMB 的下载器下载,下完核对哈希。把做法分享给别人时,对方也能照着这份清单拿到一模一样的文件。
- 线稿上色在 Anima 模型上改用 Anima 的线稿 LLLite。
- 每块做法现在是一个单独的数据文件(api/blocks/),里面有它的节点、要的节点包和模型、给 AI 的话、面板上的中英文说明。以后收集到新做法,加一个文件就行。
- AI has ready-made ways of doing things (reference blocks): a few groups of nodes distilled from workflows that do one job well; the AI names one and Anomalous builds it on the canvas, spliced into your workflow, in one call instead of node by node. The AI gets their list, a line each, when it begins a task.
- **Repaint, crop and stitch**: crops the marked area with a margin around it, repaints it at 1024 and stitches it back, the rest untouched. Anomalous sets the sampler's denoise to 0.45 (a fix: higher, the new thing fills the whole mark and a hand comes out too big) and tells the AI to mark tightly, write the prompt for that area only ("a hand, five fingers"), or it paints the whole picture's subject (a whole person) into it, and to make 4 of a hand or face for you to pick. Needs ComfyUI-Inpaint-CropAndStitch.
- **Repaint (built-in nodes)**: for when that pack is not installed, ComfyUI's own nodes only.
- **Face and hand detailing**: after a picture is made, finds faces and hands, repaints each larger and puts it back, saving the detailed picture beside it (prefix detailed). Needs Impact Pack and Impact Subpack and detector models in models/ultralytics/bbox.
- **Repaint with LanPaint**: the sampler becomes LanPaint's (same steps, cfg and seed), which thinks several times a step, so the new part blends in best, with any model and no inpainting model; several times slower. Needs LanPaint.
- **Upscale**: an upscale model enlarges the picture to 1.25×, then a light redraw with the same settings (denoise 0.35) makes the new detail real rather than smeared; saved beside it (prefix upscaled). ComfyUI's own nodes, with an upscale model you have. 1.25× is what the source workflow used; 1.5× is too much for a 6 GB card.
- **Tiled upscale**: 2×, redrawn in 1024 tiles, so any size fits; slower. Needs ComfyUI_UltimateSDUpscale.
- **Follow a pose**, **follow a layout**: from a picture you hand the AI, Anomalous reads the pose (OpenPose) or depth (Depth Anything V2) and steers the picture with a ControlNet it picks to fit the canvas's model (SDXL with SDXL, the very same base first); the picture is copied into the input folder, never over a file. Needs comfyui_controlnet_aux.
- **Iterative upscale**: 1.3× in 3 small steps, a light redraw each, steadier than one jump. Uses Impact Pack.
- **Upscale with a second model**: like Upscale, but a second, steadier model redraws it (for a base model that goes wrong enlarged); its new loader starts as the same model and the AI changes it as you say. Built-in nodes only.
- **Remove the background**: saves a picture with its background cut away as a transparent PNG (prefix nobg). Needs comfyui-inspyrenet-rembg.
- **Tags from a picture**: from a picture you hand the AI, the WD14 tagger reads its Danbooru tags for the AI to write the prompt from. Needs ComfyUI-WD14-Tagger; the first time it downloads its model (about 1.2 GB). What a run's nodes show as words (these tags) now reaches the AI with the run.
- With KJNodes, the upscales match their colours to the original at the end (as the source workflow does), so they don't drift; without it they work as before and the panel says they're better with KJNodes.
- Follow a pose reads the pose with DWPose: anime characters too, face and fingers included (OpenPose often misread them). A crowded or very stylised picture may come out empty; the pose skeleton or depth map is left as a preview for the AI, which then turns to Follow a layout. The first time, DWPose downloads its models (about 350 MB).
- **Upscale held by tile**: 1.25×, a tile ControlNet holding the picture while it is redrawn (strength 0.6 to 80% of the steps, the source's), so the redraw can be stronger (0.5): much more detail, the layout kept. SDXL uses the union ControlNet, NoobAI and Illustrious NoobAI's tile one first, Anima its tile-repair LLLite (slower).
- **Colour a line drawing**: from a black-on-white drawing you hand the AI, its lines steer a lineart ControlNet (the union one's lineart mode) while the prompt says the colours and style.
- A union ControlNet is told what it follows (pose, depth, tile or lineart). A ControlNet not scanned yet goes by the folder it is in (SDXL_1.0, NoobAI…). When a block lacks a model, Anomalous reads ComfyUI's file lists anew once before saying so: ComfyUI does not see a model just downloaded into a subfolder until it restarts.
- The upscales take the least enlarging upscale model that reaches their size (a 2× one for 1.25×, less work than a 4× one scaled back down).
- A way is built for the canvas's model: on an Anima model, Follow a layout and Follow a pose use Anima's ControlNet-LLLite, and Repaint: crop and stitch adds Anima's repaint LLLite; the AI still only names the way. Needs ComfyUI-Anima-LLLite and anima-lllite models in models/controlnet. Anima's LLLite steers more gently than an SDXL ControlNet, so the prompt has to agree with the picture, and the AI is told so.
- With several samplers on the canvas the AI has to say which one; nothing is guessed.
- A **Ways** panel on the right of the AI workbench lists every way by job, what it does and where it came from. Ways missing a node pack or a model are greyed with what they lack; a missing pack opens ComfyUI-Manager searched for it. Each has a switch: one turned off is not offered to the AI, and building it is refused. When it begins, the AI gets only the ways that are on and can run here, with one line on what installing which pack would add.
- The Ways panel says how each way was tried: the card (so far an RTX 4050 laptop, 6 GB), the size, a whole run's time; its settings are for that card (the upscale stops at 1.25×). The AI hears it too when it uses one, so it can say how long to wait.
- Anomalous keeps a list of the models the ways use (api/block_models.json): each one's links (Hugging Face, Civitai, GitHub), size, SHA256, licence and folder. Each way names the models it recommends; the panel ticks the ones here (renamed ones too) and downloads a missing one with Anomalous's downloader in a click, checking its hash. Someone given a way gets the very same files from the list.
- Colouring a line drawing uses Anima's lineart LLLite on an Anima model.
- Each way is now a data file of its own (api/blocks/): its nodes, the packs and models it needs, what the AI is told and the panel's words in both languages, so a way collected later is one more file.
## v1.62.4 (Fix: opened at the computer's LAN address, changes were refused) — 2026-11-08
- ComfyUI 只监听局域网地址(比如 `--listen 192.168.1.5`)时,在运行 ComfyUI 的这台电脑上用这个地址打开,现在也算本机:扫描、改设置、填代理都能用了。以前它被当成别的设备,只能浏览。
- 设置页的代理和“测试连接”从别的设备打开时会说清原因:只能在运行 ComfyUI 的电脑上改。以前代理总说“格式写错了”,测试说“连不上 Civitai”,其实格式和网络都没问题。被拒绝的提示也会指向窗口顶上的“仅浏览”:点开有从别的设备也能用的办法(服务器上的 `allow_remote_changes` 文件)。
- With ComfyUI listening on its LAN address only (say `--listen 192.168.1.5`), opening it at that address on the computer it runs on now counts as this computer: scans, settings and the proxy work. Before, it was taken for another device and could only look.
- Opened from another device, the settings page's proxy and Test connection now say why nothing happened: only the computer ComfyUI runs on can do that. Before, the proxy always said the format was wrong and the test said Civitai could not be reached, though neither was the case. Refusals also point to the View only chip at the top of the window, which says how to use the plugin from other devices too (the `allow_remote_changes` file on the server).
## v1.62.3 (Fix: ComfyUI-Manager took other packs' nodes for AMB's) — 2026-11-05
- ComfyUI-Manager 不再把别的插件的节点算到 AMB 头上:AMB 没有节点,是测试文件里的假节点表让它误以为有,于是缺 Power Lora Loader 等节点时会推荐装 AMB。Manager 重新扫描后生效。AMB 本身用起来没有变化。
- ComfyUI-Manager no longer takes other packs' nodes for AMB's: AMB has no nodes, but node tables in its test files looked like some, so a missing Power Lora Loader and the like could offer AMB. Takes effect once Manager scans again. AMB itself works as before.
## v1.62.2 (Fix: workflow cards squeezed into strips) — 2026-11-05
- 工作流很多时,卡片不再被压成细条,列表正常滚动。
- With many workflows, the cards are no longer squeezed into thin strips; the list scrolls.
## v1.62.1 (Fix: Anomalous sometimes did not start) — 2026-11-05
- 修好一个偶发的启动故障:有时打开 ComfyUI,AMB 整个没加载,悬浮按钮和窗口都没有,刷新一下又好了。
- Fixed a startup failure that came now and then: ComfyUI opened with no Anomalous at all, no button and no window, until a reload.
## v1.62.0 (AI Workbench, AI Builds Workflows on the Canvas, Repaint an Area, Materials Side Panel) — 2026-10-29
- 音频库搬进了“角色语音”页:页面顶上的标签现在是 **角色 | 配音 | 音频库**,左边栏少了一项。加了 AI 工作台以后左边栏有 10 项,数字键只有 1–9,音频库就没键可按了;现在 9 打开角色语音,并回到你上次停的那个标签。
- The audio gallery moved into Voices: the tabs at the top read Characters | Voice-over | Audio, and the rail is one entry shorter. With the AI workbench the rail had ten entries for nine number keys, leaving the gallery without one; 9 now opens Voices on the view used last.
- AI 能拿刚出的图接着改了:搭“在这张图上重画”的工作流时,加载图像节点也认输出文件夹里的图(`xxx.png [output]`,ComfyUI 本来就支持);以前 input 文件夹里没有这张图就被拦下。左侧栏的“模型”“图库”不再显示成英文 models / gallery。
- The AI can redraw a picture it just made: a Load Image it builds now takes an output picture (`name.png [output]`, as ComfyUI itself does) instead of being refused when the input folder lacks it. The rail shows Models / Gallery again instead of the raw keys.
- 修复完成后的任务接续:AI 带着原任务 ID 查看结果、补面板,也会留在刚完成的任务里,不会另开空任务或把面板放进另一个任务。错误标记只在对应重试完成并成功后消除;等待审批、仍在运行、已取消,以及另一个节点或设置的成功都不算修好。
- Fixed follow-ups carrying an explicit task ID: reads and panels immediately after completion stay with that task instead of creating an empty task or placing a panel in another one. Error recovery requires a completed successful retry of the recorded target; pending approval, running or cancelled attempts and success on another node or setting do not clear it.
- 参数名自动纠正现在也用于审批和任务记录:你批准后,AI 改用正确参数名重试不再重复问,换模型的结果卡片也不会漏掉。展台和底部提问条同步同一道题的草稿,刷新时保留原来的光标、选区和编辑位置。
- Corrected argument names now carry through approval and task recording: retrying with the proper name reuses an unused approval, and model changes keep their result cards. The stage and bottom question strip share each answer draft; refresh preserves the caret, selection and editing surface.
- 修复任务接续与等待回答:按你最近一次未读回复接续任务,后台进度更新不再打乱顺序;选择题回答也能接回原任务,仍有工具正在执行的任务不会被新会话自动接走。读取答案不指定面板时也会正确处理超时;到时间自动选择或恰好收到你的回答,都会在当次调用里交给 AI。等选择题时发的留言和图片不再被读走却漏传。
- Fixed task resumption and answer waiting: resume by the latest unread user reply, including panel answers, rather than background updates; a task with a tool still running is excluded from automatic takeover. Reading answers without a panel now handles expired questions too. Timeout choices and user answers arriving at that boundary reach the AI in the same call; messages and pictures sent while it waits for a question are no longer consumed without delivery.
- 任务列表更准:AI 改正过来的错(比如参数写错、下一次就对了)不再让整个任务显示成“出错”,只有 AI 没跨过去的才算,红标改叫“有一步没做成”;AI 说做完以后马上再看一眼结果,这一眼记在原来的任务里,不再多出一个只有一步的空任务;接着给你看前后对比、问你满不满意,也回到原来那个任务里(任务重新打开),不再另起一个;你点“新任务”写好的任务一直等到 AI 来接(或者你再开一个新的),不会过了 12 小时悄悄失效、而页面还写着“等 AI 接手”。
- AI 少走弯路、少猜:
- AI 把必填参数写成了常见的另一个名字(比如打开工作流时把 `workflow` 写成 `name`、看图时把 `image` 写成 `file`)时,AMB 直接照它的意思做,并告诉 AI 正确的名字,省一次来回。只认考试里见过的这几种叫法;别的名字照旧报错、列出该写什么,免得猜错意思。
- 时间线里 AI“放大看图”那一步显示成代码名,改成了中文名。
- 去掉了“给 AI 的工具”里的 **日常**:所有工具都直接给。Antigravity、Claude Code、Cursor、VS Code 这些软件会自己按需读工具说明,AMB 再做一层“现查现用”只会让 AI 多绕路(一次实测 29 次调用里 18 次花在查工具和转一道上)。
- AMB 更新以后,AI 软件还拿着旧的工具列表来调用时,任务标题下面会出现一行黄字,告诉你怎么让它重新连接;AI 那边也收到同样的提醒。AI 软件重新取了列表,黄字自己消失。
- AI 加 LoRA 或换模型时,AMB 对比 LoRA 和主模型的底模:一个是 SD 1.5、一个是 SDXL 这种用不了的,会提醒 AI 并告诉它按底模去找;Pony 的 LoRA 用在 Illustrious 上这种能用但可能效果差的,也会轻轻提一句。只提醒,不拦着。
- AI 看模型详情时,能看到作者示例图用的采样器、步数、CFG、尺寸,LoRA 还有作者用的权重(来自扫描时存下的 Civitai 资料),还有版本名。
- 跑图被 ComfyUI 拒绝时,AI 收到的不再是原样的英文报错,而是“哪个节点、缺什么、该用哪个工具修”。
- 用 Gemini 3.8 Flash 实际考了 7 道常见任务(出图、换模型加 LoRA、问你再改、缺模型、找图看参数、搭放大分支、修断线),失败多半出在第一次用某个工具时参数写错,于是:参数写错时,AI 收到的报错会列出这个工具该写哪些参数,下一次就对;同一个东西在各工具里叫同一个名字(写提示词和跑图都叫 `prompt`,打开工作流和跑图都叫 `workflow`);等待时间写超了(比如 120 秒)按最长的 55 秒等,不再报错;按类型找模型时写单数(checkpoint、lora)也能找到。
- 很久以前(两个多小时前)留下、AI 一直没收到的留言或标注,不再把一个新对话拉进那个旧任务。
- 关卡窗口新增 **等不到回答时**:**停下等我**(默认)或 **按推荐的继续**。选后者时,到了等待时间你还没答,AI 标了推荐项的选择题就按推荐的选,展台和“决策”里都写着“到时间,按推荐选了”;没有推荐项的问题、你已经动手写了回答的问题、关卡里“先问我”的事,永远不会替你答。
- The task list says what is true: an error the AI got past (a wrong argument right on the next call) no longer marks the whole task as failed, only one it did not get past does, and the red mark reads "A step did not go through"; a look the AI takes right after saying it is done goes into that task instead of making an empty one-step task, and a before and after or a "happy with it?" it shows next opens that task again instead of starting another; a task you start with New task waits until an AI takes it (or you start another), instead of quietly lapsing after 12 hours while its page still says it waits.
- The AI takes fewer detours and guesses less:
- When a call writes its one needed argument under a known other name (`name` for open_workflow's `workflow`, `file` for `image`), Anomalous takes it as meant and tells the AI the right name, saving a round trip. Only the names seen in the exams count; any other is answered with what the tool takes, so no meaning is guessed.
- The timeline names the AI's "look closer at a picture" step instead of showing its code name.
- The tool set's **Daily** choice is gone: AI apps get every tool. Antigravity, Claude Code, Cursor and VS Code load tool descriptions as they need them, so looking tools up through Anomalous only added detours (in one run 18 of 29 calls).
- When an AI app still calls by the tool list of an older Anomalous, a yellow line under the task header says how to reconnect it, and the AI is told too; it goes once the app fetches the list again.
- Adding a LoRA or swapping a model, Anomalous compares the base models of the LoRA and the main model: one that cannot work (SD 1.5 on SDXL) is pointed out with how to find a fitting one; one that loads but may look off (a Pony LoRA on Illustrious) gets a softer note. A note only, never a block.
- Model details give the AI the sampler, steps, CFG and size the author's example pictures used, and for a LoRA the weight (from the Civitai record a scan keeps), and the version name.
- When ComfyUI refuses a run, the AI gets which node, what is missing and the tool that fixes it, instead of ComfyUI's raw report.
- Gemini 3.8 Flash took seven everyday tasks as an exam (a run, a model and LoRA swap, asking you first, a missing model, a picture's settings, an upscale branch, a broken link); most failed calls were a tool's first use with wrong arguments, so: an argument error now lists what the tool takes, so the next call is right; one thing has one argument name across tools (`prompt` for set_prompt as for run_workflow, `workflow` for open_workflow as for run_workflow); a wait longer than 55 s waits 55 s instead of failing; a model type in the singular (checkpoint, lora) finds them too.
- A message or marks the AI never got, left more than two hours ago, no longer draws a new chat into that old task.
- The Gates window has **When I don't answer**: **Wait for me** (the default) or **Go with the recommendation**, where a choice the AI marked a recommended option for takes it once your waiting time runs out, shown on the stage and under Decisions; a question without a recommendation or one you began to answer, and anything a gate asks first, is never answered for you.
- AI 问你的问题更好答:选项是一按就发出去的按钮,右边有箭头,问题下面写着“点一个就发给 AI”,点了马上变成选中的样子;**让 AI 定** 挪到问题右上角,不再跟选项挤在一起被当成“确认”;“都不是”那一行默认收起成 **都不是,我自己说…**,关卡卡片上的原因也收起成 **说说想怎么改…**,点了才出输入框。展台、留言框上方的提问条、关卡卡片都一样。关卡窗口里几条说明改对了:改画布也管加节点、连线、新建工作流;跑完以后那几句话在留言框上方;“先问我”和按钮上的字对上了。写了一半的回答不再被清掉:AI 又问了新问题、读走了另一个回答时,字和光标都还在,那个问题一直排在提问条最前面;窄窗口里你正在打字时,新问题只点亮展台标签,不把你切过去;用输入法按回车选字时,不再把半句回答发出去。
- Questions from the AI are easier to answer: options are buttons that send at a press, with an arrow and "One press sends it to the AI" above them, and the pressed one shows at once; **Let the AI decide** sits at the question's top right instead of under the options where it read as a confirm button; a line of your own stays folded as **None of these, I'll say it…**, and the Gates card's reason as **Say how to change it…**. The same on the stage, the question strip over the message box and the Gates card. Gates window texts fixed: changing the canvas includes adding nodes, wiring and new workflows; the lines after a run are over the message box; the intro names "Ask me" as the button does. A half-typed answer is no longer wiped when the AI asks something new or reads another answer: the text and the cursor stay, and that question stays first in the strip; in a narrow window a new question lights the stage tab instead of taking you there while you type; Enter that picks a word in an input method no longer sends half an answer.
- 局部重绘前先对一下范围:你圈得不一定准,AI 看了标好的图可以挪动、改形状(方框 / 椭圆)、往外扩、把边缘做软,然后在展台把 **你圈的** 和 **AI 调整后** 并排给你看,你点 **用 AI 调整的 / 用我圈的 / 我重新圈**,或者写一句想怎么改。选好以后 AMB 把带这个遮罩的图放进 input 文件夹,AI 收到文件名和一句常用重绘节点的提示;画布上怎么加节点、怎么连线仍然由 AI 自己搭,不套固定模板。
- Agree on the area before repainting: your marks are rough, so the AI can move, reshape (box or ellipse), grow and soften the area and show **Your marks** beside **The AI's area** on the stage; you pick **Use the AI's / Use mine / I'll mark it again** or write what to change. Anomalous then puts the picture with that mask into the input folder and the AI gets its name with a short note on the usual repaint nodes; how to build the repaint on the canvas stays the AI's, no fixed template.
- AI 问你问题后会等你:关卡窗口新增 **等我回答**(1 分钟 / 3 分钟 / 10 分钟,默认 3 分钟)。一次调用最多等 55 秒,你没答它就接着等,直到这个时间;你在回答框或留言框里打字时,停手一分钟内都算还在答。AI 写完说明或放出展台面板后会被提醒:收工前先问你下一步做什么、要不要开始(跑图、下载、扫描时除外),说明里写了问句的,提醒它改用提问。问题上的“这题跳过”改叫 **让 AI 定**,AI 收到后自己决定、记下决定,不再把同一个问题再问一遍。
- The AI waits for your answer: the Gates window has **Waiting for your answer** (1, 3 or 10 minutes, 3 by default). One call waits up to 55 s and the AI then waits on until that time, and while you type in an answer or the message box until a minute after your last key. After a note or a panel it is reminded to ask what next or whether to go ahead before it ends its turn (not while a run, download or scan goes on), and to put a question in its note into ask_user. "Skip this one" is now **Let the AI decide**: the AI decides, records it and does not ask again.
- 记录里不再出现“你在 AMB 里改了:保存图像 $$canvas-image-preview”:那是 ComfyUI 显示预览图时自己改的内部字段,不是你的改动,以前会被当成你的改动发给 AI。
- The record no longer says you changed a Save Image node's `$$canvas-image-preview`: ComfyUI sets it to show a node's picture, and it used to reach the AI as your change.
- AI 已经把任务做完了,你再留言或在图上标注:任务重新打开,留言框上方出现那句一键复制的话(“我在 Anomalous 里回复了,看看再接着做”),AI 回来接着在这个任务里做、回复也挂在你那条下面,不再另开一个新任务。给更早的任务留言也一样:AI 手上的任务做完或停下后,会回到你留言的那个任务。
- When the AI had already finished a task and you write to it or mark a picture, the task opens again: the line to copy for the AI shows over the message box, and the AI goes on in this task, answering under what you wrote, instead of starting a new one. The same holds when you write to an older task while the AI had moved on: once its own task is done or paused, it comes back to the one you wrote to.
- 同一个东西只用一个名字:界面上的“配方”统一叫 **工作流**(工作流页的卡片、保存和导出、操作记录、备份说明),“配方包”保留,它是发给别人的 .zip;AI 工作台里统一用“素材”“留言”“节点结构”“连接”“副本”“撤销”。
- One name for each thing: what the screen called a recipe is a **workflow** everywhere (the Workflows page, saving and exporting, the activity log, backups); a recipe package stays the .zip you give to others. The AI workbench says materials, message, node map and copy throughout.
- 标注窗口的“同时做成重绘遮罩”勾选框换成 **怎么改** 三选一:交给 AI 判断、只改标出的地方(其余保持原样,AMB 做好遮罩,AI 只重绘这些地方)、整张重新出图。你选的会作为你的要求写在 AI 收到的第一句,时间线里那条标注也写着“改法:…”;用“涂重绘区”笔刷时自动选中“只改标出的地方”。
- The marker's "make a repaint mask" checkbox is now **How to change it**, one of three: leave it to the AI, only the marked areas (the rest stays; Anomalous makes the mask and the AI repaints just those), or the whole picture anew. The choice is the first thing the AI reads, as yours, and the timeline row says it; painting with the repaint brush picks "only the marked areas".
- 任务列表更跟手:收起时列表也提前排好藏在左边,鼠标碰到左上角按钮(或从页面往左移到最左边缘)下一帧就滑出来;每几秒的自动刷新只重画变了的任务。点任务马上显示:看过的任务直接出来,鼠标停在任务上时先读好,再悄悄补上最新内容;展台里的图先用缩略图,显示得比缩略图大时才换原图。
- The task list keeps up: folded, it stays laid out out of sight and slides in from the next frame when the pointer reaches its button (or moves left onto the page's left edge); the regular refresh redraws only the tasks that changed. A picked task shows at once, from the copy read before or read ahead while the pointer rested on it, then fills in what changed; the stage's pictures load as thumbnails first and as the file itself only when shown larger.
- 任务标题栏的“专用标签页”改叫 **AI 改副本 ▾**:点开是带说明的二选一(改副本 / 改当前画布),不再点一下就悄悄切换;旁边的按钮叫 **切到副本**。快捷指令和关卡窗口用同样的说法。
- The header's "Own tab" is now **AI edits a copy ▾**, opening the two ways with what each means instead of switching at a click; beside it, **Show the copy**. The / commands and the Gates window say the same.
- 素材的“来源”列表:鼠标移到哪一行,哪一行就亮起来,当前选中的那项有底色和勾(之前被一条样式盖掉了);名字没被截断时不再弹白色小提示;搜索框里能用 ↑ ↓ 选、回车确定。
- The materials' Source list lights the row under the pointer and tints and ticks the one shown (a later style had hidden both); a name gets a tooltip only when it is cut short; ↑ ↓ and Enter work from the search.
- 展台入口显示面板数量;收起或查看其他栏时出现新增、更新,会亮主题色小圆点,打开查看后消失。
- The Stage button shows its panel count and a theme-colored dot for new or updated content while hidden; viewing the stage clears the dot.
- “回到最新一步”改为约 240 毫秒的短滚动,工作台刷新不会打断它;滚轮或触摸可随时接管,减少动态效果时直接定位,并补上按钮按下反馈。
- Jump to latest now uses a short 240 ms scroll that survives workbench refreshes, yields to user navigation, respects reduced motion, and gives immediate pressed feedback.
- AI 工作台新增右侧“成果”页:按步骤汇总图片,集中查看工作流、搭配、最新图片的提示词、模型下载请求和音频;复用图库的保存、参数对比与提示词复制入口。时间线上方改为简洁的成果统计,右侧按钮提示尚未查看的新成果。
- The AI workbench now collects pictures, workflows, combos, prompts, model download requests and audio in a Results side view, reusing gallery keep/comparison actions. A compact timeline summary and unseen-result badge replace the old results strip.
- AI 搭工作流支持一次添加并自动接线:`insert_after` 接回选中的下游,`wire_from` / `wire_to` 按唯一类型批量连接。正负条件歧义、多分支、已有输入冲突和循环在写入前报明;连接失败恢复原接线。成功回执包含实际连线、缺失输入与输出可达性,支持原生 Ctrl+Z 撤销。
- 现状简报新增有长度限制的流程链;节点搜索只返回少量摘要,具体参数按需读取,减少逐线调用和重复上下文。
- Canvas tools can add and wire a node in one call, safely splice a selected branch, and connect unique concrete types by node IDs. Ambiguity/conflicts/cycles are rejected before mutation; failed connections restore previous wiring. Compact receipts, bounded flow chains and brief node search reduce context.
- AI 加节点时能插在已有的线前面:`wire_to` 指向的输入原来接着线的,改接新节点,原来的来源接进新节点,比如在采样器前加 ControlNet(正向接正向、反向接反向),或在采样器和两个提示词前加 LoRA,一次调用接好。同类型的几个插口名字相同时自动配对;`insert_after` 会把 LoRA 的模型线和 CLIP 线一起接进去。
- add_node splices in front of a node: an input `wire_to` feeds that was already wired now goes through the new node, its old source feeding the new node (a ControlNet before a KSampler, positive to positive and negative to negative; a LoRA before the sampler and both prompts). Same-type sockets of the same name pair up, and `insert_after` splices every type a node passes through (a LoRA's MODEL and CLIP).
- 改参数不再靠固定的填空:`run_workflow` 和 `set_values` 都能按参数名改任何节点的参数,第三方节点也一样(比如 `{"steps": 30}`)。只有一个节点有这个参数就直接改;几个节点都有时(高清修复的两个采样器)什么都不改,列出是哪几个,让 AI 写上节点号。几个正向提示词框接进同一个采样器(分区提示词)时,`prompt` 也不再把同一句话填进所有框,而是停下来让 AI 分别写。
- Settings go by name instead of a growing list of blanks: run_workflow's and set_values' `values` set any node's setting, a third-party node's too ({"steps": 30}). The one node that has it is changed; a name several nodes have changes nothing and lists them, for the AI to name the node ({"12": {"denoise": 0.5}}). `prompt` no longer gives one text to several positive prompt boxes combined into one sampler (regional prompts).
- 一次调用加一组节点:`add_nodes` 按 ComfyUI 的 API 格式写一组节点,组里互相连,也能连画布上已有的节点(`"#12"`),AMB 先检查类型,排成一块放在空位上,接好线,整组算一步撤销。原来要十几次调用的局部重绘分支,现在一次。
- add_nodes adds a group of nodes in API form to the open canvas in one call: links inside the group, to canvas nodes as "#12" (outputs by slot or name) and wire_to into canvas nodes, checked first, laid out as one block and undone in one step.
- 图跑完后,第一个告诉 AI "跑完了" 的回复(不管是查进度还是别的工具)直接带着这次出的图,最多 4 张缩略图,每次运行只带一次。AI 不用再去取图,看完就能留言。
- The answer that first tells the AI its run ended, run_status or any other, carries up to four thumbnails of the run's pictures, once per run, so it need not fetch them with get_image_info.
- 跑完叫我:关卡窗口新增“等跑图”,默认“跑完叫我”。AI 提交后最多等 1 分钟(跑得快的当场就拿到结果),还没跑完就告诉你在跑,然后收工,不再一遍遍查进度(以前一张图要查 10 次左右)。跑完后,留言框上方有几句话,点一下复制,粘到 AI 的对话框里,它就接着看结果和图;AI 知道结果后这几句话自动消失。选“AI 自己等”就和以前一样。
- Tell AI when done: a new setting in the Gates window, on by default. The AI waits up to a minute after queueing (a quick run comes back with its pictures at once) and, if the run still goes, says so and ends its turn instead of polling run_status. When the run ends, lines over the message box are ready to copy into the AI's chat; they go once the AI knows. "AI waits" keeps the old way.
- 工具说明压短:常用工具的说明重写,去掉重复和绕的话,规则和“不要做”的都保留;开始任务时收到的规则从约 1900 字减到约 900 字。
- Shorter tool descriptions: the everyday tools' descriptions are rewritten without repeats, keeping their rules; begin_task's rules are about 900 characters instead of 1,900.
- 工具目录:AI 开始任务时,回复里附带一张按“想做的事”排好的工具目录(出图、画布、模型、提示词、图片、和你沟通……),AI 看一眼就直接去用对的工具,不用先到处试。目录从工具登记表自动生成,只列当前给 AI 的工具,新工具会自己出现。
- A directory of the tools by what the AI wants to do comes with begin_task's answer (and get_situation's while there is no task): each tool offered under its stage of the work with its title, built from the catalog so new tools show up by themselves.
- 给 AI 的话常驻在消息框上方:AI 停下来的时候(没在调用工具、没在跑图、没在等你回答),AI 工作台消息框上方总有一行话,点一下就复制,粘到 AI 的对话框里。话跟着情况变:图跑完了是“跑完了,看看结果”这几句;你在 AMB 里回复了、AI 还没收到,是“我在 Anomalous 里回复了,看看再接着做”;别的时候是“继续”。最后一句“用 Anomalous 接着做任务:…”给新开的 AI 对话用。原来挂在跑图那一步下面的几句话挪到了这里。“交给 AI”给你的那句话也不再叫 AI 用旧的 show_panel 问你。
- Lines for the AI's chat stay over the message box: whenever the AI is not in a call, a run or a question, the AI workbench offers a line to copy that fits what waits (about the run that ended; "I replied in Anomalous, take a look and go on" when it has not got your reply yet; else "Go on"), and one that picks the task up in a new chat. The lines under a finished run's step moved here. The Hand to AI sentence no longer tells the AI to ask with show_panel.
- AI 在 AMB 里问你时至少等 30 秒:就算它自己说不等(Gemini 常这样),你在 AMB 里点了选项,它在同一步里就收到,接着做,不用再回 AI 的对话框说“继续”。“加节点”工具的说明也提醒 AI:要加一组互相连着的节点(重绘、放大一段),用一次调用的“加一组节点”。
- ask_user waits at least 30 s even when the AI asks it not to, so an answer given in Anomalous right away reaches the same call and the AI goes on without a word in its chat. add_node's description points to add_nodes for several nodes wired to each other (an inpaint or upscale branch).
- 时间线更短:AI 连着做的事(查资料、加节点、连线、写提示词、改参数、记决策)三步以上、其中至少两步不是查资料时,叠成一行“做了 N 步”,下面一排小标签写做了什么(加了节点 ×5、连了线 ×10……),点开就是原来的每一步。行上有“撤销这一串”,一下撤掉这一串在画布上的改动。你的话、AI 留言、问你的、面板、出图、出错的、等你允许的、正在做的一直露在外面。AI 还在做时,这一行写着最后一步是什么。一个修手的任务从 43 行变成 18 行。
- A shorter timeline: three or more of the AI's working steps in a row (reads, canvas changes, prompts, settings, decisions; two of them not reads) fold into one row, "Did N steps", with chips of what they did and "Undo these" for their canvas changes; it opens to the steps as before. Your words, the AI's notes and questions, panels, runs, errors, steps waiting for you and running ones stay in sight. While the AI is at it, the row names its last step. A hand-fix task went from 43 rows to 18.
- 展台自动打开:AI 往展台放了新面板,右栏自己打开并跳到它,你关掉了或者在看节点结构、决策、素材也一样;AI 只是改已有的面板(比如进度)时不会。展台顶上有开关“有新东西就打开”,默认开着。窗口窄时不抢时间线,只在“展台”标签上亮个点;AI 问你问题时仍然切过去。
- The stage opens by itself: a new panel from the AI opens the side column at it, even when you closed it or were on another view; the AI changing a panel (a progress bar) does not. The switch "Open on new panels" in the stage's head turns it off. In a narrow window it lights the stage's tab instead of taking the timeline's place; a question still brings the stage forward.
- 打开右栏的按钮更醒目:时间线里每个面板那一行变成卡片,左边小图标表示里面是什么(问题、图、对比、节点结构),标题下一行写内容(“1 张图”“问你 · 2 个选项”),右边是实心按钮“在右侧打开”;右栏正在显示的那个面板,按钮换成绿点“正在右侧显示”。跑图那一步的“在右侧看大图”也换成同样的按钮。“在画布上找到”“撤销”“在图库里看”这些蓝色小字换成带图标的小按钮。
- Ways to the right are easier to see: each panel's line in the timeline is a card with an icon of what it holds, a line of what is in it ("1 picture", "Asks you · 2 options") and a solid "Show on the right" button, which turns into a green "On the right now" while that panel is in view there. A run's "See it large on the right" is the same button. "Find on canvas", "Undo" and "See it in the gallery" are small buttons with icons instead of blue text.
- 修复任务画布标签关闭或切换失败后误用当前工作流的问题:操作会明确报错,重新打开或明确指定新画布后才能继续。下载缺失模型现在也跟随任务画布;留言里的 `@` 搭配和工作流按完整名称查找,不再因模糊搜索前几项占满而误报缺失。
- Task canvas actions now fail when their recorded tab is closed or cannot be activated instead of using another workflow. Missing-model downloads follow the task tab, and message references resolve exact combo/workflow names before search limits.
- AI 工作台任务工具栏新增小巧的“运行状态”按钮与浮窗(移除遮挡时间线的常驻大横条):点开即可查看真实运行状态、排队、节点进度、用时和结果,运行中直接在按钮上显示进度百分比;没有收到运行信息时明确提示,不再空转显示“AI 正在工作”或把沉默当成完成。页面可见时自动补查状态。运行工具默认等待 15 秒并返回实际结果;外部提交可用 `track_run` 接入准确的运行 ID,不会重新出图。
- The AI workbench now places confirmed execution state, queue/node progress, elapsed time and results inside a compact toolbar button and popover (instead of a tall sticky banner), with visible-page refresh. Untracked work and silence are explicit. Run calls wait briefly for real results; `track_run` attaches external submission IDs without resubmitting.
### 右边一栏一次一样,按钮固定在右上角 / One view at a time on the right, switched from the top right
- 工作台右边那一栏一次只显示一样东西,用窗口右上角的三个按钮切换:**展台**、**节点结构**(AI 跑过的节点结构图,大图,可以选看哪一次运行)、**素材**(原来停在屏幕右边的素材小窗,现在就在这一栏里)。再点一下亮着的按钮收起这一栏;切走再切回来,内容都还在。时间线里每次运行都有 **在右侧看大图**。
- 任务标题和那一排按钮在时间线往下翻时一直停在顶上。
- 右边这一栏改成一张单独的卡片,不再用一条线硬隔开;默认窄一些,给中间的时间线留出地方。它左边的缝里有三个小点,鼠标移上去变成一条竖线,拖动调宽窄(记得住,双击恢复)。这条缝和时间线的滚动条分开,不会再一抓就抓错。
- 左边的任务列表用窗口左上角的按钮收起 / 展开;收起后鼠标移到按钮上,列表会临时浮出来,点任务或移开就收回。
- 素材在右边这一栏里时,鼠标移过卡片不再自动跳进详细说明(之前很难操作),点卡片上的 ⓘ 才看。
- 左边任务列表收起后,鼠标从按钮往下移去点任务时列表不会再中途消失(之前移得慢一点就会收回):鼠标在按钮和列表围成的范围里都算还在用,离开后才收回。
- The folded task list no longer vanishes on the way down from its button: it stays while the pointer is anywhere in the box around the button and the list, however slowly it moves.
- **模型补丁不再被当成 ControlNet**:Anima LLLite、Qwen-Image 分块 ControlNet、Z-Image Fun ControlNet 这类文件名字里常带 ControlNet,但只有「加载模型补丁」节点能读,它只看 `model_patches` 文件夹。现在拖进模型页会自动放进 `model_patches`;已经放在 `controlnet` 里的,模型页的“整理”会提示挪过去。
- Model patches (Anima LLLite, the Qwen-Image block-wise and Z-Image Fun ControlNets) are told from ControlNets by their header: imported into `model_patches`, and Tidy offers to move those already in `controlnet`.
- **AI 找模型时直接知道该用哪个节点**:`search_models` / `get_model` 给每个模型写上能加载它的节点;放错文件夹的会说该放哪、放在这里加载不了,同名文件已经在对的地方时会直接指给 AI。新工具 `move_model` 把放错的模型挪过去(新关卡“挪动模型文件”,默认先问你;从不覆盖文件)。AI 不必再去命令行翻 ComfyUI 的源码。AI 加节点时如果下拉列表是空的,会被告知“ComfyUI 没找到文件,可能放错了文件夹”。
- search_models and get_model say which node types load each model, and where a misfiled one belongs; `move_model` moves it there behind a new "files" gate (asks by default, never overwrites). An empty drop-down in add_node now says ComfyUI found no file for it.
- AI 第一次在任务的副本标签页上动手时,切换标签页不再被记成“你在画布上的改动”告诉 AI。
- The AI switching to its task tab is no longer reported to it as a canvas change of the user's.
- **工作台里直接用图库的图**:留言框左边新增图片按钮,打开图库(最新的在前,可按提示词、模型、seed 搜索),点选几张后 **加进留言**;打 **@** 时也能看到图库最新的图,打字会顺带在图库里搜。AI 收到的是图片本身和它的生成参数。
- Gallery pictures in the AI workbench: the message box's picture button picks them from the gallery (newest first, searchable), and "@" offers the gallery's newest pictures and searches it as you type; the AI gets each picture with what made it.
- **等 AI 接手时先备好素材**:“等 AI 接手”的任务是一页:最上面是 **把这句话发给你的 AI** 和 **复制这句话**,往下滚时它停在顶上,挑完图不用翻回去;下面的 **写一句要做什么**、**从图库挑图给 AI**(大图库可搜索,点一张就放到最下面的留言框,再点一下拿掉)都是可选的,点了才展开,行上写着已经放了几张;备好的都列在下面。AI 接手时一起收到。那句话改成让 AI 照任务里的交代做,没有交代才问你,不会一接手就停下来问。
- A task waiting for an AI is one page: **Send this to your AI** with **Copy** leads and stays at the top while the page scrolls; **Say what to do** and **Pictures from the gallery** (a big searchable grid feeding the message box) are optional folds that open when pressed and say how many pictures are in; what is ready is listed under them. The sentence now has the AI do what the task says, asking only when it says nothing.
- **不全屏时中间更宽敞**:AI 工作台的窗口不够高时(没有最大化),任务标题、状态、时间步数、“接着”和右边的工具并成一行,上下留白也收紧;输入框下面的提示只在出错时显示(平时鼠标停在输入框上能看到);等 AI 接手的页面图片变小、步骤靠紧。同样的窗口里,中间能多显示约两成内容;最大化时样子不变。
- In a low window (not maximised) the AI workbench keeps the task's head to one line, tightens the room around it, shows the message box's hint only for a failure (its words are the input's tooltip) and shrinks the waiting page's picture: about a fifth more room for the timeline.
- **换类型**:模型详情页多了 **换类型** 按钮,给“导入时类型选错了 / 代码认不出、放错了文件夹”的模型用。窗口里写明现在放在哪一类、文件头看起来像什么(看得出就替你选好并标“推荐”,不确定标“可能是”,看不出就不猜),每一类都写着用哪个节点加载;可以改挪到的根目录和子文件夹,下面显示完整路径。挪动和“整理”用的是同一套:封面、信息、备注一起走,从不覆盖文件,挪完 ComfyUI 的模型列表马上更新。导入单个模型后,提示里也有 **类型选错了?**,点一下直接改。
- "Change type" on a model's page, and "Wrong type?" in the toast after importing one model: the window says where it is and what its header suggests (picked and marked when it can tell, nothing guessed when it cannot), offers every type with its loader and where it goes, and moves it as Tidy does.
- 跨盘挪动、复制模型时,除了比大小,还会比对文件开头、中间、结尾各 1 MB 是否一模一样,对不上就不删原文件(只多花几毫秒,不需要事先有指纹)。
- A model copied to another drive is checked at its start, middle and end (a megabyte each) besides its size before the original goes; no fingerprint needed, no felt cost.
- 挪动、导入模型时,就算目标位置在最后一刻冒出一个同名文件,也绝不会被盖掉:这次挪动会失败,两边的文件都原样保留(之前在 Windows 上理论上有极小的机会覆盖)。
- Moving or importing a model never replaces a file that took the name at the last moment: the move fails and both files stay (on Windows a rename could replace it before).
- **AI 能放大看图、看预览,不用再自己写代码**:新工具 `zoom_image` 把一张图的某一块放大给 AI 看,还能把两张图的同一块并排比较(改之前和改之后);输出、输入、临时预览的图都能看,只看不改。出图结果里会列出这次运行的预览图(比如 OpenPose 骨骼、遮罩的预览),AI 用它们就能检查中间结果,不必自己去跑预处理器。AI 开任务、收到遮罩时都会被明确告知:要改图就跑 ComfyUI 的工作流,绝不用自己的代码去画、去改、去覆盖图片文件。
- zoom_image shows part of a picture larger (output, input or temp folder), with the same part of a second picture to compare, reading only; run_status lists a run's PreviewImage pictures; begin_task and the repaint note tell the AI to change pictures only by running workflows, never by editing picture files with its own code.
- **AI 能直接看输入文件夹里的图**:`get_image_info` 加了 `folder=input`,能看 ComfyUI 输入文件夹里的图(你标注时 AMB 存的重绘遮罩图就在那里):尺寸、要重画的区域在哪、占多大,也能附上图本身。交给 AI 遮罩图时也明说“它已经在输入文件夹里了,不用跑命令检查”,Gemini 不会再为这个弹窗问你。
- get_image_info with folder=input reads a picture in ComfyUI's input folder (the inpaint picture Anomalous writes for a repaint): its size and the area LoadImage gives as MASK, optionally attached; the marks message says the inpaint picture is in place, so the AI need not check it with a command.
- **AI 少去翻文件**:AMB 工具返回给 AI 的内容去掉了多余的空格、画布里 ComfyUI 自己的内部字段不再列出、操作记录默认只给最近 10 条,这样 Antigravity 不必把结果另存成文件再让 Gemini 去读。AI 开始任务时也会被告知:模型、图片、节点、画布和运行都有 AMB 的工具,不要自己去读 ComfyUI 的文件夹、源码或接口(那样每一步都要你点允许)。
- Tool results reach the AI as compact JSON, without the canvas's internal "$$" widgets, and recent_activity gives the last 10 by default, so apps such as Antigravity keep more of them in the chat instead of a file the AI must open; begin_task tells the AI that AMB's tools cover models, pictures, nodes, canvas and runs, so it need not read ComfyUI's folders, source or routes.
- 修复输入框上方的出图进度停在“节点 0/29 0%”不动的问题:现在它和时间线里那一步、标题栏的进度按钮一起更新。
- The run line over the message box now follows the run's progress with the timeline's bar and the header's button, instead of staying at the start.
- **最下面就能看到 AI 在做什么、要你做什么**:输入框上方多了一条“现在”栏:AI 出图时显示进度(节点、步数、百分比、进度条)和停止按钮;AI 某一步还在进行时说在做什么;AI 想做要你点头的事(比如挪模型、下载)时,直接在这里 **允许 / 不要**;AI 问你问题时,问题和选项就在这里,点一下就回答(有推荐的带 ☆,也能自己写一句或跳过),多选和挑图会带你去展台。
- A "now" strip over the message box: a run's progress with stop, the AI's step still going, a gate to allow or refuse right there, and the AI's open question with its options as buttons (one's own words or skip too; several picks and pictures to pick lead to the stage).
- **拖进来、粘贴进来的图片也能发给 AI**:把电脑里的图片拖到输入框上,或者直接 Ctrl+V 粘贴截图,它会存进 ComfyUI 输出文件夹里的 `ai_inbox`(图库里也看得到,从不覆盖文件,只收 PNG、JPEG、WebP、GIF,最大 25 MB),并加到这条消息上。从 ComfyUI 页面里拖来的输出图片直接加上,不另存。
- Pictures dropped on the message box or pasted are kept in the output folder's `ai_inbox` (seen in the gallery; never over a file; PNG, JPEG, WebP or GIF up to 25 MB) and attached; an output picture dragged from ComfyUI's page is attached as it is.
- **很长的 AI 留言先折叠**:超过八行左右的留言只显示开头,点 **展开全文** 看全部。
- Long AI notes are folded to their first lines with "Show all".
- **输入框固定在最下面,像聊天软件一样**:不管时间线多长、翻到哪里,留言框都停在任务这一栏的最下方。它上面是这条消息要带的东西:图片显示成小图,每张有 **✎ 标注**(画框、圈、写要改哪里,标好后单独发给 AI)和 **×**;@ 提到的模型、搭配显示成小标签。从图库挑的图不再往文字里塞“@文件名”。左边是 **图库** 和 **素材** 两个按钮,给了东西就亮起并显示数量。输入框会随内容变高:Enter 发送,Shift+Enter 换行;只发图片不写字也可以。往上翻了一屏以上,或者往上翻着看时 AI 有了新步骤,输入框右上方会出现圆形 **↓** 按钮(有新步骤时角上带数字),点一下回到最新;自己发出的消息会自动滚到眼前。时间线里你发的图片和 AI 出的图一样,可以查看、对比、收藏、标注。
- The message box is docked at the foot of the task column like a chat app's: over the input what the message carries (pictures with ✎ to mark up and ×, chips for what "@" names; gallery pictures are attached, not typed in), beside it the gallery and materials buttons with their counts, an input that grows (Enter sends, Shift+Enter breaks the line; pictures alone are fine), a round ↓ button back to the newest step (with the count of new ones) when you read more than a screen up or the AI adds steps meanwhile, and your own message scrolled into view. Pictures you sent carry the same tools as the AI's. The side column's grip sits in its own gap, three dots, beside the timeline's scrollbar instead of over it.
- **打开任务就停在最新一步**:像聊天软件一样,打开任务直接看到最新的进展。你停在最下面时,AI 的新步骤来了就跟着往下走,不管任务现在是什么状态(之前只在 AI 正在干活时才跟,打开任务时还会停在上一个任务翻到的位置);往上翻着看就不动你。
- A task opens at its newest step; read at the foot, the timeline follows new steps whatever the task's state (it used to follow only while the AI worked, and opened where the previous task was scrolled to); read further up, it stays put.
- **面板上的进度条不再冒充实时进度**:AI 画在面板上的进度条是它自己写的数,不会自己动。任务往前走了(那次出图结束了、AI 又画了新面板或留了言)而它没去更新,这条进度条会变成灰色,并写明“这是 AI 在几点写下的数,之后没有再更新”。出图的真实进度一直在时间线那一步、输入框上方和标题栏的运行状态里。AI 也会被告知:出图不用另画进度条,自己画的要自己更新。
- A panel's progress bar is the AI's own numbers: once the task goes past it without an update (a run ends, a later panel or note), the stage greys it and says when it was written. The AI is told a run's real progress shows already and to keep its own bars current.
- **还没出图就问进度不再算“出错”**:AI 在出图前调用 `run_status`,现在得到“这个任务还没有出图”的回答,任务不会因此标上“有一步出错”。
- run_status before any run answers state "none" instead of failing, so the task no longer shows a failed step for it.
- **宽窗口里时间线收在中间**:右边一栏关着、窗口又很宽时,时间线、标题和输入框不再横跨整个窗口,而是收成中间约 880 像素宽的一列(像聊天软件那样),滚动条仍在最右边。时间线里的图片按自己的大小从左排开,一张图不再被撑成横跨整行的大灰框。
- **AI 每一步都知道下一步做什么**:开始任务、出图和看进度、加节点连线、问你问题得到回答,这些回复里都多了一句 `next`:比如“还在跑,等 55 秒再看”“跑完了,先放大看图再告诉你结果”“还缺这几根线,照清单连”“缺模型,先查缺哪个”。小模型不擅长自己规划,但很会照着做。
- Answers of begin_task, run_workflow and run_status, the canvas changes and an answered ask_user carry next: the one thing to do now.
- **照着跑:日常出图一步完成**:AI 出图时可以直接说“用哪个存好的工作流、改什么”:`run_workflow` 带上工作流名,再带上要改的提示词、反向提示词、种子、尺寸或一张图(比如你标注过的重绘图),AMB 就在任务的副本标签页里打开它(第一次打开,之后接着用同一个标签页),填好这几处,其余照存好的样子不动,然后出图、等结果。尺寸只改起始的空白潜空间,高清修复里的 Latent 放大保持原来的放大尺寸。原来要 6、7 步(找工作流、打开、看画布、改提示词、改种子……),现在一步,小模型也不容易绕晕。填的内容和出图一起算一步,改画布那部分同样能 Ctrl+Z;如果你给“改画布”设的关卡比“用显卡”严,这一步按改画布的关卡来问你。画布报错是因为缺模型时,“现状一览”会让 AI 先查缺哪个模型,而不是乱换选项。
- run_workflow takes blanks for a daily run in one call: workflow (a saved one, opened in the task's tab once and reused), prompt, negative, seed, width, height and image (image_node for several LoadImage nodes); the rest stays as saved. The size goes to the latent a run starts from, never to a hires fix's latent upscale. With blanks a run asks as the stricter of the GPU's and the canvas's gate.
- **现状一览,AI 一次就知道到哪一步了**:新工具 `get_situation`,AI 调一次就拿到:任务(你最后说了什么、有几个问题在等你、定了哪些决策、出过哪些图、“AMB 之外”几次)、画布简报(每个节点一行:谁接到谁;**还缺哪几根线、各要什么类型、画布上哪些现有输出能接上**;ComfyUI 报错的节点)、最近一次出图,以及**下一步该做什么**。回复控制在约 3600 字以内,Antigravity 不会把它存成文件再让 AI 去读。新开对话或拿不准时先调它;连接说明里的那句话和规则也写上了。小模型(比如 Gemini Flash)不用再把整张图记在脑子里。
- `get_situation`: the task, the canvas in brief (a line per node, the inputs still unwired with the outputs that fit, ComfyUI's errors), the latest run and the next step in one answer under about 3,600 characters, for a new chat or an AI that lost track.
- **AI 每连一根线都知道还缺什么**:`add_node`、`connect_nodes`、`remove_nodes` 的回复里附上画布上还没接的输入,以及哪些现有输出能接上,像一张清单,照着补完就能跑。
- add_node, connect_nodes and remove_nodes answer with the inputs still unwired and what fits them.
- **AMB 之外做的事也看得见**:AMB 只看得到 AI 发给它的调用,AI 软件自己跑的命令和脚本它看不到。现在 AI 每调用一次 AMB,AMB 就看一眼输出和输入文件夹:上次调用以来有没有不是出图产生的新图片、有没有图片被原地改写(比如之前那张被覆盖了 6 次的图)。有的话,时间线上多一行橙色的 **AMB 之外**,列出是哪些文件,输出文件夹里的图直接显示;AI 同时收到提醒:可能是你自己做的,如果是它做的,改图只能跑工作流。只在 AI 调用时看,不在后台扫描;ComfyUI 正在出图时,新图等出图结束再判断,不会把正常出的图当成外面来的。AI 也多了一个工具 `log_outside`:用了别的工具(命令、脚本、读写文件)后说清做了什么、为什么、动了哪些文件,同样显示为一行 **AMB 之外**;任务的统计里会写“AMB 之外 N 次”。
- Pictures changed outside Anomalous show in the timeline: when the AI calls, Anomalous looks at the top of the output and input folders for pictures no run saved and pictures rewritten in place since its previous call, and tells the AI with its answer; new pictures wait while ComfyUI runs a prompt. `log_outside` is the AI's own word on a command or script it ran. Both are "Outside AMB" steps, counted on the task.
- **连接说明里多了一段给 AI 的规则**:第 ④ 步可以复制一段规则(先用 AMB 的工具、只通过跑工作流改图、在 AMB 里问和说明、用了别的工具就说一声),放进 AI 软件的“规则”里(Antigravity:Customizations → Rules → + Global),它每次对话都会读到。
- The connect guide has rules to paste into the AI app's own rules: use AMB's tools, change pictures only by running workflows, ask and explain in AMB, and say what was done outside it.
- In a wide window the task (its head, timeline and message box) keeps to a centred column of about 880 px, as chat apps do; the scrollbar stays at the edge. A step's pictures are cards of about their own size from the left, no longer one stretched across the row.
- **点得到、看得见**:选中的图片有粗框、发光和大对勾,图片罩上一层颜色,和鼠标停留一眼分得开;留言框左边的图片按钮在图库面板打开时亮起,留言里有图时显示张数。素材列表、AI“看了资料”那一行(带 › 可展开)、关卡选项、优先用 / 只能用、来源下拉、发送和复制按钮、模型页的标签、首页的“一键准备”、记录里可展开的一行,鼠标移上去都有明显的变化。点 **展台** 时即使还没有内容,也会打开并说明会放什么,不再没反应。“交给 AI · 0”在什么都没选时不能点。挪模型那一步的标题写文件名和挪到哪,不再是一串编号;只改了一个节点时不再出现两个“在画布上找到”;“接着哪个任务…”的字号和旁边一致。
- Clearer feedback: picked pictures get a thick glowing frame, a tint and a big tick; the picture button lights while its panel is open and counts the message's pictures; materials rows, the folded reads row (now with a ›), gate options, the mode switch, the source menu, send and copy buttons, the models page's tabs, the home page's set-up button and expandable activity rows all react clearly to the pointer. The Stage button opens an empty stage that says what will appear there; "Hand to AI · 0" is disabled with nothing picked; move_model's step names the file and where it goes; a one-node canvas change shows one "find on canvas"; the "Goes on from…" chip matches the size around it.
- 右边那一栏的宽度把手更好抓了:卡片左边整条空隙(从上到下)都能拖,把手平时就淡淡地显示,鼠标靠近时整条边亮起;点一下后也能用 ← → 调宽窄。
- The side column's grip catches the pointer across the whole gap left of the card, top to bottom, shows faintly at rest and lights the edge when pointed at; focused, ← → resize it.
- **节点结构的简单看法**:除了完整的方块图,现在默认是一张一眼能看懂的说明:每一段采样用哪个模型加哪些 LoRA(带权重)、正负提示词、从空白画布还是从上一段的结果开始(用哪个放大模型、缩放多少),最后保存在哪;和上一段一样的就写“和第 1 段一样”。点任意一项定位到画布上的节点;“简单 / 完整”随时切换,AMB 记得你的选择。
- The run map has a Simple view (now the default): per sampler, the model and LoRAs feeding it, its prompts, where it starts (an empty canvas, a picture, or the part before, upscaled) and where it is saved, "same as part N" when nothing changed; Simple / Full is kept on this browser.
- **删掉任务里的图以后**:时间线、本次成果、展台上的那张图显示成“图片已删除”的占位(鼠标停上去说明可在回收站找回),不再是破图,也点不开;@ 列表里不再出现它。AI 那边会收到“你删除了这张图,别再用它”;它要是还去读,会被告知“可能已被用户删除”。
- A task's pictures deleted since show as "Picture deleted" instead of a broken picture, and "@" no longer offers them; the AI hears plainly that the user deleted one, and a tool reading a gone picture says it may have been deleted.
- **关联任务**:从一个任务新开的任务会记住它“接着”哪个任务(AI 开的新任务、你看着某个任务时点的“新任务”都会)。标题下面显示“接着:…”“后续:…”,点一下跳过去;任务列表里后续任务带 ↳。可以解开,也可以自己选“接着哪个任务…”。AI 接手后续任务时会先收到上一个任务的决策。
- Tasks remember the task they go on from (an AI's new task while on another, or one the user starts from a task): links under the title lead both ways, the list marks follow-ups, the user can link or unlink, and an AI beginning a follow-up hears what was decided before.
- **决策记录**:聊天在哪里都行,结论留在 AMB。AI 用新工具 `log_decision` 把商量好的结论记成时间线上的 📌 卡片(定了什么、为什么、没选哪些、谁定的、跟哪几步有关);你在 AMB 里的选择(面板回答、允许或不允许、交出去的素材、AI 改画布的地方、停止)自动算作决策。右上角新增 **决策**,按时间列出全部决策,点一下回到那一步;AI 很久没记录时会轻轻提醒。AI 重新接手任务时会先收到这些决策。
- Key decisions stay in Anomalous: the AI records what was settled with `log_decision` (a card on the timeline: what, why, what was left out, who decided, the steps it is about); the user's own choices in Anomalous count as decisions too; a Decisions view in the side column lists them all with a link to each step, notes work done since the last one, and begin_task repeats them to an AI picking the task up.
- **留言框里的 @ 和 /**:打 **@** 弹出能搜索的列表(这个任务的图、搭配、模型、工作流、提示词、节点包、节点),选中的写成“@名字”并在下面显示成小标签,AI 收到的是确切的那个东西(图片连同生成参数一起)。开头打 **/** 是快捷指令:停止、撤销、展台、节点结构、素材、副本、当前画布、新任务马上生效;重跑、解释、继续帮你写好一句话。
- "@" in the AI workbench's message box names the task's pictures, combos, models, workflows, prompts, node packs and node types; the AI gets each in full with the message (pictures attached). "/" lists commands that act at once (stop, undo, side views, new task, canvas mode) or fill in words to send.
- **整个自定义节点包交给 AI**:素材里点“节点”,再在来源那一排点某个自定义节点包,上面会出现“整包交给 AI”。不知道该选哪个节点时,把整个包给它,AI 自己从包里挑;选“只能用”时,这个包里的节点都允许,别的第三方和付费节点照样拦下。AI 用 `get_toolbox` 能看到包里有哪些节点,`find_nodes` 搜索时也把它们排在前面。
- A whole custom node pack can be picked (from its source in the nodes' second column): the AI gets its node types in get_toolbox, finds them first, and with "only" every node of the pack is allowed.
- 小窗里的“来源”不再是一条要横着拖的长条,改成一个“来源:全部 ▾”按钮:点开是一张列表(来源多时上面有搜索框),点一个就选上并收起;Esc 或点别处也收起。
- In the side panel the sources (or types) are one "Source: All ▾" button opening a list to pick from, with a search when it is long, instead of a strip to scroll sideways.
- 素材在右边这一栏(小窗)里时改成一行一个的列表:左边是图标或小封面,中间是名字和来源,右边是勾选;搜索框和“全部 / 已选 / 常用”并成一行。同样的高度能看到的条目多了好几倍;展开成大窗口还是原来的卡片。
- In the side panel the materials picker lists one line per item (badge or small cover, name, where it is from, the tick) and puts the search and the filters on one line; the large window keeps the cards.
- AI 工作台和素材窗口里的滚动条变细,平时看不见,鼠标移到能滚动的那一块上才显出来。
- Scrollbars on the AI page and in the materials picker are thin and show only while the pointer is over what scrolls.
- The side column is a card of its own, narrower by default and resizable from its left edge (kept; double-click resets). The task list folds from a title-bar button and peeks over the page while pointed at. In the docked materials picker, hovering a card no longer opens its detail; its ⓘ does.
- The AI workbench's side column shows one view at a time, switched from the window's title bar: the stage, a run's node map at full size (pick the run), or the materials picker docked there. Pressing the shown one hides the column; each view keeps its state. Each run's step has "See it large". The task's header stays on top while the timeline scrolls.
### 回答发出去了没有,一眼看清 / See at a glance whether the AI has your answer
- 面板上每个回答、你的留言、批注、发给 AI 的图片下面都有醒目的状态标签:琥珀色 **已发送 · 等 AI 来取**,AI 拿到后变成绿色 **AI 已收到 · 时间**(取代原来那行灰色小字)。鼠标停在“已发送”上会提示 AI 停下时该怎么办。
- 面板上的问题可以点 **这题跳过**:可选的问题不再一直显示“等你回答”,AI 会收到“你跳过了”,自己拿主意。
- Each answer, message, set of marks and picture handed to the AI shows a pill: "Sent · waiting for the AI", then "The AI has it" with the time. Panel questions can be skipped; the AI reads `skipped: true`.
### 看得见 AI 跑了什么、AI 改副本、图片直接给 AI / What the AI ran, a copy for the AI, pictures straight to the AI
- 每次运行下面都有 **节点结构**:一行写明底模、几个 LoRA、尺寸、步数,点开是方块图(LoRA 带权重、正负提示词、采样器设置……,点方块定位到画布上的节点),还用黄色小块标出 **比上次改了什么**,比如“步数 20 → 30”“LoRA 权重 0.65 → 0.8”“新增「加载LoRA」”。图是 AMB 按 ComfyUI 实际收到的工作流画的,不靠 AI 描述;AI 讲解时用新工具 `show_run_map` 一次把它放上展台。
- **AI 改副本**(默认开):AI 第一次改画布前,AMB 把你正开着的工作流复制到以任务命名的新标签页(副本),之后它只在副本里改和运行;你切去别处,它下次动手前会切回来;你原来的工作流一笔不动。任务标题栏的“AI 改副本 ▾”能换成“改当前画布”,“切到副本”跳过去;关卡窗口里设定新任务的默认做法。
- **图片直接发给 AI**:图库右键“发给 AI”或看图窗口的 ✦,可以发给 AI 正在做的任务(也能开新任务),可附一句话。AI 收到图片本身和从图里读出的模型、LoRA、提示词、尺寸、采样设置。AI 搜图库时也能让结果直接带上前 6 张图。
- Each run shows the shape ComfyUI actually ran (boxes, links, settings; boxes locate their nodes) and what changed since the run before; `show_run_map` puts it on the stage. By default the AI works in a tab of the task's own: the open workflow is copied before its first change and brought back before later ones, the user's tab untouched (per task, and a default in the Gates window). Output pictures can be handed to the task an AI is on (gallery menu, inspector): the AI gets them attached with what made them; `search_images(with_pictures)` attaches the first six.
### 运行时看得更清楚,也能一键停 / See more of a run, and stop it in one click
- AI 运行工作流时,进度条旁边多了红色的 **■ 停止**(任务工具栏的运行状态按钮旁边也有):一点就停下这一次运行,排队中的移出队列、正在跑的立刻中断,只动这次运行。时间线上记着“你停止了运行”,AI 也会收到,不会自己再跑一遍;AI 自己也能用新工具 `stop_run` 停掉自己的运行。
- 进度条下面多了当前节点**约剩多久**、每步几秒、**显存还剩多少**,以及 ComfyUI **控制台**最新的一行(比如放大模型加载、`Model Initializing ...`),点开看最近几行。AI 收到的进度里也有这些,不用再去翻控制台。
- 出图一排上队,AMB 就读这次工作流,算出每个采样器要在多大的图上采样。放大模型放大后又整张重新采样、尺寸超出这张显卡的舒适范围、要解码特别大的图时,会出现黄色提醒,说清可能很慢或爆显存,以及怎么改(只想放大就单用放大模型,或先缩小、减步数、换分块解码)。AI 收到同样的提醒,并被要求先问你要不要停。
- 运行状态浮窗开着时按 Esc 只关浮窗,不再把整个 AMB 窗口一起关掉。
- A stop button on a running run (and beside the run-status button): only this run's prompts leave the queue or are interrupted; the step ends "cancelled", the AI hears that the user stopped it, and `stop_run` lets the AI stop its own. Runs show time left and per step, VRAM, and ComfyUI's console (the bar's note, models loading), for the user and the AI alike. Once queued, each sampler's working size is estimated from the prompt, with warnings for an upscaled picture sampled again, a size past the card or a huge decode. Esc closes the run-status card only.
### 素材面板的侧边栏小窗 / The materials picker as a side panel
- “素材”默认在屏幕右边打开成侧边栏小窗,不挡工作台和画布:种类是一排标签,卡片两列,点卡片在小窗里看说明。右上角 ⤢ 展开成原来的大窗口,大窗口里也能收回侧栏;AMB 记得你上次用的哪种。
- The materials picker opens as a side panel by default (the page stays usable), and expands to the large window and back; the last choice is remembered.
### AI 在画布上搭工作流 / The AI builds workflows on the canvas
- AI 可以在你眼前的画布上搭工作流了:查这台 ComfyUI 有哪些节点(包括自定义节点)、每个节点有哪些输入输出和可选项;新建一个工作流标签页(空白的,或者一次搭好整套并自动排好位置,不动你正在开的那个);加节点、连线、改参数、删节点。每一步都在画布上当场发生,是一次 Ctrl+Z,记在操作记录里,并在 AI 工作台那一步下面显示改了什么,连线也会写成“某节点的输入 model:(空)→ 加载模型 #4 · MODEL”。连不上的类型、不存在的选项、写错的参数会先被拦下并告诉 AI 正确的写法,画布不会留下半截。新建的标签页名字带时间,不会打开或覆盖你存过的工作流。连线会绕成死循环时也会先拦下。运行被 ComfyUI 拒绝时,AI 收到的是 ComfyUI 给的具体原因(哪个节点、什么问题),不再只有一句“运行失败”。
- The AI can build workflows on the canvas the user sees: look up this ComfyUI's node types (custom nodes too) with their inputs, outputs and choices; open a new tab, blank or built and laid out from a workflow in API form (the open one is left alone); add, wire, set and remove nodes. Each is one Ctrl+Z step, logged as the AI's and shown under its AI workbench step, rewiring included. Wrong types, choices and settings are refused with what fits, before anything changes. Wiring that would loop is refused too, and a run ComfyUI rejects reports ComfyUI's own reason.
### 局部重绘:涂一涂就交给 AI / Repaint an area: paint it, hand it to the AI
- 标注窗口多了“▧ 涂重绘区”笔刷(粗细可调):涂上要重画的地方(框、圈、画笔圈的地方也算),选“只改标出的地方”发给 AI,AMB 就在 ComfyUI 的 input 文件夹里做好一张带遮罩的原图(`anomalous_inpaint_….png`),AI 用 LoadImage 读进来就能局部重绘;AI 同时收到怎么接、重绘强度怎么选的说明。时间线上你的标注下面写着“已做成重绘遮罩”。AI 搭工作流时如果刚生成的文件还不在下拉列表里,AMB 会自动刷新一次列表。
- The marker's repaint brush and "only the marked areas": the picture with the painted area as its alpha goes to ComfyUI's input folder for LoadImage, and the AI is told how to inpaint with it. Canvas tools refresh ComfyUI's lists once when a choice is missing.
### AI 的问题在 AMB 里问 / The AI asks in Anomalous
- 你在图上标注或给 AI 留言后,AI 的分析和修改方案也回在 AMB 里:它的留言会写明“回复第几步”,你那条标注下面出现“AI 已回复 · 第几步”,两边点一下就互相跳过去。AI 的留言现在有排版:小标题、列表、加粗、`代码` 都会正常显示(只认这几种写法,不会运行网页代码)。AMB 告诉 AI:收到标注和留言就在 AMB 里回复,要你选修法就用选择题问。
- The AI replies in Anomalous: `report` takes `reply_to` (the user's message or marks), both steps link to each other; its notes are laid out (headings, lists, bold, code) with text only.
- AI 接了 AMB 的任务以后,要你做选择(比如“先测哪种题材?”)不再弹在 AI 软件的对话框里,而是出现在 AI 工作台(展台上有提示,你不在工作台时右下角也会提醒):几个选项按钮,AI 推荐的那个标着“推荐”,每个选项可以带一句说明;下面还有一行“都不是?写下你的想法”。你点了或写了,AI 在同一步里马上拿到答案,接着干活。AMB 告诉 AI 的说明里写明了:干 AMB 的活时,问题都这样问。
- New `ask_user` tool: a question with options (one may be marked recommended) and a line for an answer of the user's own, shown in the AI workbench; the AI waits for the answer in the same call. The server instructions tell AIs to ask this way, not in their own chat.
### AI 工作台换新外观 / A new look for the AI workbench
- 左边任务栏:“新任务”成了醒目的主按钮,“连接 AI”和“关卡”变成带图标的按钮;下面写着最近连接的是哪个 AI、什么时候;有任务在进行、等你回答或出错时,上面出现对应的筛选按钮和数量;每个任务前的状态换成清楚的图标,选中的任务左边有一条色条。
- 任务标题栏:状态标签带图标;“素材”“全部撤销”“显示展台”是统一样式的按钮,“改名”和“从列表移除”换成铅笔和垃圾桶图标(鼠标移上去有说明,删除悬停时变红)。
- 等 AI 接手的任务:左边一张插图,右边标题;下面是三个带编号、连成一条线的步骤(想让 AI 做什么 → 把这句话发给 AI,大号“复制这句话”按钮 → AI 的每一步会出现在这里,附淡淡的示意);最下面“第一次用?先把 AI 连上 AMB”是一张带插图的卡片,点“查看连接方法”就在原地展开。没有任何任务时的欢迎页也换成插图加同样的卡片。插图是用 ChatGPT 画的。
- The task list leads with a New task button, icon buttons for Connect an AI and the gates, which AI came last, quick filters by state and state icons; a task's header uses icon buttons; a waiting task shows a picture, three numbered steps and a connect card that opens in place; the empty page matches. Illustrations made with ChatGPT.
### 给 AI 的素材 / Materials for the AI
- AI 工作台每个任务上多了“素材”按钮:按来源分组列出这台 ComfyUI 的全部节点(自带的、你装的每个第三方节点包、只在网页里的小工具、会花积分的付费 API 节点单独放在最后),还有你的搭配、工作流、提示词和模型。勾选这次想让 AI 用的,点“交给 AI”;可以选“优先用这些”或“只能用这些节点”(自带节点照常可用,AI 想加别的第三方或付费节点会被拦下)。☆ 设为常用后,没挑素材的任务自动带上常用的。AI 下一步就会收到,时间线上也记着你给了什么、AI 收到没有。
- AI 能在新标签页里打开你存的工作流,在它的基础上改;工作流详情页也有“✦ 交给 AI”。AI 查节点时会标出你挑的节点和付费节点。
- 素材窗口重做:大窗口,顶部一个搜索框搜所有素材(按 / 直接搜),左边分类显示各自挑了几个、搜到几个;节点可以按来源筛选,每个第三方节点包有自己的彩色徽标;节点卡片画成一个迷你节点,输入输出的颜色和 ComfyUI 画布上一样,写着有几个参数;搭配、工作流、模型显示封面;提示词显示原文,并且能直接挑搭配和工作流里的提示词。鼠标移到卡片上,右边显示完整说明(节点的每个输入、参数、输出,提示词全文)。下面一排是挑好的素材,带缩略图,可以一个个去掉;“优先用 / 只能用”做成了开关。图标和节点包徽标是矢量画的,跟着深浅色主题变;空白时的工具箱插图,以及没有封面的搭配、工作流、模型用的占位封面,是用 ChatGPT 画的透明背景插图(每张几十 KB)。
- 打开不用等了:节点列表直接用网页里已有的节点信息,其他素材一次读完、提前读好、留一分钟,打开窗口时先显示留着的,再在后台刷新。
- 实测:你装的 10 个第三方节点包(Impact Pack、IPAdapter、Easy-Use、Comfyroll、rgthree、ControlNet 预处理器等)共 1642 种节点,AI 都能放到画布上;用 Comfyroll 节点搭的工作流真实跑出了图。
- A Materials button on each AI task: every node type of this ComfyUI grouped by source (built-in, each custom node pack, page-only helpers, paid API nodes last), plus combos, workflows, prompts and models; picks go to the AI as prefer or only-these-nodes (built-in nodes stay allowed), favourites stand in for tasks without picks. The AI can open a saved workflow in a new tab; a workflow's page can hand it to the AI.
### AI 后端修复 / AI backend fixes
- 出图结果按本次提交返回的准确运行 ID 归属,不再通过全局队列差集猜测;其他 AI、其他页面或后续手动排队不会混入。批次只提交成功一部分时,保留这些运行的进度和图片,并明确报告未全部提交;不会自动重跑整批。旧页面需刷新后才能接手出图,不支持请求 ID 的旧版 ComfyUI 前端会在提交前提示更新。同一页面正在提交时另一 AI 收到忙碌提示,待提交结束即可重试。
- Workflow runs now follow exact submission IDs in batch order. Partial submissions keep accepted runs/results and report the missing portion; no automatic resubmission. Stale pages cannot claim runs until refreshed, unsupported host frontends fail before queueing, and a second AI waits while the same page is submitting. Native widget/seed behavior is preserved and later manual batches do not delay the AI receipt.
- “允许一次”只用于一次调用,不会被相同参数的后续调用重复使用;无法建立确认记录时不会直接执行。修复确认超时后用户刚好回答、工具返回又覆盖答案的问题;重启后的待确认步骤仍能把后续答案交给 AI,回答也会唤醒正在等待的 AI。
- 指定任务或会话读取留言时,不再取走同一 AI 客户端其他任务的留言、标记和回答。
- 没找到排队记录、无法读取运行记录、跟踪超时均明确标为未完成,不再误报完成或永久显示运行中;批次内出错及时记录,批次进度保留总数。跟踪停止不会取消 ComfyUI 实际运行。
- 标图的校验和保存移到工作线程;跨盘图片路径返回校验错误,错误类型的 MCP 工具名返回协议错误。
- One-shot approvals cannot be reused, missing approval records fail closed, and timeout/restart races preserve user decisions and wake waiting readers. Task/session input stays isolated. Missing or unreadable run history and monitoring timeouts no longer imply success or leave receipts running forever. Marked-image validation/storage runs off the event loop; malformed image paths and tool names return controlled errors.
### AI 工作台与插件能力融合 / Integrated task results
- 工作台的出图结果和 AI 展台图片:鼠标移到图片上,右上角出现 ⓘ 图片与参数、⇄ 和当前画布对比、☆ 保存为(同一个保存菜单存成工作流、搭配和提示词),不再在每张图下面排三个大按钮。左上角可以勾选:勾两张(不同步骤、不同任务都行),页面底部出现“查看参数差异”,用插件现有的参数对比看真实差异;勾第三张会替换最早那张。“本次成果”直接显示缩略图,不用再点开。视频和临时文件没有这些参数操作,缩略图按需加载。
- AI 干活时你在 AMB 里做的改动(画布改动、改模型信息、存搭配和提示词等)会作为“你在 AMB 里改了”出现在同一条时间线上,画布改动附带修改前后;AI 下一次调用任何工具时会收到这条改动,并被告知保留它,不要改回去;时间线上写着 AI 是否已经知道。操作记录页里 AI 做的、以及 AI 干活期间你做的记录,旁边有“AI 任务 →”可以跳到对应任务。
- ComfyUI 在 AI 某一步进行到一半时重启,这一步不再永远显示“正在执行…”,而是标成没有结束。
- **AI 软件里直接显示 AMB 卡片**(MCP Apps,Claude、ChatGPT、VS Code 等支持的软件):AI 开始任务、出图、画面板、留言时,对话里会出现一张 AMB 卡片,显示它在做哪几步、最新的几张图,和它问你的问题。在卡片里点选项就算回答,AI 马上收到并接着做。为了安全,“关卡”只能在 AMB 里点允许,卡片里只提示你去 AMB。Gemini 命令行和 Claude Code 终端版不显示卡片,照常用 AMB 的工作台。
- **在图上标给 AI 看**:AI 工作台里的图片,鼠标移上去多了一个 ✎。点开后可以用框、圈、箭头、画笔(红黄青三种颜色)在图上标出要改的地方,每个标记写一句“这里要怎么改”,再写整体的想法,点“发给 AI”。AI 下一步就会收到这些说明和这张标好的图(能看图的 AI 能直接看到标在哪),时间线上也会显示你标的图和说明。标好的图存在 AMB 自己的记录里,不会混进图库。
- **运行进度 AI 也看得见**:AI 让工作流开始运行后,时间线上那一步下面有进度条:排队时写“前面还有几个”,运行时写正在跑哪个节点、第几步(比如 采样器 14/28)和节点做完了几个。AI 多了一个 `run_status` 工具,可以随时问进度,也可以等到跑完(每次最多等 55 秒),跑完直接拿到出的图;运行的回复里也会提醒它用这个,别凭时间猜有没有跑完。进度条右边显示百分比(和 ComfyUI 浏览器标签上的一样:正在跑的节点走到第几步)。AI 不用自己去问:运行期间它调用任何工具,回复里都附带一句当前进度(比如“采样器 14/28,50%,节点 3/9,已经 42 秒,还没跑完”),跑完后的下一次回复会告诉它跑完了、出了哪几张图,只说一次。
- **提示词改动一眼看清**:AI(或你自己)改了提示词时,记录里先列出“去掉 N 个”(红色划掉)和“加上 N 个”(绿色)的词,点“看词义”用 AMB 的翻译在每个词下面显示中文意思;“看完整的修改前 / 后”里,两段全文中变化的词也标了颜色,没变的变暗。其他文字改动会把不同的那一段标出来。操作记录里提示词改动最多保留 2000 字(以前 400 字),整段提示词都能对比,不会把被截断的半个词算成改动。
- **等 AI 接手的任务**重新排版:中间一个输入框写想让 AI 做什么(就是任务名),下面是要发给 AI 的那句话和“复制这句话”,再加三个小步骤;第一次连接 AI 的说明收进“AI 还没连上 AMB?”里,不再整屏铺开。等 AI 的时候不显示留言框。
- **交给 AI**:模型页、图片详情(右上角 ✦)和搭配卡片上多了“交给 AI”。写一句想让 AI 做什么(已经帮你填好,比如“用这个模型出一张图”),AMB 会建好一个带着这个模型、图片或搭配的任务,再给你一句话贴给 AI;AI 接手时会直接拿到它们,不用再自己去找。
- **斜杠命令**(MCP prompts):在 Claude 等软件里可以直接选“在工作台开一个任务”“出几张让我挑”“补齐缺的模型”“讲讲现在的画布”,AI 会按适合在工作台里看的方式去做。
- **流程图**:任务标题下面一排“模型 › 搭配 › 提示词 › 运行 › 挑图”,按 AI 做事的先后排列,每段标着做完了、正在做、等你点头或出了问题;点一下跳到时间线上那一步。
- **关卡**:任务列表上方的“关卡”里,改画布、用显卡出图配音、下载模型、联网扫描四类事,各自可以选“直接做 / 先问我 / 不许做”(默认只有下载要先问)。选了“先问我”,AI 的这一步会停在时间线上,列出它要做什么,等你点“允许”或“不要”(可以写上原因或想怎么改,AI 会收到);勾上“以后直接做”就不再问。你不在工作台时右下角会提示“AI 在问你”。AI 等不及先走了也没关系:你回答后它下一步会收到,允许的话再做一次,不会问第二遍。
- 画布操作直接显示记录中的“修改前 / 修改后”、新增或删除的节点;与操作记录页共用展示,保留定位和适用时的撤销。旧记录缺失时明确提示,不用当前画布猜历史。任务页更新隔离迟到请求,并整理了结果卡片、时间线和窄布局。
- 修复参数对比窗口遮挡撤销提示的问题,撤销按钮现在可直接点击。
- Task outputs and AI panel pictures now use the existing image inspector, Keep menu and image/canvas parameter comparison, as tools over each picture on hover; tick any two pictures (any step or task) and compare their settings from the bar at the foot of the page. The results strip shows thumbnails straight away. Changes you make in Anomalous while an AI works join its timeline, and the AI is told of them (to keep, not undo) with its next call; activity entries link to their AI task. Steps a restart interrupted are marked as not finished instead of running forever. In apps that support MCP Apps (Claude, ChatGPT, VS Code…) an Anomalous card in the conversation shows the task's steps, latest pictures and the AI's questions, answered with a click (gates are only allowed in Anomalous). Four ready-made prompts appear as slash commands. "Hand to AI" on a model's page, an image's inspector and a combo card starts a task carrying it, with a sentence to send the AI. A flow over each task shows its stages (models, combo, prompt, run, pictures) in order with their state. Gates: changing the canvas, using the GPU, downloading and scanning online can each go ahead, ask you first or never happen (downloads ask by default); a step that asks waits on the timeline with what it would do, for Allow or No (with your reason for the AI). Canvas changes share recorded before/after receipts with the activity page. Unavailable history is explicit; stale task responses cannot replace a newer selection. Refined result cards and timeline styling, and fixed Undo toasts being blocked by the comparison overlay.
### Fixes / 修复
- AI 工作台与 MCP 调用实时生命周期及消息送达:每个工具调用开始时立即写入“正在执行…”步骤并接手等待中的任务,完成后原位更新同一步骤(不再等到耗时操作结束后才显示,也不会重复生成步骤);用户在工作台留下的消息和展台面板回答,除了可通过 `get_answers` 读取外,还会在 AI 下一次成功调用任意工具时自动附带送达一次,并立即在界面上更新为“AI 已经看到”;多会话与显式 `task` ID 互不串任务。
- Live in-flight MCP step lifecycle and automatic delivery of user messages and panel answers: every tool call now starts a `"running"` step immediately and updates the same step in place on completion or failure; unread user messages and panel answers are piggybacked once onto the next successful MCP tool response (in addition to `get_answers`) and immediately notify the workbench UI; concurrent sessions and explicit `task` ids stay isolated.
- 展台内容按出现顺序保留:`update_panel` 新增的成图、说明等接在已有内容下面,即使 AI 把整张看板按“结果在前”重发,也不会倒置旧内容。省略旧内容不再将它删除;重复提交不重复追加。明确指定积木 ID 时仍可原位更新进度、问题或修正内容,已有回答保留。超出 30 块时提示新开面板,不截掉旧内容。旧记录保留其已保存的排版。
- Panel updates append new output after what the user already saw, preserve omitted blocks, and avoid duplicating unchanged resubmissions. Stable block ids allow deliberate in-place edits; invalid or over-limit updates leave the panel untouched. Historical records keep their saved layout.
- 展台右上角增加“关闭展台”,关闭后时间线展开;任务标题旁的“打开展台”或时间线里的面板入口可重新打开,保留面板和未提交的输入。当前任务更新不会强行重开。面板编号改为“来自第 N 步”,明确它的创建位置。The stage can be closed and reopened without losing panels or drafts; step labels now say “From step N”.
- AI 工作台:AI 先查询再调用 `begin_task`,现在会继续已接手的任务,保留用户标题和之前的步骤;重复调用不会拆任务。另一件事可用 `new_task=true` 明确新建,已结束或长时间闲置的任务仍分开记录。
- 展台打开历史任务时定位到底部;新增面板、更新最新面板及图片加载后继续跟随底部。往上翻看时保留位置,用“查看最新”回到底部,时间线的定位链接仍跳到对应面板开头。
- The workbench keeps preliminary reads and repeated `begin_task` calls in the active task; `new_task=true` explicitly starts separate work. The stage opens at the bottom, follows updates and loaded images while there, and preserves the reader's position when browsing earlier content.
### ✨ AI workbench: see everything the AI did, open it, undo it (AI 工作台)
- **A new page on the rail, *AI workbench***: every step an AI app (Claude Code, Cursor, Cherry Studio… connected through Anomalous's MCP) takes is kept, grouped into **tasks**. The AI names a task when it starts (a new `begin_task` tool); otherwise one app's calls that follow each other make one task.
- **A task is a timeline**: what it looked up (folded into one line, a click shows each), what it changed on the canvas, the workflow runs, downloads, scans and spoken lines, and the AI's own notes (a new `report` tool, also for pictures it made another way). Each step says when, how long it took, and why it failed when it did. Steps are **numbered in order** (1, 2, 3… down the line), each dot in its kind's colour; while the AI works, the next number waits at the foot.
- **What it made stays, and leads there**: the pictures a run made are kept with the task (watched until the run ends) and shown on top as *What it made*; a click opens the picture, a model opens its page, a combo opens the combo, a spoken line plays, a canvas change is found on the canvas or **undone** (also all of a task's canvas changes at once, stopping at anything you changed since).
- **Live**: a task the AI is still working on grows by itself and says *The AI is working…*; the page follows the newest task unless you picked another. While you are elsewhere, the rail's *AI workbench* gets a dot. Home gets *What the AI did lately*.
- **New task (新任务)**: start a task yourself (with what the AI should do, or empty): it waits for an AI (*Waiting for an AI*), and the next AI app that calls takes it over, so its work starts there instead of joining its last task.
- **Connect an AI (连接 AI)**: always beside the task list, and on an empty page or a waiting task: Anomalous's address, a sentence to copy to your AI so it adds the server itself (and starts on the waiting task), and the way for Claude Code, Gemini CLI, Cursor, VS Code and Cherry Studio.
- **Write to the AI from the workbench** (给 AI 留言): a box under a task's timeline. The message becomes a step of the task (*You*) and reaches the AI the next time it asks (`get_answers`, which now also brings the user's messages, and with no panel named waits for one); it says whether the AI has seen it. Anomalous cannot wake the AI: when it has stopped, the box says to ask in the AI app.
- The task list is grouped by day (today, yesterday, a date), each task with an icon for where it stands: working ●, waiting for you ?, a step failed !, done ✓.
- **The stage (展台)**: the AI's panels are shown in a column on the right, beside the timeline, so they stay in view however many steps come after them; the timeline keeps one line per panel (*… on the stage ▸*), which brings the stage to it. Like the timeline it reads from the top down: the task's panels in the order they came, each with its step number, **the newest at the bottom**; a new one scrolls the stage down to it while you are at the bottom, otherwise *↓ New panel* says it came. The strip of what a task made also runs oldest to newest, left to right. In a window too narrow for three columns, the timeline and the stage are two tabs (the stage tab lights up when a question waits, and comes forward when one arrives). The task list can be folded away (‹) for more room.
- While the AI works, only the steps that changed are drawn again: a diagram or a strip of pictures scrolled sideways no longer jumps back, and text half-typed into a panel or the message box stays.
- Diagrams read in order: a box that only feeds one further on (a prompt into the sampler) stands right before it instead of at the far left with an arrow across everything, and boxes in a column are sorted by what feeds them, so arrows cross less.
- Tasks can be renamed or removed from the list (only the record goes: pictures and the canvas stay). The newest 300 are kept, in ComfyUI's user folder. Without any AI connected, the page shows the address to add in the AI app.
- Only what goes through Anomalous's MCP is seen; the AI can report the rest. The rail now has ten entries: the number keys 1–9 open the first nine.
### 🧩 The AI draws panels, and asks you on them (AI 画面板,在面板上问你)
- **A new `show_panel` tool**: the AI lays out a panel in its task, in Anomalous's own style, from fixed blocks: headings and text (also as a tip or a warning), **pictures** (a row, a grid, or two **side by side to compare**), **your models as cards with their covers** (a click opens them), tables, lists, settings, a progress bar.
- **It can ask**: buttons (one pick or several) and a line to type into. Your answer is handed over and waits for the AI, which reads it with the new `get_answers` (it can wait up to a minute for you); each question says whether the AI has seen your answer yet. `update_panel` changes a panel (a progress bar going up) or closes its questions.
- **Safe by design**: nothing the AI sends is run or read as markup. Every block is checked on the server (only these kinds, plain text of bounded length, pictures only from ComfyUI's output folder, models only from your folders) and written into the page as text.
- When the AI shows a panel while the browser is elsewhere, a small card in the corner says so (*The AI asks you: …*, **Have a look**); a task with a question waiting says *Waiting for you* in the list.
### 🎞️ The AI's stage: marks on pictures, before / after, picks, diagrams (AI 展台)
- **Marks on a picture**: the AI circles or boxes places on a picture it made (*① the fingers are fixed*, *② softer light*); each mark has its number, the labels are listed under the picture, and a click on one **zooms the picture there** (again: back).
- **Before / after slider**: two pictures over each other, a line to drag across to compare the details.
- **Picks**: pictures side by side, each with the AI's reason and **Take this one** (the choice goes to the AI); the AI can mark the one it recommends. Each can also be **put on the canvas** (its saved workflow opens in a new tab, as dropping the picture on ComfyUI does) or dragged onto the canvas.
- **Diagrams**: boxes and arrows, laid out by Anomalous (in columns or rows), with a label on an arrow; a box naming a canvas node (◎) finds it on the canvas.
- The AI only sends data (places as fractions of the picture, what connects to what); every shape and line is drawn by Anomalous, so it looks the same whichever AI is connected, and no drawing code of the AI's is taken.
## v1.61.1 (Delete a Character) — 2026-10-28
- 角色卡上加了橘色的 **删除角色** 按钮(Anomalous_TTS 议题 #2):确认以后,角色的整个文件夹(模型、参考音频、设置)移到系统回收站,需要时能从回收站还原;U 盘、网络盘上没有回收站就不删。设置坏了、读不出来的角色也能删。需要 Anomalous_TTS v1.1.1;旧版 TTS 不显示这个按钮,其他照常。
- Character cards have an orange Delete character button (Anomalous_TTS issue #2): after a confirm, the character's whole folder (models, reference audio, settings) goes to the Recycle Bin, where it can be restored; on a drive without one nothing is deleted. Broken characters can be deleted too. Needs Anomalous_TTS v1.1.1; with an older one the button is not shown and nothing else changes.
## v1.61.0 (Quick Search, Canvas Menu, Combos From Pictures, Tidier Model Folders, Multi-Character Voice-over) — 2026-10-25
### 🔎 Quick search on the canvas: Alt+B (画布快速搜索)
- **Press Alt+B over the canvas** (or right-click the empty canvas → AMB → *Quick search…*) and type: combos, models and saved prompts that match show at once, with their pictures. A model is found by its name, file, base model or trigger words; a combo by its name, models or prompt; a prompt by its text.
- **Enter puts it where your pointer was**: a combo as its group of nodes and a model in its loader node, both following the pointer until you click; a prompt as a new prompt node. ↑ ↓ pick, **Tab** shows only combos, models or prompts, Esc closes.
- The shortcut is a ComfyUI command (*AMB quick search*), so it can be changed in ComfyUI's keybinding settings like any other. Nothing is read until the bar opens.
### 🖱️ AMB in the canvas's right-click menu (画布右键菜单)
- **Right-click a node → AMB** offers what Current node does for it, without opening the browser first: **Open in AMB** (a prompt node opens Prompts on it, any other node Current node), **Change model by picture**, **Insert a LoRA before / after**, **Apply saved values**, **Pictures made with this model**, **Model details**, **Save this node's values** and **Save as combo**. Each entry shows only where it applies: a checkpoint loader has no "before", a prompt node no model entries.
- **Right-click the empty canvas → AMB** opens any of AMB's pages.
- **Make it yours: Settings → Canvas → Right-click menu**. Switch each entry on or off and move it up or down; a line above each list shows what the menu will offer. Two cards, each with a small drawing of the menu, choose whether AMB's entries sit **together under "AMB"** (the default, ComfyUI's menu gets one line more) or are **listed in the menu**. *Adjust this menu…* at the end of the submenu leads there. **Save as combo** moved into the AMB submenu with the rest.
- In the model picker, Esc now closes the picker wherever the focus is, and only the picker: it used to close the browser behind it when the search box had lost the focus.
- The floating AMB button no longer sits on the head of a model picker opened from this menu (with the browser closed).
- Fixed: when a model folder is a link (a junction to another drive, say), a combo kept from a picture and other lookups of saved model paths got a wrong place for the model, or failed.
### 🧩 A combo's page, redone (搭配详情重做)
- **A head that says what it is**: the picture (the one you kept it from, with a **See the picture** button; else the main model's cover), the name in large type with a **✎ Rename** button beside it, and chips for its base model, main model and LoRAs. **Put on canvas** and More (delete) sit under them.
- **Only what is chosen**: the main model and the LoRAs are short rows with their covers; **Change** and **+ Add a LoRA** open the grid of compatible models only when you want it, instead of every model of that base filling the page. ✕ takes a LoRA out; the numbers say their order.
- **No Save button**: every change (models, name, prompt) saves itself, as it already did. Fixed: a tag changed in the bilingual view was not saved into the prompt.
### ★ See only the kept pictures (图库只看收藏)
- **★ Kept** beside the gallery's search lists only the pictures you kept as a workflow, combo or prompt (the ★ on their cards). Works with the search and with Pictures / Videos. Pointing at a card's ★ says what it was kept as.
### 📌 A picture kept as a combo keeps its nodes as they were (图片存搭配,节点原样记住)
- **Keep → Its combo** now keeps the nodes that made the picture's model and prompts: the model and CLIP loaders, the whole LoRA chain (Power Lora Loader and other packs' nodes too), CLIP skip and model patches, the positive **and** negative prompt nodes, with every value as the picture had it. The sampler, size and decoding are left out, so Put on canvas gives a group you wire to your own sampler, like a combo saved from the canvas.
- It used to keep only the main model, the LoRAs' names and the positive prompt: LoRA strengths came back as 1.0, and the negative prompt and CLIP skip were lost. **A combo only remembers; it never changes a value** (now one of the plugin's rules). Combos kept before stay as they are.
- Models are named by where they are on this computer; one this computer lacks is kept by the picture's name and said after keeping. A workflow without a sampler to read from still gives the simple kind.
- Fixed: after a picture was kept (as a workflow, combo or prompt) or its parameters compared, the prompt boxes of its workflow could stay on ComfyUI's page and show at its top left, over the tabs. The workflow read off the picture is now taken away once it is read.
### 🩺 Model Check and ComfyUI-Manager's list (模型检查与 Manager 清单)
- A **Download by name** from ComfyUI-Manager's model list now also says where the list's file is (the site and repository), to help you decide.
- A list entry that does not say its type is no longer offered for a node whose type is known.
- Without ComfyUI Manager, Model Check says it would add one more place to download from when a model's source is not found.
- A foundation component (VAE, text encoder, CLIP Vision…) no source was found for gets **Search Hugging Face** (its mirror when that is on): the search page for its file name, for you to pick from.
- The rules of Model Check (what counts as the same model, the confidence levels, foundation components, download sources and where ComfyUI-Manager fits) are now written in one place: `docs/guides/model-check-rules.md`.
### ⭐ Combos kept from a picture are easy to tell apart (图片存的搭配一眼认得出)
- **The card shows the picture you kept it from**, with the main model's cover as a small round badge in its corner, instead of the model's cover alone (several combos of one model used to look the same).
- **A name that says which one it is**: the first LoRA (or the main model) and the prompt's first two telling tags, like *Ahri · fox ears, kimono*, skipping words nearly every picture has (*1girl*, *solo*, *looking at viewer*…).
- **Rename it right away**: the message after keeping shows the picture and the name in a box; type a new one and press Enter. A combo's page has a Rename button too.
### 📒 Prompt cards look like notebook pages (提示词卡片换成笔记本样式)
- In Prompt Studio each card is a page with a bound spine in its colour (green positive, red negative), its title, a Positive / Negative label and its first words as small tags instead of a cut-off line of text. The card open as the reference wears a ribbon; Common and My prompts are index tabs with how many cards they hold. Dark and light themes alike.
### 🗂️ Tidier model folders (模型文件夹更清爽)
- **Settings → Models → Files that come with models**: what a scan found (`.info`), your names and notes (`.anomalous.json`) and Civitai's picture kept aside can go **in a hidden `.amb` folder** inside each model folder instead of beside every model, leaving only the model and its cover there. Covers stay beside the model, where ComfyUI and other plugins look. Choosing a place says how many files will move, then moves them (only moves; switch back any time); a file that cannot move still works where it is.
- **No more duplicate cover copies**: scans used to keep a copy of Civitai's picture identical to the cover for every model. Now the cover is noted as Civitai's and a copy is kept only when you put your own cover in its place (so you can still bring Civitai's back). **Check and tidy** in the same place sends the existing duplicates to the Recycle Bin.
### 🎭 Several characters in one script (多角色配音)
- **Every line has its character**: on Voice-over, click a character in the list on the left, then paste: the lines are its. To give lines to another, pick them (a click; Ctrl / ⌘ adds one, Shift a run; Esc lets go) and choose who reads them on the right, or click the name on a line for a menu of characters. Each line shows who reads it in that character's colour, and its emotions are that character's. The *Who speaks* bar at the top is gone: the list does its job.
- **Three layers: the whole script, a character, a line (整份剧本 / 角色 / 句子,三层分清)**: the left side picks, the side panel changes. Nothing picked: the **whole script**, what everyone reads with, and each character in it. A character clicked in the list: that character **in this script** (only its lines). Picked lines: **only them**, with who reads them. A path on top, *Whole script › Aris › Line 2*, says which layer you are changing and leads back up (Esc too). Every layer has the same three rows, speed, tone and language, and each row says where its value comes from: **this line** (yellow), **the character** (purple), **the whole script** or **the default** (grey), or the character's **usual** setting; a click on it goes to that layer, ↺ lets this layer follow the one above. The more particular wins: a line over its character over the whole script over the usual settings. The settings that last beyond this script (the tones, a character's own settings) are links at the foot, apart from the rest. The tone can be heard on that line right there; *Numbers* shows how it compares and its numbers. With many characters, *Read by* lists the ones in this script (with their colours) and the rest under *Other characters*, a menu with a search box; a card's name menu gets the same search box.
- **Drafts (草稿箱)**: every script is kept as a draft on its own, half a second after a change, in ComfyUI's user folder (so clearing the browser or using another one loses nothing). A bar over the script names it (a click renames it; unnamed, its first line names it) and has **New** (a blank script, this one stays), **Drafts** and **Close script**. Drafts shows a card per script, the last changed first, with who reads it and how many lines: open, rename, copy, delete. Deleted scripts wait in *Recently deleted* for 30 days. A reload opens the script that was open; a file voiced again from the audio gallery becomes a script of its own. Two tabs saving at once keep each other's drafts. The script the earlier 1.61 build kept in the browser moves in by itself.
- Editing the script (*Edit text*) keeps each unchanged line's own settings; it used to drop them.
- A character with no line in the script yet (clicked in the list) hears a **sample sentence** on ▶, and the panel now says so (*▶ Listen to a sample*); it used to look like a line that was not in the script. On the whole script, ▶ reads the line heard with its own character.
- **Plainer words (更好懂)**: the mode is now the **tone** (语气), the presets are **Steady / Natural / Lively** (平稳 / 自然 / 生动), *Follow* is *As the character* (同角色), *Save as default* is *Always read like this* (以后都这样念), a character's *Generation settings* are its *Own settings* (专属设置), and the GPT-SoVITS pretrained files are *base files* (基础文件). The names inside the node (stable / standard / lively) are unchanged, so saved workflows and settings keep working.
- **GPT-SoVITS Settings, in tabs (GPT-SoVITS 设置分类)**: a chip beside the title says everything is ready, or what is missing; tabs show one part at a time: **Tones** (which one to use, with a *Simple / Numbers* switch), **Other languages**, **Base files** (a dot when something there needs you, and the window opens there), **Where things are**, **Version**.
- So one script can have a calm narrator, a lively character and one line read slower, all in one go. This needs Anomalous_TTS interface 17 (1.1.0): the whole script's values go into the node's own inputs, and only the lines that differ get `[lang:…]`, `[speed:…]` or `[preset:…]`, so the text pushed or copied to a node stays readable; a file voiced again from the audio gallery comes back with them. With an older Anomalous_TTS the whole script is read as before, as its first line's character reads, and the panel says so.
- **Picking and editing no longer get in each other's way**: a click on a line picks it; a double-click (or ✎) edits its text in place, Enter keeps it and Esc puts it back.
- **The list on the left is the cast there**: narrower, the characters with their colours only; a click there never takes you back to the Characters page.
- Generating, ▶ on a line, pushing, dragging and copying all follow who reads each line: the script carries `[name]` where the speaker changes (Anomalous_TTS reads it already), and in Auto mode each character uses its own generation settings. A file voiced again from the audio gallery comes back with its speakers.
- The character cards' **Voice-over** button is gone: the switch at the top of the page and the cast do its job.
## v1.60.2 (Fix: the browser opens whatever the plugin folder is called) — 2026-10-25
### 💬 From a Bilibili comment (来自 B 站评论)
- **The browser opens when the plugin folder has another name (插件文件夹改了名也能打开)**: a plugin unpacked from a ZIP of GitHub sits in `Anomalous_Model_Browser-main`; the browser's look did not load there, so a click on **Model Browser** seemed to do nothing (the browser lay under the canvas, without its look) and that button showed in the top bar although the floating button was chosen. The plugin now finds its files under whatever name its folder has. Updates to the plugin also reach the page at once there again, as with the usual name.
## v1.60.1 (Cloud Servers, Generation Presets, Full Window, Faster Scan Page) — 2026-10-22
### 🎚️ Reference audio: every clip in a list, main and auxiliary references (参考音频:全部列出,主参考和副参考)
- **Reference audio and emotions** (the character card's button, formerly *Edit emotions*) is now a large window. On top, the **voices**: the main voice, each emotion with how many references it has (*1 main, 2 aux*), and **+ New emotion**. Below, **every clip** of the character, one row each: ▶ to hear it, its file and length, its line, and where else it is used (*Main voice · aux*). For the voice picked, a row is its **Main** reference or one of its **Aux** references, or nothing; unused clips just stay in the list. Filters (*All*, *This voice*, *In use*, *No line*) and a search by line or file name; the first 60 rows, then more.
- **Auxiliary references (副参考音频)**: a voice keeps one main reference (its tone and way of speaking, 3–10 s, with a line) and can take any number of auxiliary ones, whose timbre is averaged in when it speaks, as GPT-SoVITS does with several references; they need no line. Only v1 / v2 / v2Pro models use them (the controls say so for v3 / v4).
- **A clip's line can be fixed right in its row** (改参考台词): the fixed line is kept in the character's settings and used before its .txt / .list files, which stay as they are. A line that is missing shows *No line found: click to type it*.
- **🗑 on a row moves that clip to the Recycle Bin** (删除参考音频), with its own .txt / .lab, after saying where it is used; it is taken out of those voices and the settings. Never a permanent delete: where there is no Recycle Bin (a USB stick, a network drive) nothing is deleted and the window says so.
- Clips too long or too short to be a main reference are marked (they can still be auxiliary). The main voice can go back to the node's own pick; an emotion can be renamed or deleted (one found from a file name is changed by renaming the file). Closing with unsaved changes asks first.
- Auxiliary references, fixed lines, lengths and 🗑 need Anomalous_TTS interface 18 (1.1.0); with an older version the window works as before for main references and says what needs the update.
### 🔍 Scanning (扫描)
- **The scan page opens at once, also with many models**: it shows the last counts right away, says it is updating them, and shows the fresh ones when they come. What each model file's header says is now kept on disk (`user/anomalous/model_heads.json`, by file, read again only when the file changes), so a big library or a network drive is not read again after every restart either; requests that come while the page counts share that one count.
- **See the models found on Civitai**: the scan page's "Found on Civitai" count opens a list of them, with each model's Civitai name and base model, a box to find one by name, and a way to open each.
- **Before an online scan, the plugin checks whether Civitai can be reached**, on both its addresses. When it cannot, it asks instead of waiting on every model: **Scan from the files** (base models found, no covers or trigger words; a later scan fills them in), **Set a proxy** (opens Settings → Models → Network), or Cancel. A check that reached Civitai is trusted for five minutes.
- **Civitai's other address (civitai.red) when civitai.com does not answer**: some networks reach only the second, a cloud server in mainland China among them (tested on AutoDL: civitai.com timed out, civitai.red answered in about a second). Scans, import and Model Check's lookups and **Settings → Network → Test** ask civitai.red then, so models still get their names, trigger words and base models; the test says which address answered. The covers are on Civitai's picture server, which such networks often cannot reach either: after two covers in a row that would not download, a scan fetches no more covers instead of waiting on each.
### 🧩 Combos (搭配)
- **A combo's models are cover cards**, as in the model library: the cover on top, the name below. In a combo saved from picked nodes, the slot (Main model, LoRA…) is written on the cover and a click on the card changes the model; in a default combo, the main models and LoRAs to pick from are cover cards side by side. They used to be rows as wide as the window, with **Change** far from the name (模型卡片不再横向拉满整个窗口).
### 🎙️ Character voices (角色语音)
- **Update Anomalous_TTS from GPT-SoVITS Settings (在 GPT-SoVITS 设置里更新 Anomalous_TTS)**: a new *Anomalous_TTS version* line shows the installed version; **Check for updates** looks up its published stable releases (only then does it go online), **Update to v…** switches to the newest one and offers to restart ComfyUI, and **Back to v…** undoes it. Anomalous_TTS now publishes numbered stable releases (the first is v1.0.0), so an update never lands on unfinished work. Only an Anomalous_TTS installed with git from its GitHub repository is changed (as ComfyUI Manager installs it), only from the computer ComfyUI runs on, and never over files you changed. When an import needs a newer Anomalous_TTS, the message points here.
- **Generation presets (生成预设)**: GPT-SoVITS Settings has three presets, **Steady** (fewer slips, flatter), **Standard** (the old default) and **Lively** (livelier, slips now and then): pick the default one for every character, and one for each pair of languages, such as a Japanese character speaking Chinese (until now every such line was made steadier without asking; that is the rule you start with). With nothing changed, voices sound exactly as before.
- **Presets you can hear and compare (听得到、比得出的预设)**: pick a preset and see where it sits between *steadier* and *livelier* next to your default (a ring for the default, a dot for the one picked), with a sentence such as "a little livelier than your default (Standard)". **▶ Listen to this one** has a character read a line with that preset; every take uses the same seed, so only the preset changes, and the presets already heard stay as buttons to click back and forth. Folded under it are the numbers as bars against your default, the way the Current node card shows a change (old → new, ↑ ↓); each has its plain name and the usual one beside it (`top_k`, `top_p`, `temperature`, `repetition_penalty`), so values from the GPT-SoVITS WebUI are easy to match. Click a number to change it; a changed built-in number shows its built-in value, and **Reset** puts it back. **Make default** sets the preset every character uses. **+ New** makes your own preset from a copy of the one picked, with a name of your own (such as 念旁白 for narration); it can be picked everywhere the built-in ones can, renamed, and deleted (the question says what uses it; those places go back to the general setting).
- **Modes on Voice-over (配音时直接选模式)**: under *How it is read*, **Auto** (the presets and the character's own settings decide, line by line) or any preset for the whole script, shown against Auto the same way and heard from the **▶** on each line: it reads that line in the mode picked, and the modes already heard on it stay on the side as buttons to compare. The scale and the numbers fold away there, so the narrow side stays short. A preset's numbers can be changed right there and are saved into that preset. Links lead to the presets and to the character's generation settings. This replaces the folded fine-tuning numbers, which showed the node's defaults rather than what the presets used; **Save as default** now keeps the language and speed only.
- **A character's own generation settings (角色特调)**: a new **Generation settings** button on each character card sets the preset and speed for the character as a whole and for each language it speaks, with the numbers themselves when a preset is not enough. Under each row are the numbers its lines will get, blue where they differ from the general presets. The node follows these settings too, so a workflow in ComfyUI sounds like generating here; numbers changed on the node itself still go first. Needs Anomalous_TTS v1.1.0 (update it from the same dialog).
- **More room on Voice-over (配音页更宽敞)**: each line is one row (number, text, its buttons), so more lines fit; a bar between the lines and the generating side can be dragged to share the width (remembered; double-click for the default, about half each); *Into your own workflow* is folded to one line; who reads and their voices share one row, and the how-to line shows only until there are lines. When the side is wide enough, the modes' scale and numbers are shown open.
### 🪟 The window (窗口)
- **Full window (全屏)**: a new button beside *Dock* makes the plugin fill the whole ComfyUI page, and turns it back; a double-click on the title bar does the same. It is remembered.
### ☁️ ComfyUI on a cloud server (云服务器上使用)
- **How far it goes**: fully supported on overseas cloud servers (RunPod…). On servers in mainland China (AutoDL…) only in part: browsing, offline scans and tidying work, but Civitai is mostly out of reach there, so lookups are unreliable and covers rarely download (海外服务器完整支持;国内服务器只能部分支持). Not fully tested yet: one AutoDL server so far, no overseas one. The cloud guide ends with a section for AI assistants (what is going on, the steps, how to check, what to do when it did not work, what to tell the user about safety), so a user can hand the link to their AI.
- On a rented cloud server (AutoDL, RunPod…) the page is always opened from another device, so since v1.58.1 the plugin only let it look: scans, downloads, moves and saves were refused. Now there are two ways: open ComfyUI through an **SSH tunnel** (the address starts with 127.0.0.1; it counts as the computer ComfyUI runs on, nothing to change), or make an empty file named **`allow_remote_changes`** in the plugin folder on the server and restart ComfyUI. With the file, every device that can open the page may change things; the page can neither make nor remove it, and the MCP endpoint stays for this computer only. How, and the risk: [docs/guides/cloud-server.md](docs/guides/cloud-server.md).
- The **View only** chip beside the page title can be pressed: it says what is refused and the two ways above, with a **Copy** button for the one line to paste into the server's terminal (the plugin's real path on that server already filled in), so there is no folder to find. With the file there it reads **Remote changes on**, in yellow, and its dialog gives the line that turns it off again.
- **Models that are links (模型是快捷方式/链接时)**: on a cloud server such as AutoDL every model in the models folder is a link into the platform's shared model disk. Scans wrote the info beside the link, but the cards read it from the disk behind it: a scanned model showed as not scanned, under the disk's file name (`6b4679…`) instead of its own, and editing, deleting, opening its folder or finding it from a saved workflow could be refused as "outside the models folder". A linked model is now taken as named, with its covers and info beside the link; edits, renames and deletes act on the link, never on the shared disk; moving one in Tidy moves the link instead of copying the model disk's file onto the server. Names leaving the folder (`..`) are refused as before.
- **Models linked from another tool keep their info**: a model that is a link with no info beside it shows the info beside the file it links to (A1111's Civitai Helper leaves `.civitai.info` there). Only read there; covers, edits and scans stay beside the link.
## v1.60.0 (Model Import, Tidy, Parameter Compare, Workflows Rebuilt, Base Model Filter, Settings Tabs) — 2026-10-22
### 📥 Drop a model in, it goes to the right folder (拖进来自动归位)
- **Models page → + Import**, or drop model files anywhere on the models page: each file gets a card saying what it is — Checkpoint, LoRA, VAE, text encoder, diffusion model, ControlNet, Embedding, CLIP Vision, upscale model — with its base model, how sure that is, and one line on what such a model is for. It is read from the file's header only; .ckpt/.pt files, which can carry code, are never opened (you pick their type).
- Where it will go is shown and can be changed; by default it follows the download setting (by base model, such as loras/Illustrious). A file found in your Downloads folder or on the Desktop is **moved** there (instant on the same drive; tick "Keep the original" to copy); any other file is sent as a copy.
- While you look, the whole file is checked against Civitai: SDXL-family LoRAs get their exact base model (Pony, Illustrious, NoobAI cannot be told apart from the file alone) and the model and version name. **A file you already have** (identical content) is pointed out and left out unless you tick it; a different file of the same name is saved as "name (2)". Nothing is ever replaced. The new files are scanned right after.
### 🧹 Tidy: models in the wrong folder, files you have twice (整理模型)
- **The models page has tabs: All models, Tidy and Sources** (Sources was a chip among the model types). Tidy shows a count when it found something.
- **Tidy** reads the "table of contents" inside every model file and shows, as cards with the model's cover and name, two steps. **1. In the wrong folder**: what the file really is and where it belongs, drawn as *Checkpoint → UNet / Anima*, with one plain reason (for example an Anima, Flux or Wan model that is only the main part, which "Load Checkpoint" cannot read — Civitai labels all of these "Checkpoint"). **Move there** takes the model with its cover, info and your notes, keeping its subfolder ("Change place" to pick another); nothing is replaced, and it says which loader node your workflows need instead. **2. Identical files**: the copies side by side, the one in the right folder marked to keep (click another cover to keep that one), and one button sends the extra copies to the Recycle Bin with their covers and info. Scanned models are compared at once; same-size files never scanned when you press **Compare**.
- Models that load where they are anyway (a whole checkpoint in the UNet folder) are not listed.
- **Not used lately (很久没用的模型和节点包)**, under the two steps, when you press **Look for them**, for models under Tidy and for node packs in **Settings → Node packs** (a line under Tidy leads there), from *not used for 30 days*, *for 90 days* to *never used*. AMB now notes what each run uses and what each workflow you open holds (nodes and models, kept on this computer), and counts the tabs open when you look, so a node pack making sounds or videos, a workflow never saved, or a node only put on the canvas counts too; before that note began, your images and sounds (each records its models and nodes) and your saved workflows, recipes and combos tell. Nothing that came in the last 30 days is judged, and while the note is younger than the group you look at, the page says so.
- **Models**, biggest first, with the day they were last used and a warning when a saved workflow, recipe or combo still names one. Tick the ones to drop and send them to the Recycle Bin with their covers and info.
- **Node packs** with how many nodes and how long installed (from the day its folder was made), and a warning when another pack's nodes take what only this one makes. Packs that work only through nodes come first; those that also add buttons, menus or panels to the page are listed apart, since their nodes going unused does not say the rest is. A pack used only through its panels or buttons leaves no record: press **I still use it** and it is listed apart from then on (**Unmark** undoes it). **Find it in the Manager** opens ComfyUI Manager's node list already searched for that pack (its name is also copied), where you disable or remove it. Packs without nodes, which only work on the page or the server, are listed apart: no record can tell whether they are used.
- Importing, uploading and Tidy's moves only work on the computer ComfyUI runs on; opened from another device (ComfyUI started with `--listen`), they say so instead of writing into your model folders.
### 🧬 Filter models by base model (按底模筛选)
- Under the type chips, one chip per base model the listed models have, with counts: **All · Illustrious 21 · Anima 7 · SDXL 9 · Unknown 2**. Spellings of one family count as one (SDXL 1.0 and SDXL). Models not scanned yet are listed by what their file's header tells. Handy for LoRAs: pick the base model of your checkpoint and only the LoRAs that fit are left. It works together with the search.
- The scanner's offline guess of a base model (for models Civitai does not know) now also recognizes LoRAs, Qwen-Image, Wan, Chroma and Hunyuan Video files.
### ✏️ A new model editor, with a source link (模型编辑页重做,能填来源链接)
- **Edit** on a model opens a cleaner editor: the cover large on the left with the choices under it (the current one, Civitai's own cover when you replaced it, your newest pictures, upload, or the whole gallery), and on the right **Name**, **Source link** and **Notes**, each saying what it does. Nothing is written until **Save**; Esc or a click outside cancels. Renaming the file on disk is a tick under the name that says what it means.
- **Source link (来源链接)**: where you downloaded a model (LiblibAI, HuggingFace…), its site named as you type. The details get a button that opens it, beside Civitai's; shared workflows and Model Check point to it when someone misses that model. Only web addresses are kept.
- **Reset** clears the name and notes you set and brings Civitai's cover back; the source link stays.
- Saving no longer marks the name or notes as yours when you did not touch them.
### 🪪 Model details, rearranged (模型详情页重排)
- From a Bilibili comment: the buttons were small and spread over five places, and the file name showed twice. Now the title bar is gone and everything sits beside the cover: a small **← Models / LoRA** to go back (Esc does the same; from Model Check or the assistant it says where it leads), the model's **name**, the size / base model / source line with the Civitai and source links, and one row of buttons: **Add node** (the one coloured button), **Edit**, **Folder** (shows the file in Explorer; pointing at it shows the path) and **Outputs** (pictures made with it).
- The file's full path shows once, at the foot of the details with **Copy** and **Delete model**. Delete is no longer a red button next to Add node; it asks first in AMB's own dialog, and the files still go to the Recycle Bin. The ⬇ (jump to the bottom) button is gone.
- In a narrow window (the browser docked beside the canvas) the cover column is hidden as before, so a small cover now sits beside the name.
- A model's description (Civitai's own text) keeps its headings a little above the text instead of poster size, and a prompt quoted in it wraps instead of making the details scroll sideways.
### 🗂️ The folder column follows the type; open folders in Explorer (文件夹栏跟着类型走,一键打开文件夹)
- The column left of the models used to list every type's folders (raw names like `checkpoints (1)`, `[Root]` rows with 0, nothing marking where you were), repeating the chips above it. Now it shows the folders of the type you picked: **All**, then its subfolders as a tree whose numbers include their subfolders (empty ones dimmed, ▾ folds one), and **In the top folder** for models lying directly in the type's folder. The folder you are looking at is marked.
- **📂 Open** next to the type's name opens that type's folder in Explorer, and a 📂 on any folder you point at opens that one, so you can drop files in or tidy up by hand. Like the details' Folder button, it works only on the computer ComfyUI runs on.
- In a narrow window (docked beside the canvas) the column opens as a drawer over the page; closing it no longer squeezes the page for a moment as it slides away.
### 🎨 The Combos page looks like the others (搭配页和其他页面统一)
- It no longer sits in a lighter, inset box with a shadow, a second "Combos" title and a red ✕: it fills the page in the same colours as Models and Workflows. The explanation of what a combo is folds behind **?** (shown by itself while you have no combos), and the editor's Back reads **← All combos**.
### 🧩 Combos with rgthree's and Easy-Use's LoRA nodes (搭配支持常用的第三方 LoRA 节点)
- Stacking many LoRAs used to mean a long chain of Load LoRA nodes. **New** on the Combos page now also offers **Default combo, LoRAs in Power Lora Loader** (rgthree: every LoRA in one node, each row with its own switch and strength) and **…in Easy-Use LoRA stack** (up to ten per stack node, put on the model by Easy Apply LoraStack), each only when that node pack is installed. You still just pick the main model and the LoRAs; the combo's editor can switch between them later.
- A combo set to a node pack this ComfyUI does not have is put down with ComfyUI's own LoRA loaders, and says so.
- **Save as combo** on canvas nodes keeps a Power Lora Loader's LoRAs: before, its rows were lost and it came back empty. Each row's LoRA is now a slot you can change in the editor, its strength and switch shown beside it.
- **The LoRAs inside a Power Lora Loader are seen everywhere else too (Power Lora Loader 里的 LoRA 也认得了)**: before, AMB looked only at ordinary drop-downs, so a downloaded workflow using one never said its LoRAs were missing. Now **Model Check** lists each switched-on row's LoRA (missing, found by fingerprint, download, put back in), saving a workflow records their fingerprints, workflow cards and a picture's parameters list them with their strengths, a workflow's **models used** include them, and a LoRA's **Outputs** finds the pictures made with it. A workflow's models used also include Easy-Use LoRA stack rows. Only these widely used nodes are read this way; ComfyUI's own loaders remain the reference.
### 🚀 First-run setup on Home (新手一键准备)
- Until your models have been scanned once, Home opens with **New here? Let's get set up**: four steps and **Set up for me**. It checks that Civitai answers (many need a proxy for it; if not, **Retry** or **Scan offline**), offers the optional Civitai key, scans every model with a progress bar in the card (it renames no files and runs in the background), then starts the 30-second tour. It stops to ask when the network is down. **Later** hides it; Home's **New here?** line has **Set up** to bring it back. It is a card on Home, never a popup.
### ≈ What was read from the file says so (离线推断标清楚)
- A model Civitai does not know has its base model inferred from the file, as before; now it says so. Its card reads **推断 SDXL** (it was ≈), and its details open with a note: why Civitai did not have it, that the base model was read from the file and may be off, and **Add a name and source link**, instead of an English placeholder description.
- The scan page says it too: an online scan "looks up 5 on Civitai and infers the base model from the file for any it does not find", and the state chip reads **Not found · inferred from file**.
### 🏷️ Version labels on model cards (卡片显示版本号)
- A scanned model's card shows its Civitai version ("v2.0", "Illustrious", "fp8"…) as a small label above the name, so versions of one model that share a file name can be told apart.
### ⇄ Compare parameters (参数对比)
- Two pictures from the same workflow often differ in only a few values. Press ⇄ on one picture in the Gallery, then on another (or **Compare with the canvas**); the picture details have the same ⇄ for the canvas. The window says whether both use the same nodes and lists only what differs, node by node, A and B each in its own colour: prompts by the tags only A or only B has (marked positive or negative), long names one line per side with the differing part marked, seeds folded away, nodes only one side has listed apart. Two workflows numbered differently still compare the nodes each has one of, such as their only KSampler.
- Against the canvas, tick the values to take from the picture and put them on the canvas in one go (seeds stay); one Ctrl+Z or the toast's Undo takes them back.
- **What changed since the last picture (和最新出图对比)**: **⇄ Canvas vs. newest picture** at the top of the Gallery and of Activity compares the canvas with the newest picture in the output folder, edits made by hand on the canvas included, and can put the picture's values back.
- **Key parameters (关键参数)**: press ☆ before a value's name (tick **Show equal values too** to star one that matches) and it stays at the top of every comparison, for every workflow, kept with your ComfyUI settings.
- **Current node shows where saved values came from (当前节点的参数方案:出处、出图、并排)**: each saved set for the selected node is a card with the picture made with those values (or the picture they were kept from; click to see it large) and a line on where it came from, with the day: **a parameter set of workflow X** (with how many pictures it made, and **Open** to show that set in the workflow), **kept with picture Y** (**See it**), or **saved here**. The values it would change are drawn instead of written: numbers as two bars (now and then) with ↑ or ↓, choices as the old one struck through → the new one. What stays (seeds, models, choices this computer lacks) is said with why; **Show all** lists every value, equal ones too (there is no more "4 shown, N more"). **⇄ Compare** opens the comparison of the whole workflow with the canvas, not only this node.
- **Where the node's values came from (上次套用的是哪套)**: a line on top of the parameters says which set was last put into this node and when ("上次套用「阿罗娜K4.0 · 02」· 今天 18:02"), and what changed since ("之后变了:cfg 5 → 6.5"), with **Put them back**; that set's card is marked. It is remembered in this browser, per workflow and node, and forgotten when the change is undone. Without it, the line says which saved set the values match, or come closest to ("只差:cfg 6 → 5").
- **Side by side (并排)**: with two sets or more, switch to a table: the current values and each set in columns (its picture on top), the values a set would change marked, **Use N** under each. The choice is remembered.
- **Parameter sets in view (参数方案更好找)**: a workflow card with saved sets shows **▤ N** (how many), which opens them directly. Each set there shows its key values (those unlike the workflow's own in colour), how many values differ, and ⇄ to compare it with the workflow.
### 🗂️ A tidier Workflows page (工作流页面重做)
- One bar on top: search, **Filter** (kind and tag in one menu), grid or list, share and import, and **Save the canvas**. The drag tip strip and the red ✕ are gone (Esc still closes the page; the empty page explains dragging).
- **A workflow opens on its values (打开工作流先看参数方案)**: the details are rebuilt around them. On the left: **默认参数** (the workflow's own), your saved sets (each with its key values and how many values differ), **+ 从画布保存** and **+ 新建参数方案**, then **全部出图** and **版本**. On the right, the chosen set: **应用到当前画布**, **⇄ 对比** (with the default values or the canvas), ⋯ (copy as a new set, rename, delete), its main values, its prompts (long ones folded, the role still chosen from a menu), **the pictures made with these values**, and, folded, the models and every node's values. The bar keeps the name (click to rename), the models' state, Open and ⋯ (edit, export). The old Overview tab is gone: cover, notes and tags sit at the top of 默认参数.
- A new set is named after the workflow with a number (阿罗娜K4.0 2); values saved without names show their real names instead of 控件 1, 控件 2. A set equal to the workflow's own values (saving a workflow keeps one) is no longer listed twice.
- **Compare pictures inside a workflow too**: every picture in 全部出图 and under a set has ⇄ — pick two, or one and the canvas or the default values. The pictures there now load as thumbnails.
- Cards keep the picture, the name, a line of steps, sampler, LoRAs and size, and the model. The models' state is a dot on the cover, with words only when some are missing; 功能模块 marks a partial workflow. **▶ Open** and **⋯** (edit, export, delete) are two small round buttons in the cover's corner, shown when the pointer is on a card, so pressing a card to look inside no longer opens it on the canvas by mistake.
- **Focus values (重点参数)**: mark what matters most in a workflow. Under 全部节点参数 every value has a ☆; marked values show at the top of 默认参数 and of every set as large tiles with the node they belong to, a note you can add (「0.6–0.8 最好」), and, on a set, the default value when it differs. Set rows list them first, and in ⇄ 对比 they lead the key values with their note. A star turns at once with a little bounce, the value fades in at the top, and marking or noting one saves only that (no new version of the workflow). They belong to that workflow (the ☆ in the comparison window still stars a value for every workflow), are kept when the workflow is updated from the canvas, and travel with it in an exported workflow package (share codes carry only the canvas workflow).
- **Tips where they help (参数方案的引导)**: a workflow without sets says what they are for, with **+ 从画布保存** right there; on a set, a line points at **⇄ 对比** until you have used it (or pressed 知道了).
- **Folds remember you (折叠记住你的选择)**: 用到的模型 and 全部节点参数 open as you last left them. 用到的模型 says on its title whether the models are all here or how many are missing, and opens by itself when some are.
- **Docked, the details fit (侧边栏模式不再挤)**: in a narrow panel the left column becomes one row of pills above the values (默认参数, your sets, new, 全部出图, 版本).
- **Saving a workflow, redone (保存工作流的窗口重做)**: the cover is large on the left, chosen from the canvas, the current cover or your recent pictures (as thumbnails); name, tags and notes on the right; Cancel and Save always in view. The name starts as the canvas's workflow name when ComfyUI has saved it under one. Esc cancels, Enter in the name saves.
### 🔇 Models you never want scanned (不需要扫描的模型)
- A model whose header looks like no drawing model (a voice model such as F5-TTS kept in checkpoints, say) is no longer just "not scanned" forever. The scan page asks about them: **都不扫**, **都照常扫**, or **逐个看**. Set to not be scanned, a model shows **不扫描** instead of 未扫描, a scan of everything skips it (picked by hand it is still scanned), and the scan page lists these models to change it back. A model's details have the same button. Nothing is decided for you: scanning still keeps a fingerprint that repairs workflows.
- **Not on Civitai is no fault (Civitai 上没有不再像出错)**: the count of models Civitai did not find warns only when some file is damaged or a lookup is still to do; each such model says its fingerprint is kept for repairing workflows.
### 🎯 The scan page, rearranged: pick models beside the button, settings in place, components apart (扫描页重排)
- **Quieter the further down.** On top one card answers "what now": two rows of your models' covers (an empty dashed frame for each model not scanned yet, gently pulsing while a scan runs), what is left in one line ("3 models still to look up"), the scan button, **Pick models to scan…**, and **Online / Offline** right beside them. Then the models' state as a coloured bar with a chip per state; then, only when there is something, what needs a look (damaged files, voice-like models, models set to never be scanned, other formats), one row each with its icon; then the settings as tiles with an icon and a switch each; the last scan is one line at the foot. "How scanning works" is folded under the title.
- **Pick models to scan… sits beside the scan button.** The picks are named under it (each with a ✕, with their count and total size) and the button scans just them; **Clear, scan everything again** goes back. There is no "all / only ones I pick" switch any more. When some picks are already found, it says so: a scan only reads their record again unless Fetch again is on.
- **Under the button, what this scan will do**, in one sentence; **Details** unfolds the numbers: how many will be looked up on Civitai (not scanned yet, and those last scanned without a connection; how many of them are components), how many are already found and keep their record, how many Civitai does not have and are not looked up again, how many are set to never be scanned. Turning on offline or Fetch again changes the lines at once.
- **The scan settings are on the page** instead of a page of their own: Civitai names on cards, fixing the workflow afterwards and the Civitai key as tiles; renaming files and fetching everything again are folded under **More**, marked as changing files, and when one is on the scan card says so in yellow.
- **Components on their own (基础组件)**: VAE, text encoders, CLIP, CLIP Vision and upscalers get a chip of their own, "7 components · not found is fine", whose list (found, not found, not scanned yet) says that many of them are not on Civitai at all and that this does not matter (the fingerprint is still kept for Model Check). The counts above, and the Not found and Not scanned yet lists, leave them out; their own list shows all of them.
- **The model picker is new**: every model a scan reads, as a list (name, folder, state, base model, size) or as covers. Chips by state, the same words as the scan page — **Not scanned yet, Not found, Damaged, Found, Never scan** — with counts; a search; the folders on the left with how many are picked in each, two folders of one type told apart by where they are. **Pick these N** picks everything shown (so "the 12 not scanned yet in LoRA" is two clicks), shift-click picks a run, **Picked only** shows the picks. The footer says how many are picked and how big they are.
- Only files a scan can read (.safetensors) are listed; the old picker also offered .ckpt and other files a scan never opens.
- **Quick to open with many models, or models on a network drive** (a NAS, a cloud server's model disk): the page reads the headers of models not scanned yet 16 at a time and remembers them until the file changes, so it opens again at once; while it counts, it says **Counting your models…** instead of staying blank. On a cloud server with 1041 unscanned models it had taken 79 seconds each time.
### ⚙️ Settings in tabs (设置分组)
- The settings page is split into tabs: **Look, Models, Workflows, Node packs, Prompts, Backup, Help**. "Change where downloads go" opens Models directly.
- **Where downloads (and now imports) go is a choice**: by base model (the new default), in Downloads then by base model, all in a Downloads folder, straight in the type's folder, or your own folder typed by hand; an example path shows the result. Without a saved setting, downloads now go by base model instead of into a Downloads folder.
### 🔖 Take single tags from a saved prompt (从存过的提示词里挑词)
- **In Prompt Studio a clicked card opens as a reference** between the cards and the prompt you are editing, as its tags (with their meanings when a language is picked). Click one tag and only that tag goes into the prompt; tags the prompt has already are ticked. **Add all** puts the whole card in, as a click on a card used to; the preview's Add and dragging a card work as before. The ✕ closes the reference; beside the canvas the drawer widens by the column while it is open.
- **It does not squeeze a narrow window**: where three columns do not fit (a side-panel browser window, a narrow drawer), the reference takes the card list's place and **‹ Cards** brings the list back, so the reference and the prompt you edit each get half the width.
### Fixes
- **The browser no longer opens blank after Put on canvas (搭配放到画布后再打开不再空白)**: putting a combo on the canvas hid the combo page before closing the browser, so the next time it opened the title said Combos and nothing was below. It now opens again on the combo list, or on the combo you put down.
- **Edit emotions: every clip in the list, and the line follows the clip (编辑情绪:下拉列出全部音频,换音频台词跟着换)**: a box that already held a file listed only the files whose names contain it (the browser filters by the box's text), so a character's other clips seemed missing. Pressing the box now lists all of them, with the current file shown greyed; leaving without a pick keeps it. Picking another clip no longer keeps the old clip's line beside it: the line the node already reads for that clip is filled in, otherwise the line the node finds for the clip (its .txt / .lab or the annotation list) is filled in, with Anomalous_TTS from interface 14 on (older versions leave the box blank and the node reads the line when it speaks); going back to the first clip brings its line back. A blank line box shows, greyed, the line the node uses for its clip.
- **Search a character's clips by line (按台词找参考音频)**: Edit emotions has a search box above the clips: type part of a line or a file name and the matches list with their lines, to hear (▶), **Use as main voice** or **Add as emotion** (a new emotion row with that clip and its line, ready for its name). Searching lines needs Anomalous_TTS interface 14; with an older version it searches file names.
## v1.59.1 Beta (GPT-SoVITS v3 / v4 voices) — 2026-10-17
### 🎙️ Character voices: v3 and v4 models (角色语音支持 v3 / v4 模型)
- **Anomalous_TTS now runs GPT-SoVITS v3 and v4 models** (its first stable release, [v1.0.0](https://github.com/DemonGatanjieu/Anomalous_TTS/releases/tag/v1.0.0); update it in ComfyUI Manager), and this version of the plugin goes with it: v3 / v4 files are imported instead of refused, and a file whose version cannot be read is refused with a clearer reason. With an Anomalous_TTS too old for v3 / v4, the import says to update it.
- **Each character card shows its GPT-SoVITS model version** (v2, v2ProPlus, v4…) after the name (角色卡片显示模型版本).
- **v3 and v4 need their own official base model and vocoder** (v4 about 0.8 GB, v3 about 1.2 GB). **GPT-SoVITS Settings** lists them, with their own labels, only when one of your characters uses that version, and the import card says what is still missing for the model you dropped in. They download on first use too; a GPT-SoVITS package already on the computer is used as it is.
## v1.59.0 Beta (Model Downloads, Backup, Share Codes, Node Combos, MCP, Light Theme) — 2026-10-17
### ⬇️ Download missing models from Model Check (模型检查一键下载缺失模型)
- **Download** next to a missing model, and **Download all** for every one, when its source is certain: Civitai found the very file by the workflow's fingerprint, or the workflow carries a link to the file (a shared workflow's model links, or the download links in ComfyUI's own templates on Hugging Face). A file name alone is never enough, and a link to a different file than the workflow's is not offered.
- **You see where it goes before it starts**: the dialog shows, per file, its size, source and base model, the full path it will be saved to, and the free disk space. The models folder (when you have several, e.g. through extra_model_paths) and the subfolder can be changed right there; the folder you pick is remembered per type.
- **Your folder habits (按你的文件夹习惯放)**: Settings → Model downloads sets the default. *Where the workflow says* (default) puts the file in the subfolder the workflow names, so nothing in the workflow changes; a model without one goes into the folder set below (`Downloads`). *A set folder* always uses that folder, and the node is moved to the new place (Ctrl+Z undoes it). Write `{base}` in the folder to sort by base model (SDXL, Flux, Illustrious…); a folder of that name you already have is used, in whatever spelling.
- **Safe by default**: it downloads into a temporary file and checks the fingerprint before giving it its name, so a broken or wrong download never shows up in your models; an existing file is never replaced; an interrupted download continues where it stopped. `.ckpt` / `.pt` files get a warning (they can run code), and a source without a fingerprint says it cannot be checked. Your Civitai API key (needed for some models; Settings → Model downloads, or the scan page) is only sent to Civitai.
- Progress shows in the row with Cancel; when a file is done, the nodes using it get it, a short message says so, the new files are scanned for their information and cover, and the activity log records the download (source, size, whether the fingerprint matched).
- **Base models, VAEs, text encoders… from ComfyUI-Manager's list (Manager 模型清单)**: models that are not on Civitai (Flux's VAE, T5 and CLIP text encoders, upscalers, ControlNets…) are now looked up by file name in the model list of the ComfyUI-Manager installed here, which has a few hundred of them, mostly on Hugging Face. When the workflow carries the model's fingerprint, only the listed file with that fingerprint is offered, so it is certainly the same file; without one, a single listed file of that name is offered as **Download by name**, with a warning, and never in Download all.
- **Hugging Face first with the mirror (开镜像时优先抱脸)**: when a model is on Civitai and the very same file (same fingerprint) is on Hugging Face too, through the workflow's link or ComfyUI-Manager's list, it now downloads from Hugging Face while the China mirror is on: usually much faster there, and no Civitai login. Civitai's name, base model and page are still shown.
- **Covers and prompts from Hugging Face (抱脸模型的封面和示例提示词)**: a model downloaded from Hugging Face gets its model card's first example image as its cover, and its trigger words, example prompts and base model in its notes, with the repository as its link. Nothing you or a scan already set is replaced. Hugging Face cannot be searched by fingerprint, so this is for files downloaded from it.
- **Text encoders, CLIP vision and upscalers shown by default (文本编码器等默认显示)**: these folders are now shown and scanned by default, also where an earlier version saved them as hidden (once; you can hide them again in Settings → Model folders). Scanned models get fingerprints, so workflows you share carry them for T5, CLIP and the like too and Model Check can find the very file for whoever opens them.
- **One site down does not stop the others (一个网站连不上不影响其他)**: when Civitai does not answer (common in mainland China), the rest of the lookup no longer gives up: it stops asking Civitai and still asks Hugging Face (through the mirror) and the others.
- **AI apps can download too (AI 也能下载)**: three new MCP tools: `download_missing_models` (what Model Check's Download all does, for the open workflow), `download_model` (one model from a Civitai version link, a Hugging Face or GitHub file link, or a Civitai hash, into the folder of its type) and `download_status`. Files go where Settings → Model downloads says, are checked the same way, and are never written over an existing file.
- **Hugging Face through the China mirror (Hugging Face 国内镜像)**: Settings → Model downloads can send Hugging Face downloads through hf-mirror.com, which is on by default for the Chinese interface. When a download from Hugging Face itself fails, the row suggests the mirror.
### 🌐 A proxy for the plugin, and why Civitai cannot be reached (代理设置与连不上的原因)
- **Settings → Network → Proxy address**: the plugin's own requests (scans, model downloads, translation, update checks) go through the proxy written here, e.g. `127.0.0.1:7890`. Empty uses the system proxy as before. For a VPN app in PAC mode or one that only proxies the browser: the browser reached Civitai, the scan did not. Only this plugin uses it; ComfyUI and other node packs are untouched.
- **Test connection** right below says whether Civitai answers now, and if not, why.
- **A scan that cannot reach Civitai says why**: no answer (often no proxy), refused (wrong proxy port, VPN off), the address not found, a failed secure connection, or an error from Civitai, with what to try.
### 💾 Backup and restore (备份与恢复)
- **Settings → Backup → Export** packs your Workflow Recipes, combos, materials and saved prompts, parameter sets, the plugin settings and the names, notes and covers you gave your models into one .zip, optionally with ComfyUI's own saved workflows. Keep it on a cloud drive or a USB stick. The Civitai API key is never included.
- **Import** first lists what is in the backup next to what this computer has (new, the same, different), and the models it finds again by file hash, even after they were renamed or moved. By default it only adds what is missing; "take the backup's" replaces differing files and puts this computer's in the Recycle Bin. A model's name and notes are filled in where empty, and your own cover takes the place of the Civitai one. Nothing is deleted.
- Models' scan results (.info: names, trigger words, base models from Civitai) are in the backup too, so they survive Civitai removing a model; Civitai's covers can be added (they can be hundreds of MB). Both are put back only where this computer has none.
- **Download what the new computer lacks**: the backup also keeps a model list (each model's place, size, fingerprint and link). Importing says how many of those models this computer lacks; **Find downloads…** looks them up like Model Check (Civitai by hash, the saved link, Hugging Face), shows sizes and sources, and downloads the ticked ones one by one into the folder and file name they had, each checked against its fingerprint and scanned after.
### 🔗 Short share codes, one place to import (短分享码与统一导入)
- **Share codes are 5–10 times shorter (AMB2)**: only what a workflow needs is kept — node types, values, links, positions, titles, groups and the models' fingerprints — and ComfyUI fills in the rest when it opens. A Chinese-character spelling ("汉字码") is the shortest; a letter spelling ("字母码") posts anywhere, for chats or comment sections whose word filters stop the characters. Before a code is given, it is opened once and compared with the canvas; a workflow it cannot hold exactly (subgraphs, for one) is shared whole. Nodes from node packs keep their links on a computer that has not installed the pack yet. AMB0 and AMB1 codes still open.
- **Share and import in one place**: the Workflows page's ⇅ makes the code as soon as it opens (with the fingerprint and download-link notes), and takes anything in: a pasted share code or workflow JSON, or a dropped or chosen workflow file, an image with a workflow, or a backup .zip.
### 📦 Recipe packages are back (配方包重新开放)
- A recipe card's export button (and Export in the recipe's More menu) makes a **recipe package**: one .zip with the recipe's workflow, name, notes, tags, parameters, cover and model thumbnails (its versions only when ticked), to give to someone. Dropped into the ⇅ of their Workflows page, it becomes a new recipe card, optionally opened on the canvas; a recipe of the same name is never replaced (the new one is kept as "Name (2)"). Packages are checked for size, paths and checksums before anything is written, and a failed import leaves nothing half done. Send it as a file in chat apps.
### 🧱 Combos from your own nodes (按自己的节点结构做搭配)
- A combo is no longer only "a main model, LoRAs and a prompt". Pick nodes on the canvas (a Flux UNet with its two text encoders and VAE, a video model, a node pack's loader…), choose **New → From the nodes picked on the canvas**, and the combo keeps those nodes and how they are wired. Its editor then lists the **slots**: each model drop-down (a node with several has several) to change with a picker that shows that folder's models with covers, filtered by base model, and each prompt box to write in. **Put on canvas** lays the nodes out wired as saved, with your choices in them.
- Only certain things are kept: nodes that pick model files and nodes that hold text, and the links between them. Other picked nodes (samplers, latents…) are left out and their ports stay open for you to wire, as before. Every other value of the kept nodes (strengths, CLIP type…) is stored by name and put back untouched. When the dialog saves, you see what is kept and what is not, and can rename slots or untick the ones that should stay as they are.
- A node combo's page is grouped into Models, LoRAs and Prompts, in the order they flow (loaders first, LoRAs from the model outwards, the positive prompt before the negative); "LoRA 1" is the one next to the model. Each model shows its cover (looked up once for models chosen on the canvas), a two-line name, and its node's saved values (strength, CLIP strength, type) as small read-only tags. A whole row opens the picker; prompt boxes grow with their text. The top bar of the combo editor no longer floats 20 px down with the page showing above it.
- Each slot names the node it is on, and its node pack when that is not ComfyUI's own. A prompt has a small "译" button: a read-only panel beside it shows the translation (English for Chinese, Chinese for English); Prompt Studio stays the place to edit prompts.
- Nodes holding several models (Load CLIP Triple, LoRA stacks of rgthree, Comfyroll and Easy-Use, Easy-Use's full loader): each model is its own slot, and each saved value goes to the row with its number (strength_02 under the second LoRA); more than four values fold behind "+N". Empty rows of a stack (None) start unticked when saving, so a ten-row stack does not make ten empty slots. rgthree's Power Lora Loader draws its own rows instead of drop-downs and is not kept.
- **Right-click → Save as combo**: the canvas's node menu saves the picked nodes (or the node you right-clicked, when it is not among them) the same way, without opening the browser first. It shows only when there is a model or text node to keep.
- Every other value of a saved node stays as it was saved: the combo always puts down exactly what you saved, so a Flux combo cannot fall back to a node's default CLIP type or strength. For other values, save another combo.
- On another computer, missing nodes are named with their node pack before anything is placed; a box or link a node pack has since renamed is reported after placing, never guessed. **New → Same structure as …** starts another combo from a structure you already saved.
- **Save as combo keeps every picked node**, not only the model and text nodes: an upscale model's loader with the node that uses it and the Save Image after it, a ControlNet's nodes, any small group of nodes that work together. The models and prompts in it can still be changed in the combo; the other nodes are listed under **Nodes put back as saved** with their values, and go back on the canvas exactly as saved (change them on the canvas and save the combo again).
- A link coming in from a node that was not picked cannot be kept; the save dialog, the combo and its card (**1 input to wire**) say which inputs to connect after putting it down. Subgraphs cannot be kept yet. A combo with no models shows its nodes' names on the card. Combos saved before work as they did.
### 🤖 AI apps can use your library (MCP,让 AI 用你的库)
- While ComfyUI runs, AI apps that speak MCP (Claude Code, Claude Desktop, Cursor, Cherry Studio, VS Code…) can connect to `http://127.0.0.1:8188/anomalous/mcp` and ask about your library: models with their Civitai information, trigger words and your notes, which models are unmatched and why, output images and the settings that made them, combos, workflow recipes, saved prompts, generated speech and the activity log. A model's cover or an image's thumbnail can be attached for apps that look at pictures.
- It can also act on the workflow open in ComfyUI: rewrite or extend the prompts, swap a node's model, add a LoRA (it goes after the model loader and the LoRAs already there; one that also trains the text encoder takes the CLIP line too, from the checkpoint or, in a UNet workflow, from its CLIP loader, and everything those fed, such as both prompts, moves behind it; one that does not, like most Flux and Anima LoRAs, patches the model only), place one of your combos, check and put back missing models, and run the workflow; and start scans, or have an Anomalous TTS voice read a line. Every canvas change is one Ctrl+Z step, shows a note on the page and is in the activity log marked "AI". Nothing deletes or renames files; AI apps ask before calling a tool that changes something.
- Only this computer can connect, and requests from web pages, other hosts or through a proxy are refused. It costs no memory while nobody is connected. Setup for each app: `docs/guides/mcp.md`.
### ☀️ Light theme (浅色主题)
- **Settings → Appearance → Theme**: **Follow ComfyUI** (the default), **Dark**, **Light** or **Abyssal Scarlet**. Following ComfyUI switches with ComfyUI's color palette at once, light with a light palette and dark with a dark one. The same choice is in ComfyUI's settings under Anomalous Model Browser (it replaces the Abyssal Scarlet on/off switch; Abyssal Scarlet stays chosen if it was on).
- Every page, the Prompt Studio drawer and the dialogs follow the theme. The image viewer stays dark, like ComfyUI's own, and so do the labels and buttons over pictures (a model card's name, a recipe's cover labels, the gallery's View parameters button), so their text reads on any picture. Grey hint and secondary text is darker in the light theme so it reads clearly. The dark themes look as before.
- The "all models are here" banner on a recipe now reads green instead of pink, and the voice page's **Import character** button has dark text on its light blue in the dark themes.
### 🎯 Opens where your node is; number keys for the pages (按节点打开、数字键切页)
- **Opening follows the selected node**: open the browser with a text node selected on the canvas and Prompt Studio opens on that node; with a KSampler, a model loader or any other node, Current node opens. With nothing selected it reopens the page you used last, as before. It can be turned off in Settings → Opening and window.
- **Prompts is a page like the others (提示词变成普通页面)**: the rail's Prompts entry (and 7) now shows Prompt Studio inside the browser window instead of folding the window away. **Beside the canvas** on the page moves it next to the canvas as before, to see the canvas while you edit (its side and width are remembered); **Back to the window** returns it.
- **Prompt Studio's own shortcut is gone** (Ctrl+Shift+L, and its row in ComfyUI's settings and the Extensions menu): it opens from the rail, with 7 while the browser is open, or by opening the browser with a text node selected. The browser's shortcut (Ctrl+Shift+M) stays.
- **1–9 go to the pages on the left** while the browser is open (Home, Activity, Models, Gallery, Workflows, Combos, Prompts, Voices, Audio library). Numbers typed into a box stay in the box, and the rail's tooltips show each number. W/S were not used because W is ComfyUI's own Workflows key.
### 🔎 Model search in the header (模型搜索放到顶栏)
- The models page's search sits in the header now, instead of inside the folder list you had to open first. It matches the name, file name, folder, trigger words, base model and notes, every word you type, in the type you are looking at; Esc clears it. It also searches all the models of the type, where the old box only filtered the cards on screen.
### 🧩 Inserting a LoRA wires it properly (插入 LoRA 连线更聪明)
- **Current node → Insert LoRA before / after** now works on the usual workflows it refused before: a checkpoint whose CLIP feeds both prompts (both now hang behind the LoRA), and UNet / Flux / Anima workflows with their own CLIP loader. Whether the CLIP line goes through the LoRA is decided by the LoRA file itself: one that also trains the text encoder (most SD 1.5 / SDXL / Illustrious LoRAs) takes it, from the checkpoint or the CLIP loader; one that does not (most Flux and Anima LoRAs) is inserted as a model-only LoRA loader. Inserting before a sampler moves only that sampler's model, and the CLIP of the prompts wired into it.
### 🌐 Your own DeepL key for translation (DeepL 翻译密钥)
- **Settings → Translation → DeepL key**: prompts are translated with Google's free endpoint (MyMemory when Google cannot be reached); with your own DeepL API key, DeepL comes first. Saving the key translates one sentence to show it works, or says why not (a key DeepL refuses, a used-up month). The Free plan gives 500,000 characters a month. The key stays on this computer and is never put in a backup.
- A DeepL key saved by hand in `config.json` before did not work any more: DeepL stopped taking the key as a request parameter in 2025. It now goes in the Authorization header.
### 🎬 Videos in the gallery (图库显示视频)
- **The gallery shows your video outputs** (MP4, WebM, MOV, MKV) next to the pictures: each card shows the video's first frame with a ▶ badge naming its format and plays it muted while the pointer is on it (or always, as Settings → Video covers says for model covers); clicking it plays the video with sound in the viewer, filling the window, and a double-click goes full screen. Closing the viewer (×, a click beside it or Esc) stops it. Videos are deleted (to the Recycle Bin) like pictures, and a video dragged onto the canvas becomes a Load Video node; View parameters, the star and the right-click menu stay with pictures, which carry the parameters. A first contribution from outside: thanks to [@namnamjeopjeop](https://github.com/namnamjeopjeop) (cocorang) and [@wkdtjs](https://github.com/wkdtjs) (PR #21).
- **All / Images / Videos (全部 / 图片 / 视频)** beside the gallery's search, shown once your output folder has a video. **Settings → Gallery** hides formats you never want to see (GIF, say); click a format again to show it. Both are remembered in this browser. The recipe cover picker and AI apps (MCP) still see pictures only.
### 🧪 Tests in the repository (测试公开)
- The plugin's tests are now part of the repository: `tests/` (Python tests for the backend, `.mjs` tests for the frontend rules), `node tools/run_tests.mjs` runs them all with the structure check, and [docs/guides/testing.md](docs/guides/testing.md) says how to run and write them. They need no network or GPU and only touch temporary folders. Developer notes no longer point at tests that were removed earlier.
### ⭐ A Star for the project (点个 Star)
- Home's "Found a problem, or have an idea?" band ends with **⭐ Star on GitHub**, and Settings → Help and feedback has **Star it on GitHub**: both open the project page, where the ☆ Star button is. Nothing pops up and nothing is fetched; it is there for whoever wants it.
### ⚠️ Damaged model files are named as such (模型文件损坏会直接说出来)
- A model whose file header cannot be read (a download cut short, or another tool that rewrote the header and failed) used to look like a model Civitai simply does not know, inferred from the file. A scan now checks the header of every model it cannot match and names these **文件损坏**: a red badge on the card, a line in the details, a warning on the scan page, and first place in its unmatched list with advice to download the model again (then **Look up again**). Only the header is read. Models scanned before keep their old reason until they are looked up again.
### Fixes
- **Ctrl+Z after a scan (扫描后的 Ctrl+Z)**: after any scan, the first Ctrl+Z used to seem to do nothing: the workflow's saved fingerprints had changed, which ComfyUI counted as a step. A change of fingerprints alone no longer takes a step.
- **Esc in a model's Outputs window (作品窗口里按 Esc)**: Esc closes the window of pictures made with a model; it used to close the model's details behind it and leave the window open.
## v1.58.3 Beta (Fix: canvas speed with other extensions) — 2026-10-15
### 🐢 The canvas stays smooth with other extensions (画布不再被其他插件拖慢)
- One style rule of the browser (the list column stepping aside while a workspace is open) made every inline-style change on the page, by any extension and also while the browser is closed, restyle the whole page. Extensions that move their buttons on every canvas frame (ComfyUI-Prompt-Assistant, for one) then brought a large workflow's canvas down to 2–3 fps. The list column now follows a class on the window instead. Thanks to **@Lindemellis** for finding it, measuring it and proposing the fix ([#22](https://github.com/DemonGatanjieu/Anomalous_Model_Browser/issues/22)).
## v1.58.2 Beta (Small fixes: Esc closes, Show in folder, insert a picture as a node) — 2026-10-13
### 💬 From a Bilibili comment (来自 B 站评论)
- **Esc closes the browser (Esc 关闭浏览器)**: Esc closes a dialog or menu first; on a page one level down (a model's details, one combo, one workflow) it goes back to the list, as the page's back button does; on any other page, Workflows and Combos included, it closes the browser. The shortcut and the Extensions menu entry now close the browser when it is open. Esc in a text box still belongs to the text box.
- **Shift + B opens the browser (Shift + B 打开浏览器)**: the default shortcut is now **Shift + B** (again to close it). Ctrl + Shift + M is taken by Chrome and Edge (switch profile) before ComfyUI sees it, and B alone already opens comfyui-browser's window. A shortcut you changed yourself stays as it is; change it in ComfyUI Settings → Keybinding.
- **Dragging a gallery picture hides the browser (拖图时浏览器让开)**: while you drag a picture, the browser fades out as it does for other cards, so the canvas and the node under it take the drop.
- **Show in folder (打开所在文件夹)**: a model's details have **Show in folder** next to **Copy path**: Explorer (or Finder) opens in front of the browser with the file selected. Only on the computer ComfyUI runs on.
- **Insert a picture as a node (图片作为节点插入)**: right-click a gallery picture → **Insert as a Load Image node** gives you a Load Image node showing that picture: the browser closes and the node follows the pointer until a click puts it down (Esc cancels), like a combo's **Put on canvas**. The picture is copied into ComfyUI's input folder, as when you drop a file on the node (the same picture is kept once).
### ⌨️ Arrow keys in picture details (查看参数里方向键切图)
- In a picture's details, **← / →** (and **A / D**) switch pictures again after you ticked a node's checkbox under **All parameters**; the ticked checkbox kept the keyboard and the keys did nothing until you clicked elsewhere. Typing in a text box still keeps the arrows for the text.
### 🌐 English interface (英文界面)
- Picture details no longer show Chinese in the English interface: the step count ("20 steps"), **Show model** on the Models tab, the copied-JSON note and the empty-tab notes.
- Counts read right for one: "Changed 1 thing on the canvas", "1 model", "1 file" instead of "1 things", "1 models", "1 files".
- The voice guides name the node as English ComfyUI shows it: **Character Speech (GPT-SoVITS)**.
## v1.58.1 Beta (Security: only this computer can change things) — 2026-10-12
### 🔒 Other devices can look, not change (别的设备只能看,不能改)
-5
View File
@@ -1,5 +0,0 @@
# Claude 工作入口
本仓库的开发与维护规范以 AGENTS.md 为唯一正文,第 0 节是每次提交都必须做到的要求;这里只做入口,不另写规则。
@AGENTS.md
-11
View File
@@ -1,11 +0,0 @@
# Gemini 工作入口
在本插件中修改任何文件前,先阅读并遵守根目录的 [AGENTS.md](AGENTS.md)。它是本仓库开发与维护规范的唯一正文,适用于 UX 调整、修复和重构;第 0 节是每次提交都必须做到的要求。
随后阅读 [ARCHITECTURE.md](ARCHITECTURE.md),按其中的阅读地图加载与当前任务有关的专题。
不要只凭当前任务描述直接追加实现。先确认现有责任模块、状态来源、关闭清理和样式规则;完成后按规范验证并报告结果。
如果当前工具没有加载本文件,任务发起者应明确要求 Gemini 阅读 `GEMINI.md` 和 `AGENTS.md`。文件的存在不代表工具已实际读取,更不代表检查已自动执行。
前端设计任务按 AGENTS.md 第 4 节和 [前端架构](docs/architecture/frontend.md) 进行,不使用历史视觉建议作为当前任务单。
+270 -113
View File
@@ -2,24 +2,27 @@
# 🚀 Anomalous Model Browser
**A model browser and creative workspace for ComfyUI**
*模型库 · 出图图库 · 工作流配方 · 搭配 · 提示词 · 角色配音 · 模型检查 · 操作记录*
**A model browser, workflow studio and AI workbench for ComfyUI**
*模型库 · 出图图库 · 工作流与预设 · 画布存档 · 搭配 · 提示词工坊 · 模型检查 · AI 工作台 · 角色配音 · 操作记录*
<br/>
[![ComfyUI Manager](https://img.shields.io/badge/ComfyUI-Manager-green?style=for-the-badge&logo=comfyui)](https://github.com/ltdrdata/ComfyUI-Manager)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](LICENSE)
[![Changelog](https://img.shields.io/badge/📖_Changelog-v1.58.1_Beta-blue?style=for-the-badge)](CHANGELOG.md)
[![Bilibili Video](https://img.shields.io/badge/Bilibili-视频演示-00A1D6?style=for-the-badge&logo=bilibili)](https://www.bilibili.com/video/BV1a1bv68EuA/)
[![YouTube Video](https://img.shields.io/badge/YouTube-Video_Demo-red?style=for-the-badge&logo=youtube)](https://youtu.be/hAvsj7uiaCw)
[![Changelog](https://img.shields.io/badge/📖_Changelog-v1.63.0-blue?style=for-the-badge)](CHANGELOG.md)
[![Bilibili Video](https://img.shields.io/badge/Bilibili-视频演示-00A1D6?style=for-the-badge&logo=bilibili)](https://www.bilibili.com/video/BV1boHx61Enc/)
[![Bilibili Voices](https://img.shields.io/badge/Bilibili-配音演示-00A1D6?style=for-the-badge&logo=bilibili)](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
[![Reddit](https://img.shields.io/badge/Reddit-r%2Fcomfyui-FF4500?style=for-the-badge&logo=reddit)](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
<br/>
[**English**](#english) | [**中文说明**](#中文)
<sub>Installed but can't find it? <a href="#where-en">Here is where it is</a> · 装好了找不到入口?<a href="#where-zh">看这里</a></sub>
<br/>
<img src="docs/images/models.webp" width="900" alt="Models with their Civitai covers, base model and real names">
<img src="docs/images/en-models.webp" width="900" alt="Model library with Civitai covers, base model badges and multi-version cards">
</div>
@@ -27,185 +30,339 @@
<h2 id="english">🇬🇧 English</h2>
> **Anomalous Model Browser** takes care of the chores around ComfyUI in one window: browse your models with their Civitai covers and trigger words, look back through your outputs, keep workflows you have tuned, keep model combos and prompts, repair workflows whose models are missing, and, with the companion node pack [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS), give your characters a voice. An activity log shows everything the plugin changed.
> **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**.
### 🎬 Video Walkthrough & Demos
* 📺 **YouTube**: [Watch Quick Walkthrough on YouTube](https://youtu.be/hAvsj7uiaCw)
* 📺 **Bilibili**: [Watch Video Demo on Bilibili (在哔哩哔哩观看)](https://www.bilibili.com/video/BV1a1bv68EuA/)
### 🧭 The idea
### 👀 See it in action
**Not another WebUI, not a replacement for ComfyUI: just ComfyUI made easier to use.**
*The pictures show the Chinese interface; English is one switch away in Settings.*
- **Fewer needless chores.** Telling cryptic model filenames apart, fixing red missing-model nodes, digging out how an old picture was made, rewiring the same Checkpoint + LoRAs every session: the plugin handles these.
- **Still your ComfyUI.** The canvas and nodes are ComfyUI's own. The plugin adds **zero custom nodes**: what it places on the canvas are standard ComfyUI nodes (or packs you already installed), so the PNGs and workflows you save open cleanly even on a computer without this plugin.
- **A grounded way to use AI with ComfyUI.** You say what you want in plain words; **Anomalous** opens your workflows and wires tested node blocks on a **copy tab**; **your own ComfyUI and local models** draw the pictures. You see every step, pick which preview continues, and undo any canvas change in one click.
**1. Scan once, then pick models by their covers.** Press **Scan** and every model is looked up on Civitai by its file hash, so even a file called `下载 (3).safetensors` gets its real name, cover, trigger words and base model.
### ✨ Highlights in v1.63
<p align="center"><img src="docs/images/scan.webp" width="520" alt="Scan results: each file matched on Civitai"></p>
- **AI Workbench — staged creation instead of blind node-guessing**: run fast first-pass base previews and pick your favorite before spending time on a second-pass upscale, inspect results with a before/after slider, snap in tested **Ways** & model-aware **Skills**, and keep your open tab untouched via **AI edits a copy**.
- **Workflows — structure flow diagram, orange preset diffs & Official examples**: every workflow card now draws its stages, models and LoRAs as a compact structure diagram; pick a preset and what it changes lights up in **orange**, or start immediately on a fresh install with **Official examples**.
- **Canvas Saves & locked-seed comparison**: save states of a workflow as `1 · 2 · 3` on a bar under the canvas, batch-run them with **Locked** or **Random** seeds, compare exact node/value diffs beside each save's pictures, and click a save number to restore both the graph and that save's output node previews.
- **Grouped multi-version model cards & numbered multi-LoRA insertion**: files from the same Civitai model page fold into one card (slide out versions under an orange bar, pin your default, or download other Civitai versions); **Insert LoRA** chains several LoRAs in numbered `1 · 2 · 3` order in one `Ctrl+Z` step; the docked sidebar edge drags smoothly from `480px` wide.
**2. Open someone else's workflow without hunting for models.** When models are missing, a bar over the canvas says so. One press puts back the ones you already have under another name or in another folder; the red outlines go away.
### 🎬 Videos and discussion
* 📺 **Bilibili** (Chinese): [Feature demo](https://www.bilibili.com/video/BV1boHx61Enc/) · [Character voices demo](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
* 💬 **Reddit**: [Discussion on r/comfyui](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
<p align="center"><img src="docs/images/model-check.webp" width="760" alt="Model Check puts back three missing models with one press"></p>
---
**3. Find an old image and get its workflow back.** Search the gallery by prompt words, model or LoRA, then drag the image onto the canvas.
### ✨ AI Workbench: You direct in plain words, ComfyUI draws with your models
<p align="center"><img src="docs/images/gallery.webp" width="760" alt="Gallery search, then drag an image onto the canvas"></p>
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:
**4. Keep a model + LoRA + prompt combo and lay it down in one move.** Drag a combo card onto empty canvas: a wired group of nodes appears, and nothing already on the canvas changes.
- **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.
- **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).
<p align="center"><img src="docs/images/combo.webp" width="760" alt="A combo card dragged onto the canvas becomes wired nodes"></p>
<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>
**5. Edit prompts as tags.** Select a prompt node and Prompt Studio opens its text as tags: click to weight, drag to reorder, type to add (Chinese and other languages become English).
---
<p align="center"><img src="docs/images/prompt-studio.webp" width="760" alt="Prompt Studio editing a prompt node as tags"></p>
### 💡 Everyday ComfyUI chores it solves (with or without AI)
**6. See what the plugin changed, and undo it.** Every change the plugin makes is listed in Activity, with **Undo** and **Find on canvas**.
**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/activity.webp" width="560" alt="Activity log with an undo button"></p>
<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>
**7. Give characters a voice** (with [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)): GPT-SoVITS characters with a reference clip per emotion, and a script page to voice them.
**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.
- **Without fingerprints** (its author does not have this plugin): nothing proves which file it was, so only names are left. A file of the same name in another folder is offered for you to confirm, row by row; VAEs, text encoders and other parts that often share a name are never guessed by name.
- **Downloads** also use the links a workflow carries (ComfyUI's own templates have them) and ComfyUI-Manager's model list. A file that only has the same name is marked **Download by name** and comes only when you press it.
<p align="center"><img src="docs/images/voices.webp" width="760" alt="Character voices page"></p>
<p align="center"><a href="docs/guides/model-check-rules.en.md"><img src="https://img.shields.io/badge/📖_How_Model_Check_decides-Full_rules-blueviolet?style=for-the-badge" alt="How Model Check decides: the full rules"></a></p>
### 🌟 What it does
<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>
Every page has an icon on the rail at the left of the window.
**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.
| Page | What you do there |
| :--- | :--- |
| **🏠 Home** | Pick what you want to do: a card per task, first steps (scan your model folders, a guided tour of the interface) and your latest activity. |
| **🕘 Activity** | Everything the plugin changed, by day: on the canvas (which node settings changed from what to what, nodes added or removed, workflows opened) and in files (models, covers, recipes, materials, notes, images, audio). **Find on canvas** jumps to the node; a canvas change can be undone with **Undo** until something changes it again, the rest is recorded only. Your own edits are not listed. |
| **📦 Models** | Your models with covers, trigger words and base model. Chips at the top switch between types (Checkpoint, LoRA, VAE…) and list a whole type, subfolders included; the folder list narrows it to one folder. **+** adds a loader node to the canvas; edit a model's name, notes and cover, or scan just that model. |
| **🖼️ Gallery** | Your ComfyUI `output` folder. Search by prompt, model, LoRA, seed, file name or model hash; open an image to see the parameters it was made with, or drag it onto the canvas to get its workflow back. **☆** keeps a good one as a whole workflow, a combo (model, LoRAs, prompt) or just its prompts. |
| **🪡 Workflows** | Workflow recipes: save a whole workflow or a part of one together with its models, cover, notes and parameters. Each card shows whether the models are on this computer; drag it onto the canvas to load it. Versions can be compared and restored. |
| **🧩 Combos** | A main model, a few LoRAs and a prompt you like, kept together. **Put on canvas** (or dragging the card onto the canvas) lays them out as a wired group of nodes; nothing already on the canvas changes. |
| **✍️ Prompts** | Opens Prompt Studio beside the canvas. Select a prompt node and its prompt boxes appear as tags, edited in place: click a tag to make it stronger or weaker, double-click to edit, drag to reorder, type to add (Chinese, Japanese and other languages are translated to English), and show each tag's meaning in Chinese, Japanese, Korean and other languages. Click a card (a few common ones, plus your saved prompts) to add it, or drag it straight onto a prompt box on the canvas (outlined green for positive, red for negative) or onto empty canvas for a new prompt node. With no prompt node selected you write a draft. |
| **🎙️ Voices / 🎧 Audio** | With [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) installed: import GPT-SoVITS characters, pick a reference clip per emotion, fix pronunciations, write a script under Voice-over on the same page and generate it right there; generated audio is listed in the audio gallery. Without it, the page explains how to install it and nothing else depends on it. |
<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>
Tools on the rail:
**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).
* **Scan** reads your model folders and fetches covers, trigger words and base models from Civitai (by file hash, no extra Python packages).
* **Model Check** (formerly Model Doctor) checks each workflow you open: when models are missing, a bar over the canvas says so and puts back, with one press, the ones you have under another name or folder (recognised by hash and file size); its page lists the rest with what you can do.
* **Current node** (formerly Node Assistant) shows the model of the node you select, lets you swap it from the covers or insert a LoRA, and lists every set of values saved for that kind of node with exactly what applying it would change; saved values can be deleted there.
* **Each tool on its page**: workflow share codes (import / export) on Workflows, Model Sources (where each model can be downloaded) on Models and in Model Check, translation on the prompt boxes in Current node.
* **Settings** (at the bottom): language, font size, thumbnails, video covers, which model folders to show, and feedback. Home and Settings have **Report a problem**, which opens a GitHub issue with your versions and GPU already filled in.
<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>
**Light on your computer**: the plugin itself loads no models, so it takes no graphics memory from image generation; the browser shows small thumbnails instead of full images, and its pictures are released a minute and a half after you close it. The voice models of Anomalous_TTS give their graphics memory back whenever ComfyUI needs it for image generation, and all of it on **Unload models**.
**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.
### 📦 Quick Installation
<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>
1. Open your terminal in the ComfyUI `custom_nodes` directory:
**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>
**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>
**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>
**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>
**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
1. In **ComfyUI Manager**, search `Anomalous Model Browser`, install it and restart ComfyUI. Or clone it into `custom_nodes`:
```bash
cd custom_nodes
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
2. Restart ComfyUI. *(Alternatively, install via **ComfyUI Manager** by searching for `Anomalous Model Browser`)*
3. For character voices, also install [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) the same way. It is optional.
4. **Updating**: click the **!** button at the top right of the browser; it shows the current version, and **Check for updates / switch version** opens the version panel. Update to the newest release, go back to an earlier one or return to the latest, then restart ComfyUI. The plugin never checks for updates on its own.
2. <a id="where-en"></a>**Where to find it**: after the restart a small cube button sits at the top left of the canvas (ringed below). Click it, or press **Shift + B**. On **Home**, **Scan model folders** fetches covers and trigger words, and **Tour the interface** walks through each area of the window.
Open the browser with **Ctrl + Shift + M**, the floating button on the canvas, or **Extensions → Anomalous Model Browser** in ComfyUI's menu. **ComfyUI Settings → Anomalous Model Browser → Interface** changes the shortcut, the language and how the browser is opened (floating button, a button next to Run, or the Extensions menu only).
<p align="center"><img src="docs/images/en-entry.webp" width="640" alt="The cube button at the canvas's top left, and ComfyUI's menu C, Extensions, Anomalous Model Browser, Open"></p>
The first time you open it, start on **Home**: **Scan model folders** fills in covers and trigger words, and **Tour the interface** points at each part of the window.
Can't see the cube? It may have been dragged away, or another entry chosen: ComfyUI's **C** menu → **Extensions → Anomalous Model Browser** has **Open Anomalous Model Browser** and **Reset floating entry position**. To make it easier to spot, go to **ComfyUI Settings → Anomalous Model Browser → Interface**: **Floating entry style** → **Labeled pill** adds the words "Model Browser", **Floating entry size** → **Large**, or **Browser entry mode** → **Top action bar** puts it beside the Run button.
3. Optional: for character voices, install [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) (`Anomalous TTS` in ComfyUI Manager). Nothing else depends on it.
> [!WARNING]
> **Keep a copy of your own data:** Workflow Recipes, combos, saved prompts and node values, and parameter presets live in `workflows/anomalous_recipes`, `workflows/anomalous_notebooks`, `workflows/anomalous_materials` and `workflows/anomalous_parameters` in your ComfyUI user folder. A copy of them before updating the plugin is a good habit.
Scanning reaches Civitai by file hash (no extra Python dependencies). Without internet access, choose **Offline** scan: base models are read directly from file headers, and a later online scan fills in covers and trigger words (see **Network and backups** below).
<details>
<summary><b>📖 Every page and tool, in detail</b></summary>
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.
| Page | What you do there |
| :--- | :--- |
| **🏠 Home** | Task cards for common jobs, first-run steps (**Scan model folders**, **Tour the interface**), recent AI tasks and latest activity. |
| **🕘 Activity** | Everything Anomalous changed, grouped by day: canvas edits (before → after node values, added/removed nodes, opened workflows, links to AI tasks) and file operations (models, covers, workflows, combos, prompts, images, audio). **Find on canvas** jumps straight to the node; canvas changes offer **Undo** while still current. Your manual canvas edits are not listed. |
| **📦 Models** | Browse models with Civitai covers, trigger words and base model chips. Switch types at the top (Checkpoint, LoRA, VAE…) or narrow to a subfolder on the left; **+** drops a loader node onto the canvas. Files sharing a Civitai model ID fold into one card with a numbered side arrow (slide out under an orange bar, pin a default version, or use **Open all versions**); model details also fetch **All versions on Civitai** for SHA-256-checked downloads. Includes **Tidy** (spot misplaced or duplicate files) and **Sources**. |
| **🖼️ Gallery** | Your ComfyUI `output` folder (including background-rendered previews for ultra-large upscales). Search by prompt, model, LoRA, seed, filename or model hash; inspect full generation parameters, compare two images, drag onto the canvas to restore the workflow, or click **☆** to keep as a workflow, combo or saved prompt. |
| **✨ AI workbench** | Task-by-task workspace for MCP AI apps: live step timeline with **Undo**, copy-tab controls (**AI edits a copy** / **Show the copy**), **Stage** panels (first-pass base picks, before/after comparison slider, choices, `✎` brush repaint markup, **Turned out well / Not quite**), **Node map**, **Decisions**, **Materials**, **Results**, **Ways** (with **Use it this time**) & **Skills**, and **Gates** (including optional per-task `🔒` privacy mode). Follow-up tasks indent cleanly under their parent task. |
| **🪡 Workflows** | **Official examples** and **Saves** at the top, followed by saved workflow cards. Details show a compact **structure flow diagram**, sticky **preset tabs** (highlighting changed stages in orange, with inline node diffs and **+ New preset**), **Hand to AI**, version comparison/restore, `.zip` **recipe packages**, and `⇅` share codes / drag-and-drop import. |
| **🧩 Combos** | Keep a main model, several LoRAs (with exact strengths) and prompts together as one card. **Put on canvas** (or dragging the card) places them as a newly wired node group without touching existing canvas nodes. |
| **✍️ Prompts** | Prompt Studio—full page or docked **Beside the canvas**. Edits selected prompt nodes live as tags: click to weight, double-click to edit, drag to reorder, type in Chinese/Japanese/etc. to translate into English tags, display per-tag translations underneath, or drag prompt cards onto positive (green) / negative (red) boxes. Works as a scratchpad when no prompt node is selected. |
| **🎙️ Voices / 🎧 Audio** | With [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS): import GPT-SoVITS characters, assign reference clips per emotion, tune pronunciations, write multi-line scripts under Voice-over, and browse generated clips in the audio gallery. |
Tools at the bottom of the rail (ordered by frequency of use):
* **Current node** (formerly Node Assistant): inspects the selected canvas node's model, swaps models from visual cards, inserts one or **multiple numbered LoRAs** at once (as standard chained nodes or into an rgthree Power Lora Loader / Easy-Use stack), and compares/applies saved values from workflows, combos, gallery pictures or node presets (`⇄ Compare` checks that node alone).
* **Model Check** (formerly Model Doctor): checks opened workflows read-only; offers a one-click banner when renamed or moved local files match the workflow's SHA-256 fingerprints (written by this plugin: **Settings → Record model fingerprints in workflows**, on by default); without fingerprints, offers a file of the same name for you to confirm. Downloads come from Civitai by fingerprint, the workflow's own links or ComfyUI-Manager's model list. [The full rules](docs/guides/model-check-rules.en.md).
* **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).
**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>
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**.
**Updating**: click the **!** button at the top right of the browser, then **Check for updates / switch version** to update to the newest release or roll back to an earlier tag, and restart ComfyUI. The plugin never phones home or checks for updates in the background.
</details>
<details>
<summary><b>🌐 Network and backups</b></summary>
**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).
**Network:** Civitai scanning and Google prompt translation require network access (when `civitai.com` is unreachable, the plugin tries `civitai.red` automatically; **Offline** scan works without internet and reads base models from file headers). For Hugging Face downloads in mainland China, enable **Settings → Model downloads → Hugging Face mirror (`hf-mirror.com`)**. Daily browsing of models, gallery, workflows, saves and combos requires **no network**.
**Backups:** **Settings → Backup → Export** packs your saved workflows, presets, canvas saves, combos, prompts, node values, and custom model names/notes/covers into a single `.zip` for cloud drives or a move to another PC. **Import** never deletes anything: a file that differs from the backup's stays unless you choose to take the backup's (yours then goes to the Recycle Bin). A backup also lists your models with their fingerprints, so on a new computer the missing ones can be looked up and downloaded again. Data lives under `workflows/anomalous_*` inside your ComfyUI `user` folder.
</details>
---
<h2 id="中文">🇨🇳 中文说明</h2>
> **Anomalous Model Browser** 在一个窗口里帮你打理 ComfyUI 周边的杂事:
> - 带 C 站封面和触发词浏览模型;
> - 翻看以前的出图;
> - 保存调好的工作流,存下常用的模型搭配和提示词;
> - 别人的工作流缺模型时自动找回;
> - 配合配套节点包 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 给角色配音。
> **Anomalous Model Browser** 在一个窗口里帮你打理 ComfyUI 周边的琐事:
> - 带 C 站封面、触发词和多版本折叠卡浏览模型;
> - 翻看历史出图,一拖还原当时的完整工作流;
> - 用结构流程图和预设管理调好的工作流,随时给画布存几个存档锁种对比、一键回退;
> - 存下常用的模型 + LoRA 搭配,用词块直观编辑长提示词;
> - 打开别人的工作流缺模型时帮你找回或下载(工作流带着本插件记的指纹时最准);
> - 在 **AI 工作台** 里动嘴提要求,让 AI 在副本标签页里调用你的工作流和实测“做法”跑图、先挑底图再放大精修;
> - 配合可选节点包 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 给角色配音。
>
> 插件改动过什么,都能在“操作记录”里查到。
> 插件和 AI 改动过的每一处画布设置,都在“记录”里写得明明白白,随时定位或撤销。
### 🎬 视频演示与教程
* 📺 **哔哩哔哩 (Bilibili)**:[在 B 站观看快速上手与使用演示](https://www.bilibili.com/video/BV1a1bv68EuA/)
* 📺 **YouTube**:[在 YouTube 观看视频演示](https://youtu.be/hAvsj7uiaCw)
<p align="center"><img src="docs/images/models.webp" width="880" alt="中文模型库界面:Civitai 封面、底模标签与多版本同卡展开"></p>
### 👀 看图上手
### 🧭 理念
**1. 扫描一次,以后看封面挑模型。** 点 **扫描**,每个模型都按文件哈希去 C 站认一遍。就算文件叫 `下载 (3).safetensors`,也能显示出它真正的名字、封面、触发词和底模。
**不再造一个套壳 WebUI,也不取代 ComfyUI,只是把 ComfyUI 身边最磨人的杂活接过去。**
<p align="center"><img src="docs/images/scan.webp" width="520" alt="扫描结果:每个文件都在 C 站认出来了"></p>
- **少做没必要的体力活。** 认乱码文件名、修一片红框的缺模型节点、翻旧图找参数、每次重连同一串大模型和 LoRA——这些交给插件做。
- **用的还是你自己的 ComfyUI。** 画布和节点全是 ComfyUI 原生的。插件本身**一个自定义节点都不往工作流里塞**,放到画布上的都是官方节点或你本来就装了的节点包;出的图、另存的工作流,发给没装本插件的朋友照样直接打开。
- **接 AI 也不走“黑盒盲猜”路线。** 你只管用人话提要求;**AMB** 负责在**副本标签页**里打开你的工作流、接好实测过的节点块;真正出图的还是**你本机的 ComfyUI 和你挑的模型**。每一步改了什么都在眼前,先挑构图再跑慢速放大,不满意随时一步撤销。
**2. 打开别人的工作流,不用自己找模型。** 缺模型时画布上方会提示。你电脑里其实有、只是改了名或换了文件夹的,点一下就换上,红框随之消失。
### ✨ 1.63 重点更新
<p align="center"><img src="docs/images/model-check.webp" width="760" alt="模型检查一键换上 3 个缺失的模型"></p>
- **AI 工作台走通“分阶段导演式出图”**:多段流程支持**先快速跑几张一阶段底图让你挑**(点“就选这张”锁种进入二段放大,不重画底图),右侧展台提供**前后拉帘对比**、局部涂抹重绘与“效果好 / 不太行”一键反馈;内置按用途整理的实测**做法**与按需 **Skill**,默认在**副本标签页**干活,不碰你正在改的画布。
- **工作流详情升级“结构流程图 + 橙色预设差异 + 官方示例”**:点开工作流就能看到按出图阶段排布的结构图(底模、LoRA、控制模型横跨在它们作用的阶段上方);切换**预设**时改动过的阶段直接标成**橙色**,点格子即看 `默认 → 当前预设` 参数对比;顶部新增**官方示例**,新装环境打开就能跑。
- **画布存档与锁种公平对比**:右键画布存下当前状态,底部存档条随时切回 `1 · 2 · 3`(连输出节点上的预览图也跟着一起切回对应存档!),支持**锁种**或**随机**批量跑多组存档,并在存档页并排对比每版改了什么和各自出的图。
- **同模型多版本合卡 & 一次按顺序插入多个 LoRA**:来自同一个 C 站模型页的多个版本自动收成一张卡(点右侧数字展开橙边抽屉、图钉设默认版本、按 SHA-256 下载该模型其他版本);**插入 LoRA** 支持点选多个按 `1 · 2 · 3` 编号自动串好线(或放进 rgthree / Easy-Use 堆叠节点),一次 `Ctrl+Z` 全退回;侧边停靠支持拖拽右边缘自由调宽(最窄 `480px`)。
**3. 找到以前的图,拿回当时的工作流。** 在图库里按提示词、模型或 LoRA 搜,再把图拖到画布上。
### 🎬 视频与讨论
* 📺 **哔哩哔哩 (Bilibili)**:[功能演示](https://www.bilibili.com/video/BV1boHx61Enc/) · [角色配音演示](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
* 💬 **Reddit**:[r/comfyui 上的介绍帖](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
<p align="center"><img src="docs/images/gallery.webp" width="760" alt="图库搜索后把图拖到画布"></p>
---
**4. 存下“模型 + LoRA + 提示词”的搭配,一拖就用。** 把搭配卡片拖到画布空白处,就放出一组连好线的节点,画布上原有的东西不动。
### ✨ AI 工作台:你用人话当导演,AMB 搭线,ComfyUI 用你的模型出图
<p align="center"><img src="docs/images/combo.webp" width="760" alt="搭配卡片拖到画布变成连好线的节点"></p>
很多人一听“在 ComfyUI 里接 AI”,第一反应往往是两个顾虑:要么怕它像云端生图软件一样绕开自己的本地模型库,要么怕大模型瞎猜节点连线,把自己辛辛苦苦调好的画布搅得一团糟。**AI 工作台**走的是另一条路——**让 AI 当你的操作助手,规矩由 AMB 守住**:
**5. 像搭积木一样改提示词。** 选中提示词节点,提示词工坊把它拆成词块:点一下调权重,拖动换位置,输入就加(中文会自动译成英文)。
- **默认只改“副本”,绝不弄乱你开着的画布。** 任务默认开启 **AI 改副本**:AI 第一次动手前,AMB 自动把工作流复制到专属的任务标签页里操作,你原来的标签页一笔不动;时间线上的每一步都能点 **撤销** 或 **在画布上找到**。
- **不靠瞎猜连线,优先用你的工作流和实测“做法”。** 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)。
<p align="center"><img src="docs/images/prompt-studio.webp" width="760" alt="提示词工坊按词块编辑提示词节点"></p>
<p align="center"><img src="docs/images/ai-workbench.webp" width="840" alt="AI 工作台:先在右侧展台三选一挑中快速底图,再锁种完成二段精修并用拉帘滑块对比前后细节"></p>
**6. 插件改过什么都查得到,还能撤销。** 插件做的每一处改动都列在“操作记录”里,带 **撤销** 和 **在画布上找到**。
---
<p align="center"><img src="docs/images/activity.webp" width="560" alt="操作记录和撤销按钮"></p>
### 💡 日常玩 ComfyUI,它帮你省掉哪些麻烦(不接 AI 也一样主力)
**7. 给角色配音**(需要 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)):导入 GPT-SoVITS 角色,每种情绪一段参考音频,在配音页写台词直接生成。
**1. 模型塞满硬盘,文件名全是 `download (3).safetensors`。**
点一次 **扫描**,每个模型按文件 SHA-256 哈希去 C 站认领真名、高清封面、触发词和底模角标。同一个 C 站模型的不同版本还会自动收进一张卡片,点右侧数字滑出全部版本(顶部橙边相连),点图钉设默认版本,详情里还能直接核对下载该模型在 C 站上的其他版本。
<p align="center"><img src="docs/images/voices.webp" width="760" alt="角色语音页面"></p>
<p align="center"><img src="docs/images/scan.webp" width="560" alt="扫描明细:把乱七八糟的本地文件名精准匹配回 C 站模型名"></p>
### 🌟 能做什么
**2. 打开别人分享的工作流,满屏红框缺模型。**
**模型检查** 在打开工作流时自动只读体检,能做到哪一步,看工作流里带了什么:
- **带指纹的**(作者也装了本插件,出图、存工作流时默认记下每个模型的 SHA-256):你电脑里其实有、只是改了名或挪了文件夹的同一个文件,能确定认出来,横幅上点一下 **换上** 就全部就位;真缺的按指纹去 C 站找,下载完核对一致才换上。
- **没带指纹的**(作者没装本插件):没法证明是哪一个文件,只能按名字找。别的文件夹里恰好有一个同名文件,会列出来让你逐个确认;VAE、文本编码器这类经常同名的组件不按名字猜。
- **下载**时还会查工作流自带的下载链接(ComfyUI 官方模板就带着)和 ComfyUI-Manager 的模型清单,Hugging Face 的文件可以走国内镜像。只是名字对上的会标成 **按文件名下载**,你点了才下。
窗口左侧的图标栏,每个页面一个图标。
<p align="center"><a href="docs/guides/model-check-rules.md"><img src="https://img.shields.io/badge/📖_模型检查怎么判断-完整规则-blueviolet?style=for-the-badge" alt="模型检查怎么判断:完整规则"></a></p>
| 页面 | 在这里做什么 |
| :--- | :--- |
| **🏠 主页** | 想做什么就点哪张卡片。还有上手第一步(扫描模型文件夹、界面导览)和最近的操作记录。 |
| **🕘 记录** | 按天列出插件做过的每一处改动。<br>画布上:哪个节点的设置从什么改成了什么、加了或删了哪些节点、打开了哪个工作流。<br>文件上:模型、封面、配方、素材、笔记、图片、音频。<br>**在画布上找到** 可以直接跳到那个节点;画布上的改动在没被再改过之前可以点 **撤销**,其余只记录。你自己在画布上的修改不会记进来。 |
| **📦 模型** | 模型带封面、触发词和底模信息。<br>顶部标签切换类型(Checkpoint、LoRA、VAE…),连同子文件夹一起列出;左侧文件夹列表可以只看某个文件夹。<br>**+** 一键在画布上创建加载节点;也可以改名、写备注、换封面,或只扫描这一个模型。 |
| **🖼️ 图库** | 读取 ComfyUI 的 `output` 文件夹。<br>可以按提示词、模型、LoRA、seed、文件名或模型哈希搜索。<br>点开图片能看到生成参数;拖到画布上可以还原当时的工作流。<br>觉得好就点 **☆**:存成整个工作流、搭配(模型 + LoRA + 提示词),或者只存提示词。 |
| **🪡 工作流** | 工作流配方:把完整工作流或其中一段,连同模型、封面、备注和参数一起存下来。<br>卡片会显示这台电脑上模型是否齐全;拖到画布上即可载入。<br>可以比较、恢复历史版本。 |
| **🧩 搭配** | 把常用的主模型、几个 LoRA 和一段提示词存成一个搭配。<br>按 **放到画布**(或把卡片拖到画布上),就放出一组连好线的节点;画布上原有的东西不动。 |
| **✍️ 提示词** | 在画布旁边打开提示词工坊。选中一个提示词节点,它的提示词框就以词块的样子出现在工坊里,直接改:点一下调权重,双击改字,拖动换位置,输入就加(中文、日文等会自动译成英文),还能在每个词下面显示它的中文、日文、韩文等语言的意思。<br>点词卡(几张常用的,加上你存的)加进去,或者直接拖到画布的提示词框里(正面绿框、负面红框),拖到空白处就新建一个提示词节点。没选中提示词节点时写的是草稿。 |
| **🎙️ 角色语音 / 🎧 音频库** | 装了 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 后可用:<br>导入 GPT-SoVITS 角色,给每种情绪选参考音频,修正读音;切到“配音”写台词并直接生成。<br>生成的音频都在音频库里。没装时,页面会说明怎么安装,其他功能不受影响。 |
<p align="center"><img src="docs/images/model-check.webp" width="820" alt="模型检查:一键把 3 个改名或挪过文件夹的红框缺失节点换回正确模型"></p>
图标栏上的工具:
**3. 存下调好的工作流,结构流程图和橙色预设差异一眼看清。**
顶部自带 **官方示例**(文生图、两段放大、图生图、局部重绘,自动配好你本机已有的模型),新手装上就能出图。存下的每张工作流卡点开就是清爽的 **结构流程图**:底模、LoRA、控制模型横跨在它们管辖的采样阶段(`① 出底图` → `② 放大重画`)上方。切换下方的 **预设标签**,和默认参数不同的阶段直接亮起 **橙色**,点任意方格就能在下方展开 `默认 → 当前预设` 的逐项对比;还能打成 `.zip` **配方包** 或生成短短一截 `⇅` 汉字/字母分享码发给别人。
* **扫描**:读取模型文件夹,按文件哈希从 C 站获取封面、触发词和底模。不需要额外安装 Python 包。
* **模型检查**(原“模型医生”):打开工作流时自动检查,缺模型就在画布上方提示;本地改过名、换过文件夹的同一个文件(按哈希和文件大小认)一键换上,其余的在检查页里自己挑或去 Civitai 找。
* **当前节点**(原“节点助手”):选中画布上的节点,查看它用的模型。可以看图换模型、插入 LoRA;存过的同类节点参数列在一起,每条写明会改哪几项,一键套用;存下的参数也在这里删除。
* **工具回到各自的页面**:工作流分享码(导入导出)在“工作流”页,模型来源(每个模型去哪下载)在“模型”页和模型检查里,翻译在“当前节点”的提示词框上。
* **设置**(最下面):语言、字号、缩略图、视频封面、显示哪些模型文件夹,以及提交反馈。主页和设置里的 **报告问题** 会打开一个已经填好版本和显卡信息的 GitHub 页面。
<p align="center"><img src="docs/images/workflows.webp" width="840" alt="工作流页:官方示例、结构流程图、切换预设后橙色高亮改动阶段及节点参数对比"></p>
**不占你的资源**:
- 插件本身不加载任何模型,不和生图抢显存。
- 浏览时显示的是小缩略图,不加载原图;关闭窗口一分半后,图片也会释放掉。
- Anomalous_TTS 的语音模型在 ComfyUI 生图需要显存时会自动让出,点 **卸载模型** 时全部释放。
**4. 想试几种新参数或加个 LoRA,又怕改坏现在这版 —— 画布存档。**
在画布上右键 → **存档当前工作流**,当前状态就存进画布底部的存档条;接着随便改参数、加节点,点 `+` 再存几版。存档条支持选 **锁种**(同一组种子公平对比)或 **随机**,一键把几个存档按顺序排进队列。“工作流”页顶部的 **存档** 把每个存档改了什么和它跑出的图并排摆在一起;点底部存档条的数字 `1`、`2`、`3` 就能在当前标签页秒回那一版——**连输出节点上的预览图也会跟着切回那个存档跑出来的图**(`Ctrl+Z` 可撤回,满意的随时点 **收藏为工作流**)。
### 📦 快速安装
<p align="center"><img src="docs/images/saves.webp" width="840" alt="画布存档:并排查看三个存档的参数节点改动与锁种出图,并在底部存档条一键切换回退"></p>
1. 在 ComfyUI 的 `custom_nodes` 目录下打开终端执行:
**5. 翻到一张老图:“当时到底怎么调出来的?”**
在 **图库** 里按提示词、大模型、LoRA、种子或模型哈希秒搜(哪怕几亿像素的超大图也在后台独立进程生成缩略图,绝不卡死页面),把图往画布上一拖,当时的完整工作流直接回来。
<p align="center"><img src="docs/images/gallery.webp" width="820" alt="图库按标签词块搜索图片,拖入右侧画布立即还原完整工作流"></p>
**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>
**7. 提示词一长串逗号,改权重、调顺序眼都花了 —— 提示词工坊。**
点 **停到画布旁** 把工坊挂在画布左侧,选中任意提示词节点,正负面文本立刻拆成可视化词块:点 `+` / `−` 调权重,双击改字,拖拽换顺序,直接敲中文回车自动译成英文词块加进去,常用词卡还能直接拖进画布的正向(绿框)/ 反向(红框)输入框里。
<p align="center"><img src="docs/images/prompt-studio.webp" width="820" alt="提示词工坊停靠在画布旁,按词块实时调整选中节点的提示词权重"></p>
**8. 想让画出来的角色开口说话。**
配合可选节点包 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS):导入 GPT-SoVITS 角色,按情绪绑定参考音频、修正多音字读音,在配音页写好台词直接生成语音。
<p align="center"><img src="docs/images/voices.webp" width="760" alt="角色语音页面:按情绪管理参考音频与台词配音"></p>
**改错了也不怕。**
插件和 AI 在画布上改过的每一个参数、连过的每一根线,都按天记在 **记录** 里,展开就能看修改前后的数值,点 **在画布上找到** 直接跳过去,点 **撤销** 一键退回。
<p align="center"><img src="docs/images/activity.webp" width="760" alt="操作记录页面:展开查看节点修改前后数值,一键在画布上找到或撤销"></p>
**不抢你的显存和内存**:
- 插件自身不加载任何生图模型,**零显存占用**,绝不和 ComfyUI 出图抢资源。
- 浏览网格全程走小缩略图缓存,不把原图塞进浏览器;关闭窗口一分半后自动释放图片内存。
- `Anomalous_TTS` 的语音模型在 ComfyUI 生图需要显存时会自动让出,点 **卸载模型** 则彻底清空。
### 📦 安装
1. 在 **ComfyUI Manager** 里搜索 `Anomalous Model Browser`,安装后重启 ComfyUI;或在 `custom_nodes` 目录下手动克隆:
```bash
cd custom_nodes
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
```
2. 重启 ComfyUI 即可使用。(*也可以直接在 **ComfyUI Manager** 搜索 `Anomalous Model Browser` 安装*)
3. 想用角色配音的话,用同样的方法再装 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)。不装也不影响其他功能。
4. **更新插件**:
- 点窗口右上角的 **!**,可以看到当前版本;
- 点 **检查更新 / 切换版本** 打开版本面板,可以更新到最新版、回退到以前的版本,或回到最新版;
- 换完版本后重启 ComfyUI。插件不会自己联网检查更新。
2. <a id="where-zh"></a>**装好后去哪找**:重启后,画布左上角会多一个小立方体按钮(下图圈出来的那个),点它或按 **Shift + B** 打开窗口。第一次用可以在 **主页** 点 **扫描模型文件夹** 补齐封面和触发词,点 **界面导览** 跟着认识一圈各个区域。
打开方式有三种:按 **Ctrl + Shift + M**、点击画布上的悬浮按钮,或者从 ComfyUI 菜单 **扩展 → Anomalous Model Browser** 打开。在 **ComfyUI 设置 → Anomalous Model Browser → 界面** 里,可以修改快捷键、插件语言和打开方式(悬浮按钮、运行按钮旁的按钮,或只用扩展菜单)。
<p align="center"><img src="docs/images/entry.webp" width="640" alt="画布左上角的立方体按钮,以及 ComfyUI 菜单 C → Extensions → Anomalous Model Browser → 打开"></p>
第一次打开时会停在 **主页**:点 **扫描模型文件夹** 补全封面和触发词;点 **界面导览** 会逐一指给你看窗口的各个部分。
看不到这个立方体?可能被拖走了,或者换成了别的入口:点左上角 ComfyUI 的 **C** 菜单 → **Extensions → Anomalous Model Browser**,里面有 **打开 Anomalous 模型浏览器** 和 **重置悬浮入口位置**。想让它更显眼,去 **ComfyUI 设置 → Anomalous Model Browser → 界面**:**悬浮入口样式** 选 **胶囊文字**(会带上“模型浏览器”几个字),**悬浮入口大小** 选 **大**;或者 **浏览器入口模式** 选 **顶部动作栏**,放到运行按钮旁边。
3. 需要角色配音的话,再安装 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)(ComfyUI Manager 搜索 `Anomalous TTS`);不装完全不影响其他功能。
> [!WARNING]
> **备份自己的数据:** 工作流配方、搭配、存下的提示词和节点参数、参数预设存在 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_notebooks`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters` 文件夹。更新插件前顺手备份一份,更安心。
扫描模型默认联网查 C 站(纯内置实现,无需额外装 Python 包)。没开代理或断网时选 **离线扫描** 也能直接从模型文件头识别底模与架构,等以后连上网再扫一次会自动补齐封面和触发词(详见下方“网络和备份”)。
<details>
<summary><b>📖 每个页面和工具的详细说明</b></summary>
窗口左侧是页面图标栏;切换为“侧边停靠”时,按住窗口右边缘即可左右拖宽窄(最窄 `480px`,双击边缘恢复默认 `660px`)。
| 页面 | 在这里做什么 |
| :--- | :--- |
| **🏠 主页** | 按你想做的事分好入口卡片,包含新手第一步(扫描模型文件夹、界面导览)、AI 最近做的任务和最新操作记录。 |
| **🕘 记录** | 按天列出插件和 AI 做过的全部改动:画布改动(具体哪个节点的什么参数从多少改成多少、增删了哪些节点、关联哪个 AI 任务)与文件操作(模型、封面、工作流、搭配、提示词、图片、音频)。支持 **在画布上找到** 定位节点,没被后续覆盖的画布改动可随时 **撤销**。 |
| **📦 模型** | 带封面、触发词和底模分类条浏览模型,左侧可按子文件夹筛选,点 **+** 一键往画布放加载器。来自同一 C 站模型页的多个版本自动收成一张卡(点右侧数字展开橙边版本排、图钉固定默认显示版本、右上可 **展开全部版本**);详情页点 **Civitai 上的全部版本** 可按需联网查看并按 SHA-256 核对下载其他版本。另含 **整理**(排查放错目录或重复文件)与 **模型来源**。 |
| **🖼️ 图库** | 读取 ComfyUI 的 `output` 文件夹(几亿像素超大图由后台子进程做缩略图,浏览不卡)。支持按提示词、模型、LoRA、种子、文件名或模型哈希组合搜索;看大图与完整参数、勾选两张对比工作流差异、拖进画布还原工作流,或点 **☆** 存为工作流、搭配、提示词。 |
| **✨ AI 工作台** | 按任务记录并操控接入的 AI:左侧任务树(后续任务自动缩进挂在主任务下),中间实时步骤时间线(每步可撤销,顶部带 **AI 改副本** 与 **切到副本**),右侧可切换 **展台**(一阶段底图挑选、前后拉帘滑块、选择题、`✎` 涂抹局部重绘、**效果好 / 不太行**)、**节点结构**、**决策**、**素材**、**成果**、**做法**(支持点“这次用它”)与 **Skill**,以及权限 **关卡**(含可选单任务 `🔒` 隐私开关)。 |
| **🪡 工作流** | 顶部是 **官方示例** 与 **存档**,下方是保存的工作流卡。详情页从上到下依次为 **结构流程图**、吸顶 **预设标签栏**(橙色标出与默认不同的阶段,点方格看逐项差异,支持 **+ 新预设**)、**交给 AI**、历史版本对比恢复,支持导出 `.zip` **配方包** 或通过 `⇅` 收发短分享码、拖入图片/压缩包导入。 |
| **🧩 搭配** | 把顺手的主模型、几个 LoRA(连同各自权重)和正反提示词存成一张搭配卡。点 **放到画布** 或拖入画布即生成连好线的独立节点组,绝不改动画布上原有的节点。 |
| **✍️ 提示词** | 提示词工坊,可全窗口用,也可点 **停到画布旁** 边看节点边改。选中提示词节点后自动拆成词块:单击调权重、双击改字、拖拽排顺序、输入中文/日文回车自动译成英文词块,还能在词块下显示多语种释义;左侧词卡支持一键加入或拖进画布正/反向输入框,未选节点时可当草稿本写。 |
| **🎙️ 角色语音 / 🎧 音频库** | 安装 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 后启用:导入 GPT-SoVITS 角色、按情绪选参考音频、修读音,在“配音”页写台词直接生成,并在音频库回放管理。 |
左侧栏底部的常用工具(按日常使用频率从上到下排列):
* **当前节点**(原“节点助手”):跟随画布当前选中的节点,看封面换模型,或点 **插入 LoRA** 一次勾选多个 LoRA 按 `1 · 2 · 3` 顺序自动串接(装了 rgthree / Easy-Use 可选塞入单个堆叠节点);下方按来源小标签(工作流、搭配、图片、本节点存的)列出同类节点参数,点 `⇄ 对比` 只看当前节点差异并一键套用。
* **模型检查**(原“模型医生”):打开工作流自动只读检查;本机改过名或换过目录的同一文件凭 SHA-256 指纹在画布顶部横幅一键换上(指纹由本插件写进工作流:**设置 → 在工作流里记下模型指纹**,默认开启);没有指纹时列出同名文件让你确认。缺的模型从 C 站(按指纹)、工作流自带的链接或 Manager 清单下载。[完整规则](docs/guides/model-check-rules.md)。
* **模型扫描**:按文件哈希向 C 站匹配模型信息与封面;离线时也已针对 Manager 清单 300+ 真实模型文件头校准过底模与类型识别,不乱下误判结论。
* **设置**(最下方):中英文实时切换(工作台展台、留言框与底部存档条立即生效,无需刷新页面)、界面缩放、深浅主题、缩略图与视频封面、Hugging Face 国内镜像、数据备份导出/导入,以及自动附带版本与显卡信息的 **报告问题**。
**接入 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>
打开方式有三种:按 **Shift + B**(再按一次关闭)、点画布左上角的立方体按钮,或从 ComfyUI 菜单 **Extensions → Anomalous Model Browser** 进入(中文界面下这一项也叫 Extensions)。在 **ComfyUI 设置 → Anomalous Model Browser → 界面** 中可自定义快捷键、语言及入口按钮样式。
**更新与版本切换**:点击窗口右上角的 **!** → **检查更新 / 切换版本**,可一键升级到最新正式版或回退到历史版本,完成后重启 ComfyUI 生效。插件绝不会在后台悄悄联网检查更新。
</details>
<details>
<summary><b>🌐 网络和备份</b></summary>
**云服务器(AutoDL、RunPod 等):** 海外云服务器完整支持;国内云服务器只能部分支持:多半连不上 Civitai,有的能连上 `civitai.red` 认出模型名与触发词(封面较难下载)。目前只实测过一台 AutoDL 服务器。从非本机浏览器直接访问时插件默认只读保护,建议通过 SSH 隧道映射到本地打开,详见 [docs/guides/cloud-server.md](docs/guides/cloud-server.md)(内附可直接丢给 AI 助手照着配的步骤)。
**关于网络:** 扫描封面与谷歌提示词翻译需要联网(连不上 `civitai.com` 时会自动尝试 `civitai.red`;无代理时选 **离线** 扫描同样能识别底模)。下载 Hugging Face 模型可在 **设置 → 模型下载** 开启 `hf-mirror.com` 国内镜像。日常浏览模型、图库、工作流、存档和搭配**完全不需要联网**。
**备份自己的数据:** **设置 → 备份 → 导出** 会把工作流、预设、画布存档、搭配、提示词库、节点参数以及你自定义的模型名/备注/封面打包成一个 `.zip`,换电脑或重装后点 **导入** 即可恢复。导入从不删东西:和备份里不一样的文件默认留着你电脑上的,选了“换成备份里的”才替换,原来的进回收站。备份里还记着你有哪些模型和它们的指纹,换了新电脑,缺的模型可以照着清单查来源补下载。(数据保存在 ComfyUI 用户目录下的 `workflows/anomalous_*` 文件夹中。)
</details>
---
### 🧪 Tests (测试)
The plugin's tests live in `tests/`; run the full suite with `node tools/run_tests.mjs` from the plugin folder. See [docs/guides/testing.md](docs/guides/testing.md).
插件测试位于 `tests/` 目录,在插件根目录运行 `node tools/run_tests.mjs` 即可一次跑完。提 PR 或改代码前请先跑通,详见 [docs/guides/testing.md](docs/guides/testing.md)。
### 📝 License & Branding (开源与品牌声明)
* **Code License (代码授权)**: The source code is released under the [MIT License](LICENSE). 本项目源代码基于 MIT 许可证开源,可自由使用、修改与分发。
+3
View File
@@ -1,5 +1,6 @@
import os
from .api import setup_routes
from .api.usage_ledger import note_prompt
from server import PromptServer
WEB_DIRECTORY = "./web"
@@ -7,6 +8,8 @@ WEB_DIRECTORY = "./web"
# 注册 API 路由
if hasattr(PromptServer, "instance") and PromptServer.instance is not None:
setup_routes(PromptServer.instance.app)
# Each queued run's nodes and models, for the Tidy tab's "Not used lately" (api/usage_ledger.py).
PromptServer.instance.add_on_prompt_handler(note_prompt)
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
+45 -4
View File
@@ -1,16 +1,20 @@
from . import (
activity_log, audio_catalog, folder_types, gallery_routes, materials, media_routes, model_catalog,
model_media, model_metadata, model_resolution, model_type_listing, kept_images, node_material, recipe_packages, recipes, scan_report, scan_summary,
local_guard, translation_routes, version_manager,
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,
)
from .scanner import *
from .config import *
from .notebooks import *
from .parameters import *
import os
from aiohttp import web
EXTENSION_STATIC_PREFIX = '/extensions/Anomalous_Model_Browser/'
# ComfyUI serves the web folder under the plugin folder's own name, whatever it is called.
EXTENSION_STATIC_PREFIX = f'/extensions/{os.path.basename(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))}/'
@web.middleware
@@ -27,18 +31,31 @@ async def no_cache_extension_middleware(request, handler):
return response
def setup_routes(app):
model_heads.keep_on_disk()
if hasattr(app, 'middlewares') and no_cache_extension_middleware not in app.middlewares:
app.middlewares.append(no_cache_extension_middleware)
# Other devices on the network may look; only this computer may change things (local_guard.py).
if hasattr(app, 'middlewares') and local_guard.local_only_middleware not in app.middlewares:
app.middlewares.append(local_guard.local_only_middleware)
app.router.add_get('/anomalous/access', local_guard.api_access)
app.router.add_post('/anomalous/snapshot_check', snapshot_routes.api_snapshot_check)
app.router.add_get('/anomalous/saves', workflow_saves.api_list)
app.router.add_get('/anomalous/saves/group', workflow_saves.api_group)
app.router.add_get('/anomalous/saves/workflow', workflow_saves.api_workflow)
app.router.add_post('/anomalous/saves/add', workflow_saves.api_add)
app.router.add_post('/anomalous/saves/update', workflow_saves.api_update)
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)
try:
folder_types.upgrade_folder_defaults()
except Exception as error:
print(f"[Anomalous Browser] Could not update the shown model folders: {error}")
app.router.add_get('/anomalous/folders', model_catalog.api_get_folders)
app.router.add_get('/anomalous/all_folder_types', folder_types.api_get_all_folder_types)
app.router.add_get('/anomalous/models', model_catalog.api_get_models)
app.router.add_get('/anomalous/type_models', model_type_listing.api_get_type_models)
app.router.add_get('/anomalous/all_scan_models', model_catalog.api_get_all_scan_models)
app.router.add_get('/anomalous/batch_select', model_catalog.api_batch_select)
app.router.add_get('/anomalous/image', media_routes.api_serve_image)
app.router.add_get('/anomalous/card_cache', media_routes.api_card_cache)
app.router.add_post('/anomalous/card_cache/clear', media_routes.api_clear_card_cache)
@@ -48,21 +65,26 @@ def setup_routes(app):
app.router.add_get('/anomalous/config', api_get_config)
app.router.add_post('/anomalous/save_config', api_save_config)
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/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)
app.router.add_post('/anomalous/delete_notebook', api_delete_notebook)
# Parameter Routes
app.router.add_get('/anomalous/parameters', api_get_parameters)
app.router.add_get('/anomalous/parameters/by_node_type', api_get_parameters_by_type)
app.router.add_get('/anomalous/parameters/signatures', api_get_parameter_set_signatures)
app.router.add_post('/anomalous/save_parameter', api_save_parameter)
app.router.add_post('/anomalous/rename_parameter', api_rename_parameter)
app.router.add_post('/anomalous/delete_parameter', api_delete_parameter)
app.router.add_get('/anomalous/parameter_gallery', api_get_parameter_gallery)
app.router.add_get('/anomalous/parameter_pictures', api_get_parameter_pictures)
# Recipe Routes
app.router.add_get('/anomalous/recipes', recipes.api_get_recipes)
app.router.add_get('/anomalous/example_workflows', example_workflows.api_examples)
app.router.add_get('/anomalous/recipe_full', recipes.api_get_recipe_full)
app.router.add_get('/anomalous/recipe_asset', recipes.api_get_recipe_asset)
app.router.add_get('/anomalous/recipe_gallery', recipes.api_get_recipe_gallery)
@@ -70,6 +92,7 @@ def setup_routes(app):
app.router.add_get('/anomalous/recipe_gallery_compare', recipes.api_get_recipe_gallery_compare)
app.router.add_post('/anomalous/save_recipe', recipes.api_save_recipe)
app.router.add_post('/anomalous/update_recipe', recipes.api_update_recipe)
app.router.add_post('/anomalous/recipe_focus', recipes.api_set_recipe_focus)
app.router.add_post('/anomalous/set_recipe_gallery_cover', recipes.api_set_recipe_gallery_cover)
app.router.add_post('/anomalous/delete_recipe', recipes.api_delete_recipe)
app.router.add_get('/anomalous/recipe_history', recipes.api_get_recipe_history)
@@ -98,7 +121,9 @@ def setup_routes(app):
app.router.add_get('/anomalous/gallery_images', gallery_routes.api_get_gallery_images)
app.router.add_post('/anomalous/delete_gallery_image', gallery_routes.api_delete_gallery_image)
app.router.add_get('/anomalous/output_thumbnail', gallery_routes.api_output_thumbnail)
app.router.add_get('/anomalous/output_preview', gallery_routes.api_output_preview)
app.router.add_get('/anomalous/kept_images', kept_images.api_kept_images)
app.router.add_get('/anomalous/quick_index', quick_index.api_quick_index)
app.router.add_get('/anomalous/resolve_hash', model_resolution.api_resolve_hash)
app.router.add_post('/anomalous/resolve_hash_batch', model_resolution.api_resolve_hash_batch)
app.router.add_get('/anomalous/all_hashes', model_resolution.api_get_all_hashes)
@@ -108,11 +133,16 @@ def setup_routes(app):
app.router.add_post('/anomalous/update_metadata', model_metadata.api_update_metadata)
app.router.add_post('/anomalous/set_custom_cover', model_media.api_set_custom_cover)
app.router.add_post('/anomalous/upload_custom_cover', model_media.api_upload_custom_cover)
app.router.add_get('/anomalous/model_covers', model_media.api_model_covers)
app.router.add_get('/anomalous/model_images', media_routes.api_get_model_images)
app.router.add_post('/anomalous/resolve_paths_to_previews', model_catalog.api_resolve_paths_to_previews)
app.router.add_get('/anomalous/scan_summary', scan_summary.api_scan_summary)
app.router.add_get('/anomalous/scan_matched', scan_summary.api_scan_matched)
app.router.add_get('/anomalous/scan_pickable', scan_summary.api_scan_pickable)
app.router.add_get('/anomalous/last_scan', scan_report.api_last_scan)
app.router.add_get('/anomalous/scan_model', scan_report.api_scan_model)
app.router.add_post('/anomalous/scan_choice', scan_choice.api_set_scan_choice)
app.router.add_get('/anomalous/lora_info', lora_info.api_lora_info)
# Audio & Voice Studio Routes
app.router.add_get('/anomalous/audio_stream', audio_catalog.api_serve_audio)
@@ -121,4 +151,15 @@ def setup_routes(app):
app.router.add_get('/anomalous/audio_previews', audio_catalog.api_get_audio_previews)
app.router.add_post('/anomalous/save_audio_preview', audio_catalog.api_save_audio_preview)
version_manager.register_routes(app)
tts_version.register_routes(app)
activity_log.register_routes(app)
mcp_server.register_routes(app)
ai_tasks_routes.register_routes(app)
model_download.register_routes(app)
network_check.register_routes(app)
model_usage.register_routes(app)
usage_ledger.register_routes(app)
sidecar_layout.register_routes(app)
model_import.register_routes(app)
model_placement.register_routes(app)
backup.register_routes(app)
+108
View File
@@ -0,0 +1,108 @@
"""What each thing an AI app can do through Anomalous's MCP endpoint is, in one place.
Every tool (mcp_tools.py reads, mcp_actions.py acts, mcp_task_tools.py the AI's own) has an
entry here: its kind (the AI workbench's colour, words and counts; None = not a step of its
own), the stage of the work it belongs to (the flow over a task: models, combo, prompt,
canvas, run, pictures, voice, library) and its gate (what it touches or costs, for the user's
rules on what needs their OK: canvas, gpu, download, network, files; None = nothing to guard).
tests/test_ai_tasks.py keeps this catalog and the tools in step.
The user's own changes in Anomalous (activity log actions) get stages too, so the ones made
while an AI works sit in the same flow as its steps (ai_tasks.note_activity).
"""
from collections import namedtuple
Entry = namedtuple("Entry", "kind stage gate")
STAGES = ("model", "combo", "prompt", "canvas", "run", "picture", "voice", "library")
GATES = ("canvas", "gpu", "download", "network", "files")
# Arguments with which a call also changes the canvas: it then asks as the stricter of its own gate and "canvas".
ALSO_CANVAS = {"run_workflow": ("workflow", "prompt", "prompts", "negative", "seed", "width", "height", "image", "image_node", "values")}
TOOLS = {
# reading
"library_overview": Entry("look", "library", None),
"search_models": Entry("look", "model", None),
"get_model": Entry("look", "model", None),
"scan_report": Entry("look", "library", None),
"search_images": Entry("look", "picture", None),
"get_image_info": Entry("look", "picture", None),
"zoom_image": Entry("look", "picture", None),
"list_combos": Entry("look", "combo", None),
"list_workflows": Entry("look", "canvas", None),
"list_saved_prompts": Entry("look", "prompt", None),
"search_audio": Entry("look", "voice", None),
"recent_activity": Entry("look", "library", None),
"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"),
"set_model": Entry("canvas", "model", "canvas"),
"add_lora": Entry("canvas", "model", "canvas"),
"place_combo": Entry("canvas", "combo", "canvas"),
"fix_workflow_models": Entry("canvas", "model", "canvas"),
"find_nodes": Entry("look", "canvas", None),
"add_node": Entry("canvas", "canvas", "canvas"),
"add_nodes": Entry("canvas", "canvas", "canvas"),
"connect_nodes": Entry("canvas", "canvas", "canvas"),
"set_values": Entry("canvas", "canvas", "canvas"),
"remove_nodes": Entry("canvas", "canvas", "canvas"),
"new_workflow": Entry("canvas", "canvas", "canvas"),
"open_workflow": Entry("canvas", "canvas", "canvas"),
"run_workflow": Entry("run", "run", "gpu"),
"download_missing_models": Entry("download", "model", "download"),
"download_model": Entry("download", "model", "download"),
"move_model": Entry("act", "model", "files"),
"scan_models": Entry("scan", "library", "network"),
"speak": Entry("speech", "voice", "gpu"),
"open_in_anomalous": Entry("show", "model", None),
# the AI's own: notes and panels are steps; the rest only steer the task
"report": Entry("note", None, None),
"log_decision": Entry("decision", None, None),
"log_outside": Entry("outside", None, None), # what it did with other tools (ai_outside.py, which also notes what changed)
"show_panel": Entry("panel", None, None),
"ask_user": Entry("panel", None, None),
"propose_repaint_area": Entry("panel", "picture", None),
"show_run_map": Entry("panel", None, None),
"begin_task": Entry(None, None, None),
"track_run": Entry(None, "run", None), # attaches an existing submission; creates its own run step
"update_panel": Entry(None, None, None),
"get_answers": Entry(None, None, None),
"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),
# 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),
}
# Activity log actions (activity_log.py ROUTE_ACTIONS, and "canvas") the user makes in Anomalous
# that belong in the task an AI is working on, with their stage. Downloads, imports, moves, scans
# and backups are left out: an AI's own calls can cause those too.
USER_ACTIONS = {
"canvas": "canvas",
"model_edit": "library", "model_cover": "library", "model_delete": "library",
"recipe_save": "canvas", "recipe_edit": "canvas", "recipe_delete": "canvas", "recipe_restore": "canvas",
"material_save": "prompt", "material_edit": "prompt", "material_delete": "prompt",
"preset_save": "run", "preset_rename": "run", "preset_delete": "run",
"image_delete": "picture", "audio_delete": "voice", "audio_save": "voice", "voice_settings": "voice",
"note_save": "library", "note_delete": "library",
}
def entry(tool, read_only=True):
"""The catalog entry of `tool`; one not listed is a read, or an act when it is not read-only."""
found = TOOLS.get(tool)
if found:
return found
return Entry("look" if read_only else "act", None, None)
def is_step(tool):
"""Whether a call of `tool` is a step of its own in a task."""
return entry(tool).kind is not None
+33 -7
View File
@@ -24,6 +24,7 @@ from .path_utils import atomic_write_json
MAX_ENTRIES = 500
MAX_LOG_BYTES = 2 * 1024 * 1024 # long canvas entries could otherwise outgrow the JSON writer's limit
MAX_VALUE_CHARS = 400
MAX_CHANGE_CHARS = 2000 # a before/after value: room for a whole prompt, so its receipt can compare tags
DIFF_CONTEXT_CHARS = 60 # kept before the first difference of a long before/after pair
MAX_CHANGES = 60
@@ -66,6 +67,7 @@ ACTIVITY_DETAIL = web.RequestKey("activity_detail", dict) if hasattr(web, "Reque
_lock = threading.RLock()
_entries = None
_listeners = [] # called with each new entry once it is saved (ai_tasks.note_activity)
def log_path():
@@ -104,19 +106,20 @@ def _text(value):
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
def _short(value):
def _short(value, limit=MAX_VALUE_CHARS):
text = _text(value)
return text if len(text) <= MAX_VALUE_CHARS else text[:MAX_VALUE_CHARS] + '…'
return text if len(text) <= limit else text[:limit] + '…'
def _short_values(item, keys):
"""`keys` of `item` shortened. A long before/after pair is cut around its first difference
instead of at the start, so a change late in a long prompt still shows."""
out = {key: _short(item[key]) for key in keys if key in item and item[key] is not None}
out = {key: _short(item[key], MAX_CHANGE_CHARS if key in ('before', 'after') else MAX_VALUE_CHARS)
for key in keys if key in item and item[key] is not None}
if 'before' not in out or 'after' not in out:
return out
old, new = _text(item['before']), _text(item['after'])
if len(old) <= MAX_VALUE_CHARS and len(new) <= MAX_VALUE_CHARS:
if len(old) <= MAX_CHANGE_CHARS and len(new) <= MAX_CHANGE_CHARS:
return out
same = 0
while same < min(len(old), len(new)) and old[same] == new[same]:
@@ -124,7 +127,7 @@ def _short_values(item, keys):
start = max(0, same - DIFF_CONTEXT_CHARS)
def cut(text):
return ('…' if start else '') + text[start:start + MAX_VALUE_CHARS] + ('…' if start + MAX_VALUE_CHARS < len(text) else '')
return ('…' if start else '') + text[start:start + MAX_CHANGE_CHARS] + ('…' if start + MAX_CHANGE_CHARS < len(text) else '')
out['before'], out['after'] = cut(old), cut(new)
return out
@@ -145,9 +148,29 @@ def add_entry(source, action, target='', detail=None):
entries.insert(0, entry)
_trim(entries)
atomic_write_json(log_path(), entries)
for listener in _listeners:
try:
listener(entry)
except Exception: # noqa: BLE001 - a listener must never fail the action recorded
logging.getLogger(__name__).warning('Anomalous activity log: a listener failed', exc_info=True)
return entry
def add_listener(listener):
if listener not in _listeners:
_listeners.append(listener)
def tag(entry_id, task_id):
"""Names the AI task an entry belongs to (made by the AI, or by the user while it worked)."""
with _lock:
entries = _load()
found = next((entry for entry in entries if entry.get('id') == entry_id), None)
if found and found.get('task') != task_id:
found['task'] = task_id
atomic_write_json(log_path(), entries)
def list_entries(limit=100, source=None):
with _lock:
entries = _load()
@@ -232,7 +255,7 @@ def _clean_changes(changes):
if not isinstance(change, dict):
continue
item = _short_values(change, ('kind', 'node', 'type', 'widget', 'before', 'after'))
if item.get('kind') in ('changed', 'added', 'removed', 'opened'):
if item.get('kind') in ('changed', 'wired', 'added', 'removed', 'opened'):
cleaned.append(item)
return cleaned
@@ -248,7 +271,8 @@ async def api_get_activity(request):
async def api_post_activity(request):
"""Canvas changes from the browser: {changes: [...], total, workflow}."""
"""Canvas changes from the browser: {changes: [...], total, workflow, via?}; via "mcp" marks
changes an AI app made through the MCP endpoint."""
try:
body = await request.json()
except ValueError:
@@ -260,6 +284,8 @@ async def api_post_activity(request):
return web.json_response({'error': 'No recognizable changes'}, status=400)
total = body.get('total') if isinstance(body.get('total'), int) else len(changes)
detail = {'changes': changes, 'total': max(total, len(changes))}
if body.get('via') == 'mcp':
detail['via'] = 'mcp'
entry = await asyncio.get_running_loop().run_in_executor(
None, add_entry, 'canvas', 'canvas', body.get('workflow') if isinstance(body.get('workflow'), str) else '', detail)
return web.json_response({'entry': entry})
+109
View File
@@ -0,0 +1,109 @@
"""How long an AI waits for the user to answer its question (ask_user, a question on a panel).
One tool call waits at most ai_panels.MAX_WAIT (55 s, under the minute AI apps give a call), so
the answer to a question still open says whether to wait on with get_answers: until the time the
user set in the Gates window (ai_gates.answer_wait, from when it asked), and while the user is
typing an answer (the page posts POST /anomalous/ai_tasks/{id}/typing as they type) until a while
after their last key. Then the AI ends its turn and the user brings it back with a line to paste
(web/modules/ai_call_lines.js); or, when the user set so (ai_gates.no_answer "recommended"), each
open choice or picks block the AI marked a recommended option for takes it, marked as such for the user and the
AI (recommended_on_timeout), and the AI goes on; not once the user began writing (typed since the
question came): they stepped away mid-answer, and it waits for them. Typing is kept in memory only.
"""
import time
from . import ai_gates, ai_panels, ai_tasks
AFTER_TYPING = 60 # the user typed this recently: their answer is on its way
TOOK = ("The user did not answer in time and set that your recommended option goes then: Anomalous took it for "
"them (recommended_on_timeout). Go on with it; a question without a recommended option stays open for the user.")
STOP = "You waited as long as the user set: end your turn now. They answer in Anomalous and tell you in your chat to go on."
WROTE = ("The user began writing an answer, then stepped away: end your turn now. Nothing is chosen for them; they "
"finish it in Anomalous and tell you in your chat to go on.")
_typed = {} # task id -> time of the user's last key
def take_recommended(task_id, step):
"""Answers each open choice or picks block of panel step `step` that has a recommended option with it. Returns how many."""
taken = 0
for block in step["panel"].get("blocks") or []:
picked = [option["id"] for option in block.get("options") or block.get("items") or [] if option.get("recommended")]
if block.get("type") not in ("choices", "picks") or not picked or block.get("id") in (step.get("answers") or {}):
continue
try:
ai_panels.answer(task_id, step["id"], block["id"], choice=picked if block.get("multiple") else picked[:1],
extra={"recommended_on_timeout": True})
taken += 1
except ai_panels.PanelError:
pass
return taken
def typing(task_id, now=None):
"""The user is typing in task `task_id` (an answer or a message). False for no such task."""
with ai_tasks._lock:
if not ai_tasks.get_task(task_id):
return False
_typed[task_id] = time.time() if now is None else now
return True
def wait_on(panel_id, now=None, allow_recommended=True):
"""For a question of panel `panel_id` still open: {keep_waiting, seconds_left?, typing?, note}, or {}."""
with ai_tasks._lock:
task, step = ai_panels._find(panel_id)
return _wait_on(task, step, now, allow_recommended)
def _wait_on(task, step, now, allow_recommended):
"""Check typing and commit a timeout choice under the same lock as user answers."""
if not step:
return {}
now = time.time() if now is None else now
typed = _typed.get(task["id"], 0)
left = max(step["at"] + ai_gates.answer_wait(), typed + AFTER_TYPING) - now
if left <= 0:
wrote = typed >= step["at"] # a half-written answer is theirs: no option is taken over it
if allow_recommended and not wrote and ai_gates.no_answer() == "recommended" and take_recommended(task["id"], step):
return {"keep_waiting": False, "took_recommended": True, "note": TOOK}
return {"keep_waiting": False, "note": WROTE if wrote else STOP}
typing_now = now - typed < AFTER_TYPING
return {"keep_waiting": True, "seconds_left": round(left), **({"typing": True} if typing_now else {}),
"note": ("The user is typing their answer" if typing_now else "The user has not answered yet")
+ ": call get_answers(panel, wait_seconds=55) now, and again while keep_waiting is true. Do not "
"end your turn or ask the same in your own chat."}
def for_answer(answer, panel_id=None, now=None):
"""get_answers' `answer`, with how to wait on when nothing came back but questions still open:
for panel `panel_id`, or without one for the newest open question while the user's time runs."""
waiting = answer.get("still_waiting") or []
if not waiting or answer.get("answers") or answer.get("messages"):
return answer
if panel_id is None:
found = [wait_on(panel, now) for panel in dict.fromkeys(item["panel"] for item in waiting)]
live = [item for item in found if item.get("keep_waiting")]
status = max(live, key=lambda item: item["seconds_left"]) if live else next((item for item in found if item), {})
took = any(item.get("took_recommended") for item in found)
return {**answer, **status, **({"took_recommended": True} if took else {})}
return {**answer, **wait_on(panel_id, now)}
def collect(client, panel_id=None, wait_seconds=0, task_id=None):
"""Wait unlocked, then deliver a timeout choice or a racing user answer in this same call."""
answer = ai_panels.get_answers(client, panel_id, wait_seconds, task_id=task_id)
if answer.get("answers") or answer.get("messages") or not answer.get("still_waiting"):
return answer
with ai_tasks._lock:
# Input may have arrived after the long wait released its lock. Read it before choosing.
answer = ai_panels.get_answers(client, panel_id, 0, task_id=task_id)
if answer.get("answers") or answer.get("messages") or not answer.get("still_waiting"):
return answer
status = for_answer(answer, panel_id)
fresh = ai_panels.get_answers(client, panel_id, 0, task_id=task_id)
if fresh.get("answers") or fresh.get("messages"):
if any(item.get("recommended_on_timeout") for item in fresh.get("answers", [])):
fresh["note"] = TOOK
return fresh
return {**status, **fresh} if fresh.get("still_waiting") else fresh
+183
View File
@@ -0,0 +1,183 @@
"""The bases the user picks from (run_workflow base_only; the cut: web/modules/base_only.js). When
such a run ends its previews, in ComfyUI's temp folder (emptied whenever ComfyUI starts or stops),
become a picks panel. The one the user picks is copied into the output folder, beside where the
canvas saves its base, and its seed is held: the task's next run_workflow puts it on that sampler,
"fixed", kept under every try (web/modules/mcp_bridge.js). Where the rest of the workflow takes the
base's picture, it takes the kept picture and the base is not drawn again: drawn again, even from the
same seed and settings, it came out grainy once on a 6 GB card (2026-11-04). A base handed on as a
latent (the mid-way switch) is drawn again from its seed; when that run saves its base, the picture is
compared with the kept one, so the user and the AI know whether the second pass grew from it."""
import os
import shutil
from PIL import Image, ImageChops, ImageStat
from . import ai_outside, ai_panels, ai_pictures, ai_tasks
SAME_BELOW = 1.0 # mean difference per channel (0-255); the same seed and settings drew the same picture
PREFIX = "amb_base/base" # where a picked base goes when the canvas saves none of its own
TITLE = "挑一张底图 / Pick a base"
QUESTION = ("挑中的那张会存进输出文件夹,下一步用它的种子接着画 / "
"The one you pick is kept in the output folder; the next pass grows from its seed")
def _step(task, step_id):
return next((item for item in (task or {}).get("steps") or [] if item.get("id") == step_id), None)
def hold_of(task_id):
"""The base the user picked in task `task_id`, for run_workflow to hold, or None."""
task = ai_tasks.get_task(task_id) if task_id else None
return dict((task or {}).get("base_hold") or {}) or None
def forget(task_id):
"""New bases are coming: the earlier pick no longer holds."""
with ai_tasks._lock:
task = ai_tasks._load().get(task_id) if task_id else None
if task and task.pop("base_hold", None) is not None:
ai_tasks._save(task)
def _seed(prompt, base):
inputs = (prompt.get(str(base["node"])) or {}).get("inputs") or {}
return next((inputs[name] for name in base.get("names") or [] if isinstance(inputs.get(name), int)), None)
def offer(task_id, step_id, prompts, history):
"""A finished base_only run: its previews on a picks panel, each with the seed that drew it."""
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
step = _step(task, step_id)
info = (step or {}).get("bases")
if not info or step.get("state") != "done" or step.get("offered"):
return None
step["offered"] = True
client = task.get("client") or "AI"
ai_tasks._save(task)
items, picks = [], {}
for prompt_id in prompts:
record = history.get(prompt_id) or {}
prompt = (record.get("prompt") or [None, None, {}])[2] or {}
base = next((base for base in info["bases"] if _seed(prompt, base) is not None), info["bases"][0])
for show in info["shows"]:
for image in ((record.get("outputs") or {}).get(str(show)) or {}).get("images") or []:
if image.get("type") != "temp" or len(items) >= ai_panels.MAX_ITEMS:
continue
rel = "/".join(part for part in (image.get("subfolder"), image.get("filename")) if part)
seed, item_id = _seed(prompt, base), f"b{len(items) + 1}"
items.append({"id": item_id, "image": rel, "folder": "temp", "label": f"{len(items) + 1} · seed {seed}"})
picks[item_id] = {"image": rel, "seed": seed, "node": str(base["node"]), "type": base["type"],
"names": base.get("names") or [], "show": str(show) if show in info.get("saved", []) else None}
if not items:
return None
block = {"type": "picks", "id": "base", "question": QUESTION, "question_key": "aiBaseQuestion", "items": items}
return ai_panels.show(client, TITLE, [block], task_id=task_id, tool="pick_base", title_key="aiBaseTitle",
extra={"bases": picks, "prefix": info.get("prefix") or PREFIX})
def prepare(task_id, step_id, choice, skip=False):
"""The user's pick on a bases panel, made before it is recorded: the picture copied into the output
folder; {} for other panels."""
step = _step(ai_tasks.get_task(task_id), step_id)
bases = (step or {}).get("bases")
if not bases or skip:
return {}
picked = bases.get(str((choice if isinstance(choice, list) else [choice])[0] if choice else ""))
if not picked:
return {}
import folder_paths
source = os.path.join(folder_paths.get_temp_directory(), *picked["image"].split("/"))
if not os.path.isfile(source):
raise ValueError(f"这张预览已经没了:ComfyUI 重启时会清空临时文件夹。让 AI 用种子 {picked['seed']} 再画一次 / "
f"This preview is gone: ComfyUI empties its temp folder when it restarts. Ask the AI to draw it "
f"again with seed {picked['seed']}")
with Image.open(source) as picture:
width, height = picture.size
folder, name, counter, subfolder, _prefix = folder_paths.get_save_image_path(
step.get("prefix") or PREFIX, folder_paths.get_output_directory(), width, height)
target = f"{name}_{counter:05}_.png"
ai_outside.own(target)
shutil.copyfile(source, os.path.join(folder, target)) # its prompt and workflow come with it
kept = "/".join(part for part in (subfolder, target) if part)
picked = {**picked, "kept": kept, "kept_input": ai_pictures.to_input(kept)} # what LoadImage reads in the next run
return {"base": picked, "kept": kept, "seed": picked["seed"],
"note": f"Anomalous keeps this base as {kept} and holds seed {picked['seed']} on node {picked['node']} "
"for your next run_workflow: run the whole workflow now; do not change that seed."}
def discard(prepared):
"""The copy `prepare` made, when the pick was refused after all (answered already)."""
if (prepared or {}).get("base"):
import folder_paths
try:
os.remove(os.path.join(folder_paths.get_output_directory(), *prepared["kept"].split("/")))
except OSError:
pass
def hold(task_id, extra):
"""Holds the base the user just picked (prepare's answer) for the task's next runs."""
base = (extra or {}).get("base")
if not base:
return
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
if task:
task["base_hold"] = {key: base[key] for key in ("node", "type", "names", "seed", "kept", "kept_input", "show")}
ai_tasks._save(task)
def same(first, second):
"""The mean difference of two pictures per channel (0-255), or None when they differ in size."""
with Image.open(first) as a, Image.open(second) as b:
if a.size != b.size:
return None
return sum(ImageStat.Stat(ImageChops.difference(a.convert("RGB"), b.convert("RGB"))).mean) / 3
def check(task_id, step_id, prompts, history):
"""A run that held a picked base and saved its base again: is it the picked picture?"""
import folder_paths
with ai_tasks._lock:
step = _step(ai_tasks._load().get(task_id), step_id)
asked = dict((step or {}).get("base_check") or {})
if not asked or "same" in asked:
return
output = folder_paths.get_output_directory()
record = next((history.get(prompt_id) for prompt_id in prompts if history.get(prompt_id)), None) or {}
image = next((item for item in ((record.get("outputs") or {}).get(asked["show"]) or {}).get("images") or []
if item.get("type") == "output"), None)
if not image:
return
try:
diff = same(os.path.join(output, *asked["kept"].split("/")),
os.path.join(output, image.get("subfolder") or "", image["filename"]))
except OSError:
return
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
step = _step(task, step_id)
if step is None:
return
step["base_check"] = {**asked, "same": diff is not None and diff < SAME_BELOW, "diff": None if diff is None else round(diff, 2)}
ai_tasks._save(task)
ai_tasks._notify(task_id)
def words(run):
"""What the AI is told of a run's base: the bases offered, or whether the picked one held."""
if run.get("bases") and run.get("state") == "done":
return ("Anomalous showed the user these bases to pick from (a panel); get_answers waits for their pick, "
"then run the whole workflow: the picked base is kept and its seed held.")
check_ = run.get("base_check") or {}
if check_.get("same") is True:
return f"Its base is the one the user picked ({check_['kept']})."
if check_.get("same") is False:
return (f"Its base is NOT the one the user picked ({check_['kept']}): something before the second pass changed "
"(prompt, model, LoRA, size or a setting). Tell the user (report) before going on.")
return ""
+41
View File
@@ -0,0 +1,41 @@
"""Who is calling Anomalous's MCP endpoint (mcp_server.py), for the AI workbench's record
(ai_tasks.py): a short name for the AI app, the session it runs when it keeps several, and a task
it names. Each comes from the request's headers or metadata and is cleaned to a short plain word."""
import re
_CLIENT_NAMES = (
("claude-code", "Claude Code"), ("claude", "Claude"), ("cursor", "Cursor"), ("cherry", "Cherry Studio"),
("windsurf", "Windsurf"), ("codex", "Codex"), ("gemini", "Gemini"), ("vscode", "VS Code"), ("cline", "Cline"),
)
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 {}
info = meta.get("io.modelcontextprotocol/clientInfo")
raw = info.get("name") if isinstance(info, dict) and isinstance(info.get("name"), str) else ""
if not raw:
raw = (headers.get("User-Agent") or "").split(" ")[0].split("/")[0]
lowered = raw.lower()
for key, name in _CLIENT_NAMES:
if key in lowered:
return name
clean = re.sub(r"[^\w .-]", "", raw).strip()[:40]
return clean or "AI"
def session_of(headers, params):
"""An optional session key when one app runs multiple MCP sessions at once."""
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
raw = headers.get("Mcp-Session-Id") or headers.get("X-Anomalous-Session") or meta.get("sessionId") or meta.get("conversationId") or ""
return re.sub(r"[^\w .:-]", "", str(raw)).strip()[:64] if isinstance(raw, str) else ""
def task_of(headers, params, arguments=None):
"""An explicit task id from headers, request metadata or tool arguments."""
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
arg_task = arguments.get("task") if isinstance(arguments, dict) else None
raw = arg_task or headers.get("X-Anomalous-Task") or meta.get("anomalous/task") or meta.get("task") or ""
return re.sub(r"[^\w-]", "", str(raw)).strip()[:64] if isinstance(raw, str) else ""
+105
View File
@@ -0,0 +1,105 @@
"""A task's key decisions, kept in Anomalous to look back on ("决策"). Talk may happen anywhere,
in the AI's own chat too; what was settled is kept here: the AI records it (log_decision: what,
why, what was left out, who decided, the steps it is about), and what the user decided in
Anomalous itself is read from the task's own steps (a panel answer, a gate's allow or no, the
materials handed over, the canvas mode, a stop). decisions() lists both, oldest first; gap() says
how much the AI did since its last recorded decision, for a quiet reminder."""
import time
import uuid
from . import ai_tasks
BY = ("user", "ai", "together")
MAX_TEXT = 600
MAX_REJECTED = 6
MAX_RELATED = 8
QUESTIONS = ("choices", "input", "picks")
def log(client, title, decision, reason="", rejected=None, by="user", related=None, now=None, session="", task_id=None):
"""A decision the AI records. `related`: ids of this task's steps it is about. Returns the step."""
title, decision = str(title or "").strip(), str(decision or "").strip()
if not title or not decision:
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)}")
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),
"reason": ai_tasks._short(str(reason or "").strip(), MAX_TEXT), "rejected": rejected, "by": by, "related": []}
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "log_decision", "kind": "decision", "ok": True,
"state": "done", "summary": "log_decision", "text": record["decision"], "decision": record, "results": []}
with ai_tasks._lock:
task = ai_tasks.current_task(client, now, session=session, task_id=task_id)
known = {item["id"] for item in task["steps"]}
record["related"] = [str(item) for item in (related or []) if str(item) in known][:MAX_RELATED]
ai_tasks._add_step(task, step)
ai_tasks._save(task)
ai_tasks._notify(task["id"])
return step
def decisions(task):
"""Every decision of `task`, oldest first: {type: logged | answer | gate | toolbox | canvas | stop, step, at, …}."""
out = []
for step in (task or {}).get("steps") or []:
tool = step.get("tool")
if step.get("kind") == "decision":
out.append({"type": "logged", "step": step["id"], "at": step["at"], **step["decision"]})
elif step.get("kind") == "panel":
blocks = {block.get("id"): block for block in step["panel"].get("blocks") or []}
for block_id, given in (step.get("answers") or {}).items():
block = blocks.get(block_id) or {}
out.append({"type": "answer", "step": step["id"], "at": given.get("at", step["at"]),
"question": block.get("question") or step["panel"].get("title") or "",
"answer": "、".join(given.get("labels") or []) if given.get("labels") and given.get("choice") != ["other"] else given.get("text", ""),
"skipped": bool(given.get("skipped")), "timed_out": bool(given.get("recommended_on_timeout"))})
elif (step.get("gate") or {}).get("at"):
asked = step["gate"]
out.append({"type": "gate", "step": step["id"], "at": asked["at"], "tool": tool,
"allowed": asked.get("decision") == "allow", "text": asked.get("text", "")})
elif tool == "user_toolbox":
box = step.get("toolbox") or {}
count = sum(len(value) for value in box.values() if isinstance(value, list))
out.append({"type": "toolbox", "step": step["id"], "at": step["at"], "mode": box.get("mode", "prefer"), "count": count})
elif tool == "user_canvas":
out.append({"type": "canvas", "step": step["id"], "at": step["at"], "mode": step.get("canvas")})
elif tool == "user_privacy":
out.append({"type": "privacy", "step": step["id"], "at": step["at"], "on": bool(step.get("privacy"))})
elif tool == "user_stop":
out.append({"type": "stop", "step": step["id"], "at": step["at"]})
return sorted(out, key=lambda item: item["at"])
def gap(task):
"""Runs and canvas changes of the AI since its last recorded decision ({runs, canvas}), or None when there are none."""
runs = canvas = 0
for step in reversed((task or {}).get("steps") or []):
if step.get("kind") == "decision":
break
runs += step.get("kind") == "run"
canvas += step.get("kind") == "canvas"
return {"runs": runs, "canvas": canvas} if runs or canvas else None
def lines(task, limit=12):
"""The latest decisions in a line each, for an AI picking the task up again."""
words = []
for item in decisions(task)[-limit:]:
if item["type"] == "logged":
words.append(f"{item['title']}: {item['decision']}" + (f" (because {item['reason']})" if item["reason"] else ""))
elif item["type"] == "answer":
words.append(f"Nobody answered \"{item['question']}\" in time; your recommended option was taken: {item['answer']}" if item["timed_out"] else
f"The user answered \"{item['question']}\": " + ("left it to you" if item["skipped"] else item["answer"]))
elif item["type"] == "gate":
words.append(f"The user {'allowed' if item['allowed'] else 'refused'} {item['tool']}" + (f": {item['text']}" if item["text"] else ""))
elif item["type"] == "toolbox":
words.append(f"The user handed over {item['count']} materials ({item['mode']})")
elif item["type"] == "canvas":
words.append("The user set the canvas mode to " + ("the task's own tab" if item["mode"] == "new" else "their open canvas"))
elif item["type"] == "privacy":
words.append("The user turned privacy mode " + ("on: no pictures reach you" if item["on"] else "off"))
else:
words.append("The user stopped a run")
return words
+281
View File
@@ -0,0 +1,281 @@
"""Gates: what the user lets AI apps do through Anomalous's MCP endpoint without asking first.
Each acting tool has a gate in action_catalog.py (canvas, gpu, download, network, files) and each gate
a policy the user sets on the AI workbench: "auto" (go ahead), "ask" (wait for the user's OK)
or "never". mcp_server.py asks check() before running a tool. With "ask" the tool's step waits
in state "asking", showing what it would do, for up to WAIT_SECONDS: the user's Allow lets the
call go on, No answers the AI with the user's reason. When the user has not answered by then,
the AI is told to wait (get_answers) and call the same tool again; the user's later answer
reaches it as a message (ai_panels._collect), and an allowed call made again with the same
arguments goes ahead without asking twice. Policies are kept in the ComfyUI user folder
(anomalous/ai_gates.json); changing one is a write (local_guard.py keeps it to this computer).
The same window sets (anomalous/ai_tools.json) how an AI waits for a run (run_wait): "call", it ends its turn and the user tells it when the run is done (the AI
workbench gives them a line to paste), or "wait", it waits with run_status; how long in all an
AI waits for the user's answer to its question (answer_wait, ai_answer_wait.py), and what happens
when that time runs out (no_answer): "stop", the AI ends its turn and waits for the user, or
"recommended", a choice the AI marked a recommended option for takes it (never a gate's question);
which custom nodes the AI may add itself (node_rule): any, or only those of the ready-made ways;
and a new route (route_rule): an AI placing nodes by itself rather than through a way, card or route
builds something nobody tried. "allow" goes on with one reminder in the task that a stronger model
suits that better; "ask" waits for the user's OK, once a task.
"""
import json
import os
import threading
import time
import uuid
from . import action_catalog, ai_tasks
from .path_utils import atomic_write_json
POLICIES = ("auto", "ask", "never")
RUN_WAITS = ("call", "wait")
NODE_RULES = ("any", "ways")
ROUTE_RULES = ("allow", "ask")
NEW_ROUTE_NOTE = ("AI 在自己搭一条新路线:现成的卡、做法里没有,还没验证过。跑完看看效果,好的话存成卡,以后日常用;也可以让它用最接近的现成路线。 / "
"The AI is building a new route of its own: no card or way has it, so it is untried. See how the run turns out; "
"if you like it, save it as a card for daily use. Or have it use the nearest route there is.")
ANSWER_WAITS = (60, 180, 600) # seconds; the first call waits 30-55 s, get_answers the rest
NO_ANSWERS = ("stop", "recommended")
DEFAULTS = {"canvas": "auto", "gpu": "auto", "download": "ask", "network": "auto", "files": "ask"}
WAIT_SECONDS = 50 # below the minute many AI apps give a tool call
ALLOWED_SECONDS = 30 * 60 # an Allow the AI did not wait for holds this long for the same call
MAX_ARGS = 8
_decided = threading.Condition(ai_tasks._lock)
def _path():
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_gates.json")
def policies():
try:
with open(_path(), encoding="utf-8") as source:
stored = json.load(source)
except (OSError, ValueError):
stored = {}
stored = stored if isinstance(stored, dict) else {}
return {gate: stored.get(gate) if stored.get(gate) in POLICIES else default for gate, default in DEFAULTS.items()}
def set_policy(gate, policy):
if gate not in DEFAULTS or policy not in POLICIES:
raise ValueError("Unknown gate or policy")
with _decided:
current = policies()
current[gate] = policy
atomic_write_json(_path(), current)
return current
def _tools_path():
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_tools.json")
def _tool_settings():
try:
with open(_tools_path(), encoding="utf-8") as source:
stored = json.load(source)
except (OSError, ValueError):
stored = {}
return stored if isinstance(stored, dict) else {}
def _set_tool_setting(key, value):
with _decided:
atomic_write_json(_tools_path(), {**_tool_settings(), key: value})
return value
def run_wait():
"""How an AI waits for its run: "call" (the user tells it when it is done) or "wait" (run_status)."""
chosen = _tool_settings().get("runs")
return chosen if chosen in RUN_WAITS else "call"
def set_run_wait(mode):
if mode not in RUN_WAITS:
raise ValueError("runs is call or wait")
return _set_tool_setting("runs", mode)
def node_rule():
"""Which custom nodes an AI adds itself: "any", or "ways": only those the ready-made ways build
(reference_blocks.node_types) and those the user picked for the task (mcp_canvas_edit._allowed)."""
chosen = _tool_settings().get("nodes")
return chosen if chosen in NODE_RULES else "any"
def set_node_rule(mode):
if mode not in NODE_RULES:
raise ValueError("nodes is any or ways")
return _set_tool_setting("nodes", mode)
def route_rule():
"""An AI placing nodes by itself: "allow" (with a reminder) or "ask" (the user's OK, once a task)."""
chosen = _tool_settings().get("routes")
return chosen if chosen in ROUTE_RULES else "allow"
def set_route_rule(mode):
if mode not in ROUTE_RULES:
raise ValueError("routes is allow or ask")
return _set_tool_setting("routes", mode)
def _new_route(tool, arguments):
"""Whether this call places nodes the AI chose itself: not a way, a card or a route."""
return tool == "add_node" or (tool in ("add_nodes", "new_workflow") and bool((arguments or {}).get("nodes")))
def _route_gate(tool, arguments, step_ref, now):
"""True when this call must wait for the user's OK on a new route; else the task's one reminder is left there."""
if not _new_route(tool, arguments):
return False
with _decided:
task, _step_now = _step(step_ref)
steps = (task or {}).get("steps") or []
if route_rule() == "ask":
return not any((step.get("gate") or {}).get("gate") == "route" and (step.get("gate") or {}).get("decision") == "allow" for step in steps)
if task and not any(step.get("tool") == "new_route" for step in steps):
ai_tasks._add_step(task, {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "new_route", "kind": "note", "ok": True,
"state": "done", "summary": "new_route", "text": NEW_ROUTE_NOTE, "results": []})
ai_tasks._save(task)
return False
def answer_wait():
"""How long in all an AI waits for the user's answer, in seconds (one of ANSWER_WAITS)."""
chosen = _tool_settings().get("answer_wait")
return chosen if chosen in ANSWER_WAITS else 180
def set_answer_wait(seconds):
if seconds not in ANSWER_WAITS or isinstance(seconds, bool):
raise ValueError("answer_wait is 60, 180 or 600")
return _set_tool_setting("answer_wait", seconds)
def no_answer():
"""When the user's time runs out unanswered: "stop" (the AI waits for them) or "recommended"."""
chosen = _tool_settings().get("no_answer")
return chosen if chosen in NO_ANSWERS else "stop"
def set_no_answer(mode):
if mode not in NO_ANSWERS:
raise ValueError("no_answer is stop or recommended")
return _set_tool_setting("no_answer", mode)
def describe(tool, arguments):
"""What a call would do, as [{label, value}] for the card the user answers."""
out = []
for key, value in list((arguments or {}).items())[:MAX_ARGS]:
if key == "task":
continue
if key == "model_id": # its name, and the folder it is in
value = " · ".join(filter(None, (ai_tasks._model_result(value).get("name") or value, str(value).split(":")[0])))
out.append({"label": key, "value": ai_tasks._short(value, 400)})
return out
def _signature(tool, arguments):
return json.dumps([tool, {k: v for k, v in (arguments or {}).items() if k != "task"}], sort_keys=True, ensure_ascii=False)
def _blocked(text):
return {"content": [{"type": "text", "text": text}], "isError": True}
def _step(step_ref):
task = ai_tasks._load().get(step_ref[0]) if step_ref else None
step = next((item for item in (task or {}).get("steps", []) if item.get("id") == step_ref[1]), None) if task else None
return task, step
def check(tool, arguments, step_ref, now=None):
"""None when the call may go ahead; else the result to give the AI instead of running it."""
gate = action_catalog.entry(tool).gate
if gate and set(arguments or {}) & set(action_catalog.ALSO_CANVAS.get(tool, ())):
strict = {"auto": 0, "ask": 1, "never": 2}
gate = max((gate, "canvas"), key=lambda name: strict.get(policies().get(name, "auto"), 0))
policy = policies().get(gate, "auto") if gate else "auto"
now = time.time() if now is None else now
if policy == "auto" and _route_gate(tool, arguments, step_ref, now):
policy, gate = "ask", "route"
if policy == "auto":
return None
if policy == "never":
return _blocked(f"The user does not let AI apps do this ({gate}) through Anomalous; tell them what you "
"wanted to do instead, or ask them to change it in the AI workbench (Gates).")
signature = _signature(tool, arguments)
with _decided:
task, step = _step(step_ref)
if not step:
return _blocked("Approval could not be recorded; nothing was done. Retry after reopening the AI workbench.")
# An Allow given after the AI stopped waiting: the same call again goes ahead.
for earlier in task["steps"]:
asked = earlier.get("gate") or {}
if (asked.get("timed_out") and asked.get("signature") == signature
and asked.get("decision") == "allow" and not asked.get("used")
and now - asked.get("at", 0) < ALLOWED_SECONDS):
asked["used"] = True
earlier["state"] = "done"
step["gate"] = {"gate": gate, "signature": signature, "decision": "allow", "used": True, "earlier": earlier["id"]}
ai_tasks._save(task)
return None
step["state"] = "asking"
step["gate"] = {"gate": gate, "signature": signature, "lines": describe(tool, arguments), "asked": now}
ai_tasks._save(task)
task_id = task["id"]
ai_tasks._notify(task_id, {"gate": gate, "asks": True})
with _decided:
_decided.wait_for(lambda: "decision" in step["gate"], WAIT_SECONDS)
asked = step["gate"]
if asked.get("decision") == "allow":
asked["used"] = True
step["state"] = "running"
ai_tasks._save(task)
return None
if asked.get("decision") == "deny":
return _blocked("你没有允许 / The user said no to this in Anomalous." + (f" They said: {asked['text']}" if asked.get("text") else ""))
asked["timed_out"] = True
ai_tasks._save(task)
return {"content": [{"type": "text", "text": "Waiting for the user's OK in Anomalous's AI workbench: nothing was done yet. "
"Call get_answers (wait_seconds up to 55) to hear their answer; once they allow "
"it, call this tool again with the same arguments."}],
"structuredContent": {"waiting_for_user": True, "gate": gate}, "isError": False}
def decide(task_id, step_id, decision, text="", remember=False, now=None):
"""The user's answer to a step that asks: "allow" or "deny" (with words for the AI)."""
if decision not in ("allow", "deny"):
raise ValueError("decision is allow or deny")
with _decided:
task, step = _step((task_id, step_id))
asked = (step or {}).get("gate")
if not asked or step.get("state") != "asking":
raise ValueError("Nothing waits for an answer there")
asked.update(decision=decision, at=time.time() if now is None else now)
if text:
asked["text"] = ai_tasks._short(str(text).strip(), 1000)
if asked.get("timed_out"):
# The AI stopped waiting: it hears of the answer as a message, and an Allow waits for its call.
asked["delivered"] = False
step["state"] = "allowed" if decision == "allow" else "error"
else:
step["state"] = "running" if decision == "allow" else "error"
if decision == "deny":
step.update(ok=False, error="你没有允许 / You said no")
ai_tasks._save(task)
_decided.notify_all()
if remember and decision == "allow":
set_route_rule("allow") if asked["gate"] == "route" else set_policy(asked["gate"], "auto")
ai_tasks._notify(task_id)
from . import ai_panels
ai_panels.wake()
return step
+210
View File
@@ -0,0 +1,210 @@
"""Marks the user draws on a picture to tell the AI where to change it (the AI workbench's marker,
web/modules/ui_ai_marker.js): boxes, circles, arrows and free lines, each with a note. The page
sends the picture with the marks drawn on it (a PNG) and the marks themselves; this keeps the
PNG in the ComfyUI user folder (anomalous/ai_marks/<task>/<step>.png, never the output folder, so
the gallery stays the user's pictures) and adds a "user_marks" step to the task. The AI gets it
like a message (ai_panels._collect, kind "marks": the notes with where each mark is, in
fractions of the picture) and, attached to the same answer, the marked picture as a thumbnail
(image_items), so an AI that can see pictures sees the marks. Only an output picture can be
marked; the PNG is checked (signature, size, dimensions) before it is kept.
The user also says how to change the picture (MODES), and the AI reads that choice first, as the
user's: leave it to the AI, change only the marked areas, or make the whole picture anew. For
"only the marked areas" the page sends a mask (the marker's "repaint" brush, and the boxes,
circles and lines; white where to repaint), and the picture is also written to ComfyUI's input
folder as anomalous_inpaint_<step>.png with that area as its alpha, so LoadImage gives the
picture and the area as its MASK for an inpaint workflow; the AI gets the file name with the marks.
"""
import base64
import io
import os
import re
import time
import uuid
import folder_paths
from . import ai_tasks
from .mcp_tools import _image_content
MAX_PNG_BYTES = 8 * 1024 * 1024
MAX_EDGE = 4096
MAX_MARKS = 20
MAX_NOTE = 300
SHAPES = ("box", "circle", "arrow", "pen")
MODES = {
"auto": "How to change it is up to you: say in report which way you took (repaint only the marked areas, "
"or make the picture anew) and why. To repaint only the marked areas, propose_repaint_area(marks=this "
"id) agrees on the area with the user and makes the picture to repaint.",
"local": "The user chose: change ONLY the marked areas and keep everything else exactly as it is. Repaint "
"them from inpaint_image (inpaint_note says how); do not make the whole picture anew. If the area "
"looks off, propose_repaint_area(marks=this id) agrees on a better one first.",
"whole": "The user chose: make the whole picture anew with these changes (its own model and prompt, "
"changed as the notes say; a new seed or img2img). Do not repaint only a part of it.",
}
FILE_NAME = re.compile(r"[0-9a-f]{10}\.png")
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
class MarksError(ValueError):
pass
def path_of(task_id, name):
"""The kept PNG `name` of task `task_id`, or None (only names this module makes)."""
if not FILE_NAME.fullmatch(str(name)):
return None
path = os.path.join(ai_tasks.marks_dir(task_id), name)
return path if os.path.isfile(path) else None
def _png(data_url):
text = str(data_url or "")
if not text.startswith("data:image/png;base64,"):
raise MarksError("Expected the marked picture as a PNG data URL")
try:
data = base64.b64decode(text.split(",", 1)[1], validate=True)
except (ValueError, IndexError):
raise MarksError("The marked picture is not valid base64")
if len(data) > MAX_PNG_BYTES or not data.startswith(PNG_SIGNATURE):
raise MarksError("The marked picture is not a PNG of a usable size")
from PIL import Image
try:
with Image.open(io.BytesIO(data)) as image:
if image.format != "PNG" or max(image.size) > MAX_EDGE:
raise MarksError("The marked picture is too large")
image.verify()
except MarksError:
raise
except Exception: # noqa: BLE001 - whatever PIL says, it is not a picture to keep
raise MarksError("The marked picture cannot be read")
return data
def _fraction(value):
try:
return min(1.0, max(0.0, round(float(value), 4)))
except (TypeError, ValueError):
raise MarksError("Mark positions are fractions of the picture")
def _clean(marks, masked=False):
if not isinstance(marks, list) or (not marks and not masked) or len(marks) > MAX_MARKS:
raise MarksError(f"Between 1 and {MAX_MARKS} marks")
out = []
for mark in marks:
if not isinstance(mark, dict) or mark.get("shape") not in SHAPES:
raise MarksError(f"Each mark is one of {', '.join(SHAPES)}")
x, y, w, h = (_fraction(mark.get(key, 0)) for key in ("x", "y", "w", "h"))
out.append({"shape": mark["shape"], "x": x, "y": y, "w": min(w, 1 - x), "h": min(h, 1 - y),
"note": ai_tasks._short(str(mark.get("note") or "").strip(), MAX_NOTE)})
return out
def _words(marks, text, mode="auto"):
"""The message the AI reads: how to change the picture, the user's words, then each mark with where it is."""
lines = [MODES[mode]] + ([text] if text else [])
for number, mark in enumerate(marks, 1):
where = (f"x {round(mark['x'] * 100)}–{round((mark['x'] + mark['w']) * 100)}%, "
f"y {round(mark['y'] * 100)}–{round((mark['y'] + mark['h']) * 100)}% of the picture")
lines.append(f"Mark {number} ({mark['shape']}, {where}): {mark['note'] or '(no note)'}")
return "\n".join(lines)
def picture_path(picture):
"""Where an output picture {subfolder, filename} (as _output_media gives it) is on disk."""
return os.path.join(folder_paths.get_output_directory(), picture["subfolder"], picture["filename"])
def write_inpaint(picture, area_of, name_id):
"""The picture with its repaint area as alpha in ComfyUI's input folder (a new file, never one
that is there), so LoadImage gives the area as MASK: its name. `area_of(size)` makes the area
(an "L" image of that size, white where to repaint, soft edges kept)."""
from PIL import Image, ImageOps
try:
with Image.open(picture_path(picture)) as original:
area = area_of(original.size)
if not area.getbbox():
raise MarksError("The repaint area is empty")
out = original.convert("RGB")
out.putalpha(ImageOps.invert(area)) # ComfyUI's LoadImage: MASK = 1 - alpha
except MarksError:
raise
except Exception: # noqa: BLE001 - a picture PIL cannot read cannot be repainted either
raise MarksError("The picture cannot be read for repainting")
name = f"anomalous_inpaint_{name_id}.png"
with open(os.path.join(folder_paths.get_input_directory(), name), "xb") as target: # never replaces a file
out.save(target, "PNG")
return name
def _inpaint_file(picture, mask, step_id):
"""write_inpaint with the area the user drew (`mask`, PNG bytes, white where to repaint)."""
from PIL import Image
def drawn_area(size):
with Image.open(io.BytesIO(mask)) as drawn:
return drawn.convert("L").resize(size, Image.NEAREST).point(lambda value: 255 if value > 127 else 0)
return write_inpaint(picture, drawn_area, step_id)
def save(task_id, image, png, marks, text="", now=None, mask=None, mode=None):
"""Keeps the marked picture and adds the user's marks to the task, with how to change it (a
key of MODES; without one, "local" when there is a mask, as the page sent before there was a
choice). For "local", `mask` (a PNG data URL) also makes the picture ready to repaint
(_inpaint_file); the other ways keep no mask. Returns the step."""
mode = mode or ("local" if mask else "auto")
if mode not in MODES:
raise MarksError(f"How to change it is one of {', '.join(MODES)}")
if mode != "local":
mask = None
elif not mask:
raise MarksError("Changing only the marked areas needs a box, circle, line or painted area")
try:
picture = ai_tasks._output_media(image)
except (ValueError, OSError):
picture = None
if not picture:
raise MarksError("Only a picture in the output folder can be marked")
data = _png(png)
mask = _png(mask) if mask else None
marks = _clean(marks, masked=mask is not None)
text = ai_tasks._short(str(text or "").strip(), 2000)
step_id = uuid.uuid4().hex[:10]
now = time.time() if now is None else now
if not ai_tasks.get_task(task_id):
raise MarksError("No such task")
inpaint = _inpaint_file(picture, mask, step_id) if mask else None # outside the lock: a picture takes a moment
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
if not task:
raise MarksError("No such task")
os.makedirs(ai_tasks.marks_dir(task_id), exist_ok=True)
with open(os.path.join(ai_tasks.marks_dir(task_id), f"{step_id}.png"), "xb") as target: # a new name: never replaces
target.write(data)
step = {"id": step_id, "at": now, "ms": 0, "tool": "user_marks", "kind": "user", "ok": True, "state": "done",
"summary": "user_marks", "text": _words(marks, text, mode), "note": text, "image": picture, "marks": marks, "mode": mode,
"marked": f"{step_id}.png", "delivered": False, "results": [], **({"inpaint": inpaint} if inpaint else {})}
task["steps"].append(step)
del task["steps"][:-ai_tasks.MAX_STEPS]
task["updated"] = max(task.get("updated", 0), now)
task["ended"] = None # like a message (ai_panels.leave_message): the task goes on
ai_tasks._save(task)
ai_tasks._notify(task_id)
return step
def image_items(messages):
"""MCP image items of the marked pictures among `messages` and of the pictures the user handed
over (ai_pictures.py), their thumbnails, taking their private "_marked" / "_pictures" paths off
the messages."""
items = []
for message in messages:
for path in [message.pop("_marked", None), *message.pop("_pictures", [])]:
item = _image_content(path) if path else None
if item:
items.append(item)
return items
+88
View File
@@ -0,0 +1,88 @@
"""What the user points at in a message to the AI ("@" in the AI workbench's message box,
web/modules/ui_ai_mention.js): models, combos, workflows, prompts, node types, custom node packs
and output pictures, by the names the AI's tools use. Checked when the message is left (clean)
and given to the AI with it (message_of): each thing as get_toolbox gives a pick (lookup), the
pictures attached with what made them, as ai_pictures.py hands them over, so the AI needs no
guessing which "that LoRA" is."""
from . import ai_pictures
from .mcp_tools import ToolError, _resolve_image
KINDS = ("models", "combos", "workflows", "prompts", "nodes", "packs", "pictures")
MAX_REFS = 12
MAX_NAME = 300
class RefsError(ValueError):
pass
def clean(refs):
"""[{kind, id, label}] from the page: known kinds, text names, each thing once, at most MAX_REFS."""
if refs in (None, []):
return []
if not isinstance(refs, list) or len(refs) > MAX_REFS:
raise RefsError(f"refs: a list of up to {MAX_REFS} things")
out = []
for ref in refs:
if not isinstance(ref, dict) or ref.get("kind") not in KINDS:
raise RefsError(f"refs: each is {{kind, id}} with kind one of {', '.join(KINDS)}")
name = ref.get("id")
if not isinstance(name, str) or not name.strip() or len(name) > MAX_NAME:
raise RefsError(f"refs: an id is text of up to {MAX_NAME} characters")
label = str(ref.get("label") or name).strip()[:MAX_NAME]
item = {"kind": ref["kind"], "id": name.strip(), "label": label}
if not any(have["kind"] == item["kind"] and have["id"] == item["id"] for have in out):
out.append(item)
return out
def lookup(kind, name):
"""A combo, workflow, prompt or model as the AI's tools give it, or None when it is gone."""
from . import mcp_tools
if kind in ("combos", "workflows"):
lister = mcp_tools.list_combos if kind == "combos" else mcp_tools.list_workflows
return next(iter(lister(name, 1, exact_name=True)[kind]), None)
if kind == "prompts":
from .ai_toolbox_catalog import everything
return next((entry for entry in everything()["prompts"] if entry["id"] == name), None)
if kind == "models":
try:
found = mcp_tools.get_model(name)
except ToolError:
return None
return found[0] if isinstance(found, tuple) else found # without its cover picture
return None
def message_of(step):
"""The refs of a message step for the AI ("refs", each in full or marked missing) and the
pictures' files in "_pictures" for ai_marks.image_items to attach."""
items, files = [], []
for ref in step.get("refs") or []:
item = {"kind": ref["kind"], "id": ref["id"], "said_as": f"@{ref['label']}"}
if ref["kind"] == "pictures":
try:
rel, path = _resolve_image(ref["id"])[:2]
except ToolError:
item["missing"] = True
else:
item.update(image=rel, made_with=ai_pictures.brief(path))
files.append(path)
elif ref["kind"] == "nodes":
item["note"] = "a node type: find_nodes(types=[id]) gives it in full, add_node adds it"
elif ref["kind"] == "packs":
item["note"] = "a custom node pack (folder): its node types are those find_nodes lists with this source"
else:
found = lookup(ref["kind"], ref["id"])
if found is None:
item["missing"] = True
else:
item["details"] = found
items.append(item)
out = {"refs": items, "refs_note": "What the user's @ names in their text point at, and the pictures they attached, exactly."}
if files:
out["_pictures"] = files
return out
+203
View File
@@ -0,0 +1,203 @@
"""What happened outside Anomalous while an AI works on a task, as steps of kind "outside" in the
AI workbench. Anomalous sees only the calls made to it: an AI app may also run commands and
scripts of its own, and one that edits pictures that way loses their workflow and the user's
earlier result. Two ways to see it:
check() looks, when the AI calls Anomalous (never in the background), at what changed since its
previous call: pictures in ComfyUI's output folder no ComfyUI run saved, and pictures in the
output or input folder rewritten in place (changed a while after they were made). Only the top of
each folder, by the files' own times (made, changed), at most MAX_ENTRIES entries. While ComfyUI
runs a prompt, new pictures wait until it ends (its history names them then). What it finds is
a step and goes to the AI with its next answer (tell), which says it may well be the user's own
doing. Without a creation time (Linux), output pictures are "new or changed" and the input
folder is not looked at (an upload looks the same).
log() is the AI's own word: what it ran or changed outside Anomalous, why, and which files.
"""
import os
import threading
import time
import uuid
from collections import OrderedDict, deque
import folder_paths
from . import ai_tasks
from .mcp_tools import tool_text
PICTURES = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff", ".mp4", ".webm", ".mov"}
MAX_ENTRIES = 20000 # of a folder's top looked at in one check
MAX_FOUND = 12 # files named in one step
REWRITE_SECONDS = 60 # changed this long after it was made: rewritten, not just written
HISTORY_ITEMS = 300 # ComfyUI's latest runs whose saved pictures are no news
MAX_WATCHED = 16 # tasks whose last check is kept
MAX_TEXT, MAX_FILES, MAX_FILE = 300, 20, 200
_lock = threading.Lock()
_watched = OrderedDict() # task id -> {"since": time of the last full check, "told": {(folder, name, changed)}}
_own = deque(maxlen=200) # pictures Anomalous itself put at the top of the output folder
def own(name):
"""Picture `name` was put there by Anomalous (a base the user picked, ai_bases.py): no news."""
_own.append(name)
def _queue():
from server import PromptServer
return PromptServer.instance.prompt_queue
def _busy():
"""Whether ComfyUI runs a prompt now (its pictures are in no history yet)."""
try:
running, _pending = _queue().get_current_queue_volatile()
except Exception: # noqa: BLE001 - no server (tests) or an older queue: nothing runs that we know of
return False
return bool(running)
def _saved_by_runs():
"""Names of the pictures ComfyUI's latest runs saved at the top of the output folder."""
try:
history = _queue().get_history(max_items=HISTORY_ITEMS, map_function=lambda record: record.get("outputs") or {})
except Exception: # noqa: BLE001 - no server (tests)
return set()
names = set()
for outputs in history.values():
for output in outputs.values() if isinstance(outputs, dict) else ():
for items in output.values() if isinstance(output, dict) else ():
for item in items if isinstance(items, list) else ():
if isinstance(item, dict) and item.get("type") == "output" and not item.get("subfolder") and item.get("filename"):
names.add(item["filename"])
return names
def _times(entry):
"""(made, changed) of a folder entry; made is None where the system keeps no creation time."""
stat = entry.stat()
return getattr(stat, "st_birthtime", None), stat.st_mtime
def _changes(folder, root, since):
"""[(name, change, when)] of the pictures at the top of `root` changed after `since`:
"rewritten", "new" or (no creation time) "changed"."""
found = []
try:
with os.scandir(root) as entries:
for at, entry in enumerate(entries):
if at >= MAX_ENTRIES:
break
if os.path.splitext(entry.name)[1].lower() not in PICTURES or not entry.is_file(follow_symlinks=False):
continue
made, changed = _times(entry)
if changed <= since and (made is None or made <= since):
continue
if made is None:
found.append((entry.name, "changed", changed))
elif changed - made > REWRITE_SECONDS:
found.append((entry.name, "rewritten", changed))
elif made > since:
found.append((entry.name, "new", changed))
except OSError:
return []
return [item for item in found if folder == "output" or item[1] == "rewritten"]
def check(task_id, now=None):
"""What changed outside Anomalous since the last check of `task_id`, as a step of the task
(also returned), or None. The first check of a task only starts the watch."""
if not task_id:
return None
now = time.time() if now is None else now
with _lock:
watch = _watched.get(task_id)
if watch is None:
_watched[task_id] = {"since": now, "told": set()}
while len(_watched) > MAX_WATCHED:
_watched.popitem(last=False)
return None
_watched.move_to_end(task_id)
busy = _busy()
saved = set() if busy else _saved_by_runs()
files = []
for folder, root in (("output", folder_paths.get_output_directory()), ("input", folder_paths.get_input_directory())):
for name, change, when in _changes(folder, root, watch["since"]):
if busy and change != "rewritten":
continue # maybe the running prompt's: judged once it ends
if change != "rewritten" and (name in saved or name in _own):
continue
if (folder, name, when) not in watch["told"]:
watch["told"].add((folder, name, when))
files.append({"folder": folder, "name": name, "change": change})
if not busy:
# A file's change time can be a second or two after the clock read (Windows): kept as told
# until it is older than the next check's start.
watch.update(since=now, told={key for key in watch["told"] if key[2] > now})
if not files:
return None
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "outside_files", "kind": "outside", "ok": True,
"state": "done", "summary": "outside_files", "files": files[:MAX_FOUND], "more": max(0, len(files) - MAX_FOUND),
"results": []}
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
if not task:
return None
ai_tasks._add_step(task, step)
ai_tasks._save(task)
ai_tasks._notify(task_id)
return step
def tell(result, step):
"""Puts what check() found into the AI's tool result."""
if not step or not isinstance(result, dict):
return result
files = [f"{item['folder']}/{item['name']} ({item['change']})" for item in step["files"]]
note = ("Anomalous saw pictures change outside its tools since your previous call; it shows them to the user. "
"It may be the user's own doing. If it was yours: never edit, crop, paint or overwrite pictures with code "
"of your own (that loses their workflow and the user's earlier result); change a picture by running a "
"ComfyUI workflow, look at one with zoom_image, and say what you ran outside Anomalous with log_outside.")
if result.get("isError"):
result["content"] = list(result.get("content") or []) + [{"type": "text", "text": f"{note} Files: {', '.join(files)}"}]
return result
data = result.get("structuredContent")
data = dict(data) if isinstance(data, dict) else {}
data["outside_changes"], data["outside_note"] = files, note
result["structuredContent"] = data
content = list(result.get("content") or [])
text = {"type": "text", "text": tool_text(data)}
if content and isinstance(content[0], dict) and content[0].get("type") == "text":
content[0] = text
else:
content.insert(0, text)
result["content"] = content
return result
def _line(value, limit, what):
value = str(value or "").strip()
if len(value) > limit:
raise ValueError(f"{what} is longer than {limit} characters.")
return value
def log(client, what, why="", files=None, now=None, session="", task_id=None):
"""The AI's word on what it did outside Anomalous, as a step of its task. Returns the step."""
what = _line(what, MAX_TEXT, "what")
if not what:
raise ValueError("what is empty: the command, script or change you made outside Anomalous.")
if files is not None and not isinstance(files, list):
raise ValueError("files is a list of the files you read, wrote or changed.")
named = [_line(item, MAX_FILE, "a file") for item in (files or [])[:MAX_FILES] if str(item or "").strip()]
now = time.time() if now is None else now
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "log_outside", "kind": "outside", "ok": True,
"state": "done", "summary": f"log_outside: {what[:80]}", "what": what, "why": _line(why, MAX_TEXT, "why"),
"files": named, "results": []}
with ai_tasks._lock:
task = ai_tasks.current_task(client, now, session=session, task_id=task_id)
ai_tasks._add_step(task, step)
ai_tasks._save(task)
ai_tasks._notify(task["id"])
return step
+133
View File
@@ -0,0 +1,133 @@
"""The parts of the blocks the AI sends to a panel (ai_panels.py), each checked to one plain shape:
text and lists of bounded size, fractions of a picture, marks (circles or boxes with a label),
the part of the pictures shown enlarged (zoom), pictures only from ComfyUI's output folder,
diagram boxes and arrows, and model cards only from the user's folders."""
import os
from . import ai_tasks
MAX_ITEMS = 12
COLORS = ("red", "green", "blue", "yellow", "purple")
MAX_MARKS = 8
MAX_NODES = 24
MAX_EDGES = 40
class PanelError(ValueError):
"""What is wrong with a panel, for the AI to fix."""
def _text(value, limit, where, required=False):
if value is None and not required:
return ""
if not isinstance(value, (str, int, float)) or isinstance(value, bool):
raise PanelError(f"{where} must be text")
text = str(value).strip()
if required and not text:
raise PanelError(f"{where} is empty")
return text[:limit]
def _list(value, limit, where):
if value is None:
return []
if not isinstance(value, list):
raise PanelError(f"{where} must be a list")
if len(value) > limit:
raise PanelError(f"{where} has more than {limit} items")
return value
def _fraction(value, where):
if not isinstance(value, (int, float)) or isinstance(value, bool) or not 0 <= value <= 1:
raise PanelError(f"{where} must be a number from 0 to 1 (a fraction of the picture)")
return round(float(value), 4)
def _marks(value, where):
marks = []
for i, mark in enumerate(_list(value, MAX_MARKS, where)):
here = f"{where}[{i}]"
if not isinstance(mark, dict):
raise PanelError(f"{here} must be {{x, y, w, h | r, label, color}}")
out = {"x": _fraction(mark.get("x"), f"{here}.x"), "y": _fraction(mark.get("y"), f"{here}.y"),
"label": _text(mark.get("label"), 60, f"{here}.label"),
"color": mark.get("color") if mark.get("color") in COLORS else "red"}
if mark.get("w") is not None or mark.get("h") is not None:
out.update(w=_fraction(mark.get("w"), f"{here}.w"), h=_fraction(mark.get("h"), f"{here}.h"))
else:
out["r"] = _fraction(mark.get("r", 0.06), f"{here}.r")
marks.append(out)
return marks
def _image(item, where):
image = item.get("image") if isinstance(item, dict) else item
temp = isinstance(item, dict) and item.get("folder") == "temp" # a run's preview (run_status previews)
found = ai_tasks._output_media(str(image or ""), "temp" if temp else "output")
if not found:
raise PanelError(f"{where}: {image!r} is not a picture in ComfyUI's {'temp' if temp else 'output'} folder "
"(subfolder/name.png, as search_images or run_status gives it)")
out = {**found, "caption": _text(item.get("caption") if isinstance(item, dict) else "", 120, f"{where}.caption")}
if isinstance(item, dict) and item.get("marks"):
out["marks"] = _marks(item["marks"], f"{where}.marks")
return out
def _zoom(value, where):
"""The part of every picture of a slider or picks block shown enlarged: {x, y, w, h} as fractions,
x/y its centre (as a mark's box), kept inside the picture: {left, top, w, h}."""
try:
x, y, w, h = (float(value[key]) for key in ("x", "y", "w", "h"))
except (KeyError, TypeError, ValueError):
raise PanelError(f"{where} must be {{x, y, w, h}}: fractions 0-1 of the picture, x/y the centre")
if not (0 <= x <= 1 and 0 <= y <= 1 and 0.02 <= w <= 1 and 0.02 <= h <= 1):
raise PanelError(f"{where}: x and y 0-1, w and h 0.02-1")
return {"left": round(min(max(x - w / 2, 0), 1 - w), 4), "top": round(min(max(y - h / 2, 0), 1 - h), 4),
"w": round(w, 4), "h": round(h, 4)}
def _diagram(block, where):
nodes, ids = [], set()
for i, node in enumerate(_list(block.get("nodes"), MAX_NODES, f"{where}.nodes")):
here = f"{where}.nodes[{i}]"
if not isinstance(node, dict):
raise PanelError(f"{here} must be {{id, label, detail, node_id}}")
node_id = _text(node.get("id") or f"n{i + 1}", 40, f"{here}.id")
if node_id in ids:
raise PanelError(f"{here}.id {node_id!r} twice")
ids.add(node_id)
out = {"id": node_id, "label": _text(node.get("label"), 40, f"{here}.label", True),
"detail": _text(node.get("detail"), 80, f"{here}.detail"),
"color": node.get("color") if node.get("color") in COLORS else ""}
if node.get("node_id") not in (None, ""):
out["node_id"] = _text(node.get("node_id"), 20, f"{here}.node_id")
nodes.append(out)
if not nodes:
raise PanelError(f"{where}.nodes is empty")
edges = []
for i, edge in enumerate(_list(block.get("edges"), MAX_EDGES, f"{where}.edges")):
here = f"{where}.edges[{i}]"
if not isinstance(edge, dict) or edge.get("from") not in ids or edge.get("to") not in ids:
raise PanelError(f"{here} must be {{from, to, label}} naming two node ids")
edges.append({"from": edge["from"], "to": edge["to"], "label": _text(edge.get("label"), 30, f"{here}.label")})
return {"nodes": nodes, "edges": edges, "direction": "down" if block.get("direction") == "down" else "right"}
def _model(item, where):
from .mcp_tools import ToolError, _resolve_model
from .model_media import _preview_url_for_model
model_id = item.get("model_id") if isinstance(item, dict) else item
try:
folder_type, path_idx, base_dir, file_path = _resolve_model(model_id)
except ToolError as error:
raise PanelError(f"{where}: {error}")
try:
cover = _preview_url_for_model(folder_type, path_idx, base_dir, file_path) or ""
except Exception: # noqa: BLE001 - a card without its cover is still a card
cover = ""
return {"model_id": str(model_id), "folder": folder_type, "path_idx": path_idx,
"rel": os.path.relpath(file_path, base_dir).replace(os.sep, "/"), "name": os.path.basename(file_path),
"cover": cover, "note": _text(item.get("note") if isinstance(item, dict) else "", 160, f"{where}.note")}
+515
View File
@@ -0,0 +1,515 @@
"""Panels the AI draws in its task (show_panel), from Anomalous's own blocks, and the answers
the user gives on them.
A panel is a step of the AI's current task (ai_tasks.py) holding a title and a list of blocks:
heading, text, images (from ComfyUI's output folder, with marks: circles or boxes and a label
at places given as fractions of the picture), slider (two pictures over each other, a line to
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
(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).
The user's answers come back through POST /anomalous/ai_tasks/{task}/answer and wait in the
step until the AI asks for them (get_answers, which may wait a while for one) or makes its next
successful MCP tool call (attach_pending). The user can also write to the AI in a task
(POST /anomalous/ai_tasks/{task}/message): the message is a step of its own and reaches the AI
the same way.
"""
import re
import threading
import time
import uuid
from . import ai_marks, ai_tasks
from .ai_panel_parts import ( # noqa: F401 - PanelError and the limits are read as ai_panels.*
MAX_ITEMS, MAX_NODES, PanelError, _diagram, _image, _list, _model, _text, _zoom,
)
from .mcp_tools import tool_text
MAX_BLOCKS = 30
MAX_ROWS = 30
MAX_COLUMNS = 8
MAX_WAIT = 55
TONES = ("normal", "muted", "tip", "warning")
LAYOUTS = ("row", "grid", "compare")
INTERACTIVE = ("choices", "input", "picks", "prompts")
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 "
"check it with a command (get_image_info(image=inpaint_image, folder='input', include_image=true) shows it). "
"Repaint with a workflow, never by painting the file with code. On a workflow that makes this picture (its model "
"and prompt: get_image_info on the picture says them), add_nodes(use=\"repaint\", image=inpaint_image) "
"builds the repaint in one call, wired into its sampler (the best repaint this ComfyUI can run); set_prompt "
"the sampler's prompt to what belongs in the area (\"a hand, five fingers\"), not the whole picture. Keep the block's low denoise (it keeps the thing's size; higher paints a new one filling the whole "
"area). run_workflow with batch 4 (each try gets its own seed) and let the user pick in a picks panel zoomed on the "
"area (zoom {x, y, w, h} around it), then show the old and new with a slider panel, zoomed the same. Say in "
"report what you set and why."
)
_answered = threading.Condition(ai_tasks._lock)
def clean_block(block, at, taken):
"""One block as the page may draw it, or PanelError."""
where = f"blocks[{at}]"
if not isinstance(block, dict) or not isinstance(block.get("type"), str):
raise PanelError(f"{where} must be an object with a type")
kind = block["type"]
out = {"type": kind}
if kind == "heading":
out["text"] = _text(block.get("text"), 120, f"{where}.text", True)
elif kind == "text":
out["text"] = _text(block.get("text"), 4000, f"{where}.text", True)
out["tone"] = block.get("tone") if block.get("tone") in TONES else "normal"
elif kind == "images":
items = _list(block.get("items"), MAX_ITEMS, f"{where}.items")
if not items:
raise PanelError(f"{where}.items is empty")
out["items"] = [_image(item, f"{where}.items[{i}]") for i, item in enumerate(items)]
out["layout"] = block.get("layout") if block.get("layout") in LAYOUTS else "row"
elif kind == "slider":
out.update(before=_image(block.get("before"), f"{where}.before"), after=_image(block.get("after"), f"{where}.after"),
before_label=_text(block.get("before_label"), 40, f"{where}.before_label"),
after_label=_text(block.get("after_label"), 40, f"{where}.after_label"))
if block.get("zoom"):
out["zoom"] = _zoom(block["zoom"], f"{where}.zoom")
elif kind == "picks":
items, ids = [], set()
raw = _list(block.get("items"), MAX_ITEMS, f"{where}.items")
if not raw: # one is "take this one" (a single base, ai_bases.py)
raise PanelError(f"{where}.items needs 1 to {MAX_ITEMS} pictures")
for i, item in enumerate(raw):
here = f"{where}.items[{i}]"
if not isinstance(item, dict):
raise PanelError(f"{here} must be {{id, image, label, reason, recommended}}")
item_id = _text(item.get("id") or f"p{i + 1}", 40, f"{here}.id")
if item_id in ids:
raise PanelError(f"{here}.id {item_id!r} twice")
ids.add(item_id)
picture = _image(item, here)
items.append({**picture, "id": item_id, "label": _text(item.get("label"), 60, f"{here}.label"),
"reason": _text(item.get("reason"), 200, f"{here}.reason"), "recommended": item.get("recommended") is True})
out.update(question=_text(block.get("question"), 200, f"{where}.question"), items=items,
multiple=block.get("multiple") is True)
if _page_word(block.get("question_key")):
out["question_key"] = block["question_key"]
if block.get("zoom"):
out["zoom"] = _zoom(block["zoom"], f"{where}.zoom")
elif kind == "run_map": # a run's real shape (ai_run_map.py), drawn by the page from the step; only show_run_map makes it
out["step"] = _text(block.get("step"), 20, f"{where}.step", True)
elif kind == "area": # the user's repaint area and the AI's, pictures in the task's marks folder; only ai_repaint makes it
if not all(ai_marks.FILE_NAME.fullmatch(str(block.get(side))) for side in ("yours", "ai")):
raise PanelError(f"{where}: an area block shows pictures Anomalous made")
out.update({side: block[side] for side in ("yours", "ai")}, grow=int(block.get("grow") or 0), feather=int(block.get("feather") or 0))
elif kind == "diagram":
out.update(_diagram(block, where))
elif kind == "models":
items = _list(block.get("items"), MAX_ITEMS, f"{where}.items")
if not items:
raise PanelError(f"{where}.items is empty")
out["items"] = [_model(item, f"{where}.items[{i}]") for i, item in enumerate(items)]
elif kind == "table":
columns = [_text(c, 60, f"{where}.columns") for c in _list(block.get("columns"), MAX_COLUMNS, f"{where}.columns")]
rows = []
for r, row in enumerate(_list(block.get("rows"), MAX_ROWS, f"{where}.rows")):
cells = _list(row, MAX_COLUMNS, f"{where}.rows[{r}]")
rows.append([_text(cell, 200, f"{where}.rows[{r}]") for cell in cells])
if not rows:
raise PanelError(f"{where}.rows is empty")
out.update(columns=columns, rows=rows)
elif kind == "list":
items = [_text(item, 300, f"{where}.items") for item in _list(block.get("items"), MAX_ROWS, f"{where}.items")]
if not items:
raise PanelError(f"{where}.items is empty")
out.update(items=items, ordered=block.get("ordered") is True)
elif kind == "params":
items = []
for i, item in enumerate(_list(block.get("items"), MAX_ROWS, f"{where}.items")):
if not isinstance(item, dict):
raise PanelError(f"{where}.items[{i}] must be {{label, value}}")
items.append({"label": _text(item.get("label"), 60, f"{where}.items[{i}].label", True),
"value": _text(item.get("value"), 300, f"{where}.items[{i}].value")})
if not items:
raise PanelError(f"{where}.items is empty")
out["items"] = items
elif kind == "choices":
options, ids = [], set()
raw = _list(block.get("options"), 8, f"{where}.options")
if len(raw) < 2:
raise PanelError(f"{where}.options needs 2 to 8 options")
for i, option in enumerate(raw):
if not isinstance(option, dict):
option = {"label": option}
label = _text(option.get("label"), 80, f"{where}.options[{i}].label", True)
option_id = _text(option.get("id") or f"o{i + 1}", 40, f"{where}.options[{i}].id")
if option_id in ids:
raise PanelError(f"{where}.options: id {option_id!r} twice")
ids.add(option_id)
options.append({"id": option_id, "label": label, "detail": _text(option.get("detail"), 200, f"{where}.options[{i}].detail"),
**({"recommended": True} if option.get("recommended") is True else {}),
**({key: option[key] for key in ("label_key", "detail_key") if _page_word(option.get(key))}),
**({"values": {str(key): _text(value, 120, f"{where}.options[{i}].values") for key, value in option["values"].items()}}
if isinstance(option.get("values"), dict) and _page_word(option.get("label_key")) else {}),
**({"sample": option["sample"]} if re.fullmatch(r"[a-z_]{1,40}", str(option.get("sample"))) else {})})
# other: a line for an answer none of the options is
out.update(question=_text(block.get("question"), 200, f"{where}.question"), options=options,
multiple=block.get("multiple") is True, other=block.get("other") is True)
elif kind == "input":
out.update(question=_text(block.get("question"), 200, f"{where}.question", True),
placeholder=_text(block.get("placeholder"), 80, f"{where}.placeholder"))
elif kind == "prompts": # filled in Anomalous only: the texts are sealed and the AI gets tokens
count = block.get("count", 1)
if not isinstance(count, int) or isinstance(count, bool) or not 1 <= count <= MAX_PROMPT_BOXES:
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
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")
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:
raise PanelError(f"{where}.id {block_id!r} twice")
taken.add(block_id)
out["id"] = block_id
return out
def clean_blocks(blocks):
blocks = _list(blocks, MAX_BLOCKS, "blocks")
if not blocks:
raise PanelError("blocks is empty")
taken = set()
return [clean_block(block, at, taken) for at, block in enumerate(blocks)]
def _block_ids(blocks):
"""Stable handles for deliberate edits; old records get them only when updated."""
taken = {block["id"] for block in blocks if block.get("id")}
out = []
for block in blocks:
block = dict(block)
if not block.get("id"):
block_id = uuid.uuid4().hex[:10]
while block_id in taken:
block_id = uuid.uuid4().hex[:10]
block["id"] = block_id
taken.add(block_id)
out.append(block)
return out
def _merge_blocks(existing, incoming):
"""Keep the order already seen, append new output, edit only identified blocks in place."""
merged = list(existing)
by_id = {block["id"]: at for at, block in enumerate(existing) if block.get("id")}
used = set()
for raw, block in zip(incoming, clean_blocks(incoming)):
if block["type"] in INTERACTIVE and not raw.get("id"):
# Positional q1/q2 defaults cannot identify a question in a partial append.
block.pop("id")
at = by_id.get(block.get("id"))
if at is None and not block.get("id"):
# 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:
if existing[at]["type"] != block["type"]:
raise PanelError("An existing block id must keep its type; use a new id for new output.")
merged[at] = {**block, **({"id": existing[at]["id"]} if existing[at].get("id") else {})}
used.add(at)
if len(merged) > MAX_BLOCKS:
raise PanelError(f"The panel would exceed {MAX_BLOCKS} blocks; use show_panel for the next stage of work.")
return _block_ids(merged)
def _find(panel_id):
"""(task, step) holding panel `panel_id`, or (None, None). Call under the lock."""
for task in ai_tasks._load().values():
for step in task.get("steps") or []:
if step.get("kind") == "panel" and step.get("panel", {}).get("id") == panel_id:
return task, step
return None, None
def _page_word(key):
"""A word of the page (locales.js) Anomalous's own panels name, shown in the user's language."""
return bool(re.fullmatch(r"ai[A-Z]\w{1,40}", str(key)))
def show(client, title, blocks, now=None, session="", task_id=None, tool="show_panel", extra=None, title_key=None):
title = _text(title, 120, "title", True)
panel = {"id": uuid.uuid4().hex[:10], "title": title, "blocks": _block_ids(clean_blocks(blocks)), "closed": False,
**({"title_key": title_key} if _page_word(title_key) else {})}
now = time.time() if now is None else now
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": tool, "kind": "panel", "ok": True,
"summary": f"{tool}: {title}", "panel": panel, "answers": {}, "results": [], **(extra or {})}
with ai_tasks._lock:
task = ai_tasks.current_task(client, now, session=session, task_id=task_id, follow_up=True)
ai_tasks._add_step(task, step)
ai_tasks._save(task)
ai_tasks._notify(task["id"], {"panel": title, "asks": any(b["type"] in INTERACTIVE for b in panel["blocks"])})
return panel
def update(panel_id, title=None, blocks=None, closed=None):
with ai_tasks._lock:
task, step = _find(str(panel_id))
if not step:
raise PanelError(f"No panel {panel_id!r}; show_panel returns its id.")
# Validate the whole update before replacing the saved panel.
panel = dict(step["panel"])
if title is not 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
task["updated"] = max(task.get("updated", 0), time.time())
ai_tasks._save(task)
ai_tasks._notify(task["id"])
return panel
def answer(task_id, step_id, block_id, choice=None, text=None, now=None, skip=False, extra=None):
"""The user's answer to one block of a panel (from the page), or `skip` it, with `extra` for the AI
(ai_repaint.prepare). Returns the step's answers."""
with _answered:
task = ai_tasks._load().get(task_id)
step = next((item for item in (task or {}).get("steps") or [] if item.get("id") == step_id), None)
if not step or step.get("kind") != "panel":
raise PanelError("No such panel")
if step["panel"].get("closed"):
raise PanelError("The AI closed this panel")
block = next((b for b in step["panel"]["blocks"] if b.get("id") == block_id), None)
if not block or block["type"] not in INTERACTIVE:
raise PanelError("No such question")
if block_id in step.setdefault("answers", {}):
raise PanelError("Already answered")
entry = {"at": time.time() if now is None else now, "delivered": False, **(extra or {})}
if skip:
entry["skipped"] = True
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"):
raise PanelError("Prompts are written in Anomalous's prompt box")
elif block["type"] in ("choices", "picks"):
picked = choice if isinstance(choice, list) else [choice]
options = block["options"] if block["type"] == "choices" else block["items"]
known = {option["id"]: option.get("label") or option["id"] for option in options}
picked = [str(item) for item in picked if str(item) in known]
if not picked or (len(picked) > 1 and not block.get("multiple")):
raise PanelError("Pick one of the options")
entry.update(choice=picked, labels=[known[item] for item in picked])
if (step.get("routes") or {}).get(picked[0]): # what to call for a route (mcp_routes.py)
entry["note"] = step["routes"][picked[0]]
else:
value = _text(text, 2000, "text", True)
entry["text"] = value
step["answers"][block_id] = entry
task["updated"] = max(task.get("updated", 0), entry["at"])
ai_tasks._save(task)
_answered.notify_all()
answers = dict(step["answers"])
ai_tasks._notify(task_id)
return answers
MAX_MESSAGE = 2000
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
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)
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 {})}
with _answered:
task = ai_tasks._load().get(task_id)
if not task:
raise PanelError("No such task")
task["steps"].append(step)
del task["steps"][:-ai_tasks.MAX_STEPS]
task["updated"] = max(task.get("updated", 0), now)
task["ended"] = None # writing to the AI goes on with the task: its next call comes back here
ai_tasks._save(task)
_answered.notify_all()
ai_tasks._notify(task_id)
return step
def _collect(client, panel_id, task_id=None):
"""Answers and messages not given to the AI yet (its own tasks, task `task_id`, or panel
`panel_id` and the messages of that panel's task), marked as given. Under the lock."""
out, waiting, messages, changed = [], [], [], set()
tasks = ai_tasks._load()
panel_task = _find(panel_id)[0] if panel_id is not None else None
named_task = tasks.get(task_id) if task_id else None
for task in tasks.values():
if panel_id is not None:
if task is not panel_task:
continue
elif task_id is not None:
if task is not named_task:
continue
elif task.get("client") != client:
continue
for step in task.get("steps") or []:
if step.get("kind") == "user" and not step.get("delivered") and (panel_id is None or task is panel_task):
step.update(delivered=True, delivered_at=time.time())
changed.add(task["id"])
message = {"id": step["id"], "task": task["id"], "text": step["text"], "at": step["at"]}
if step.get("tool") == "user_activity":
message["kind"] = "change" # something the user changed in Anomalous, not words to the AI
elif step.get("tool") == "user_pictures":
from . import ai_pictures
message.update(ai_pictures.message_of(step)) # the pictures themselves go with it
elif step.get("refs"):
from . import ai_message_refs
message.update(ai_message_refs.message_of(step)) # what its "@" point at, pictures attached
elif step.get("tool") == "user_stop":
message["kind"] = "stop" # the user stopped the AI's run (ai_run_control.py)
elif step.get("tool") == "user_toolbox":
message["kind"] = "toolbox" # what the user picked for the AI to work with (ai_toolbox.py)
elif step.get("tool") == "user_marks":
# Marks on a picture: the picture's path for the AI, and the marked one attached (ai_marks.image_items).
message.update(kind="marks", image="/".join(part for part in (step["image"].get("subfolder"), step["image"]["filename"]) if part),
mode=step.get("mode") or ("local" if step.get("inpaint") else "auto"),
_marked=ai_marks.path_of(task["id"], step.get("marked")))
if step.get("inpaint"):
message.update(inpaint_image=step["inpaint"], inpaint_note=INPAINT_NOTE)
messages.append(message)
continue
asked = step.get("gate") or {}
if asked.get("timed_out") and asked.get("decision") and not asked.get("delivered") and panel_id is None:
asked["delivered"] = True
changed.add(task["id"])
messages.append({"id": step["id"], "task": task["id"], "kind": "gate", "at": asked.get("at"),
"text": (f"The user allowed {step['tool']}: call it again with the same arguments now."
if asked["decision"] == "allow" else
f"The user said no to {step['tool']}." + (f" They said: {asked['text']}" if asked.get("text") else ""))})
continue
panel = step.get("panel")
if step.get("kind") != "panel" or (panel_id is not None and panel["id"] != panel_id):
continue
for block in panel["blocks"]:
if block["type"] not in INTERACTIVE:
continue
given = step.get("answers", {}).get(block["id"])
if not given:
if not panel.get("closed"):
waiting.append({"panel": panel["id"], "question": block["id"]})
continue
if given.get("delivered"):
continue
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})
out.append(item)
for tid in changed:
ai_tasks._save(tasks[tid])
return out, waiting, messages, changed
def wake():
"""Anything waiting in get_answers looks again (the user added something to a task)."""
with _answered:
_answered.notify_all()
def get_answers(client, panel_id=None, wait_seconds=0, task_id=None):
"""The user's new answers and messages; with `wait_seconds`, waits up to that long (MAX_WAIT)
for one: for a panel's question still open, or (no panel named) for anything the user sends."""
deadline = time.monotonic() + max(0, min(float(wait_seconds or 0), MAX_WAIT))
panel_id = str(panel_id) if panel_id else None
notified = set()
try:
with _answered:
if panel_id is not None and _find(panel_id)[1] is None:
raise PanelError(f"No panel {panel_id!r}; show_panel returns its id.")
while True:
answers, waiting, messages, changed = _collect(client, panel_id, task_id=task_id)
notified.update(changed)
left = deadline - time.monotonic()
if answers or messages or left <= 0 or (panel_id is not None and not waiting):
answer = {"answers": answers, "still_waiting": waiting}
if messages:
answer["messages"] = messages
return answer
_answered.wait(left)
finally:
for tid in notified:
ai_tasks._notify(tid)
def attach_pending(result, client, task_id=None):
"""Attaches unread user messages and panel answers to a successful tool result so an AI
that does not call get_answers still receives them once."""
if not isinstance(result, dict) or result.get("isError"):
return result
with _answered:
answers, waiting, messages, changed = _collect(client, None, task_id=task_id)
for tid in changed:
ai_tasks._notify(tid)
if not answers and not messages:
return result
data = result.get("structuredContent")
data = dict(data) if isinstance(data, dict) else {}
if messages:
data["user_messages"] = messages
if answers:
data["user_answers"] = answers
if waiting:
data["still_waiting"] = waiting
data["user_notice"] = (
"The user sent new input in the AI workbench. Read user_messages / user_answers and follow them; you may "
"reply in your chat or in Anomalous (report(reply_to=<its id>)), but once you and the user settle what to do, "
"record it with log_decision so the decision stays in Anomalous; "
"kind=change messages are changes the user made themselves while you worked: keep them, do not undo them; "
"kind=marks messages are places the user marked on a picture to change (the marked picture is attached): "
"say what you see and how you would fix it (show_panel can show the picture with your own marks), "
"ask_user which fix to do, and log_decision the fix chosen (related: the marks' id); "
"kind=toolbox messages are the nodes, combos, workflows, prompts, models and ways the user picked for you to use; "
"kind=stop: the user stopped your run, so do not run it again until they say what to change; "
"kind=pictures: pictures the user handed you (attached), with made_with read from each picture's own workflow."
)
pictures = ai_marks.image_items(messages)
result["structuredContent"] = data
content = list(result.get("content") or [])
if content and isinstance(content[0], dict) and content[0].get("type") == "text":
content[0] = {"type": "text", "text": tool_text(data)}
else:
content.insert(0, {"type": "text", "text": tool_text(data)})
result["content"] = content + pictures
return result
+190
View File
@@ -0,0 +1,190 @@
"""Pictures the user hands the AI working on a task (the gallery card's menu, the image inspector's
✦): a user step with the pictures (shown on the timeline like the AI's own), which the AI gets as
a message (kind "pictures", ai_panels._collect) with each picture attached as a 512 px copy and
what made it in a few words (brief(): models, LoRAs, prompts, size, sampler; read from the
picture's own workflow by ai_run_map.py), so nothing needs describing. Pictures handed one after
another before the AI looked join one step. mark_gone() tells the page which of a task's
pictures were deleted since. save_upload() keeps a picture the user dropped or pasted into a
message in the output folder's ai_inbox, so the gallery has it and the message carries it like
any other: read as a picture first, size-capped, never over a file.
"""
import hashlib
import io
import json
import os
import re
import time
import uuid
from PIL import Image
from . import ai_run_map, ai_tasks
from .image_search import read_png_text
from .mcp_tools import ToolError, _resolve_image
MAX_PICTURES = 8
INBOX = "ai_inbox" # under the output folder
MAX_UPLOAD = 25 * 1024 * 1024
UPLOAD_TYPES = {"PNG": ".png", "JPEG": ".jpg", "WEBP": ".webp", "GIF": ".gif"}
class PicturesError(ValueError):
pass
def brief(path):
"""What made the picture at `path`, from its own workflow: {models, loras, positive, negative, size, sampler} or {}."""
try:
prompt = json.loads((read_png_text(path) if path.lower().endswith(".png") else {}).get("prompt") or "null")
shape = ai_run_map.map_of(prompt) if isinstance(prompt, dict) else None
except (OSError, ValueError):
shape = None
if not shape:
return {}
out = {}
for box in shape["boxes"]:
facts = box["facts"]
if box["role"] == "model" and facts.get("file"):
out.setdefault("models", []).append(facts["file"])
elif box["role"] == "lora" and facts.get("file"):
out.setdefault("loras", []).append(f"{facts['file']} ({facts.get('strength', '?')})")
elif box["role"] == "prompt" and facts.get("text"):
out.setdefault("negative" if facts.get("polarity") == "negative" else "positive", facts["text"])
elif box["role"] == "sampler":
out["sampler"] = {key: facts[key] for key in ("steps", "cfg", "sampler_name", "scheduler", "denoise") if key in facts}
if facts.get("width") and "size" not in out:
out["size"] = f"{facts['width']}x{facts['height']}"
return out
def mark_gone(task):
"""Marks each output picture `task` shows (results, panels, marks) that is no longer in the output
folder, deleted or moved: "gone": true, so the page says so instead of a broken picture."""
root = ai_tasks.folder_paths.get_output_directory()
def walk(value):
if isinstance(value, dict):
if value.get("folder") == "output" and value.get("filename") and \
not os.path.isfile(os.path.join(root, value.get("subfolder") or "", value["filename"])):
value["gone"] = True
for item in value.values():
walk(item)
elif isinstance(value, list):
for item in value:
walk(item)
walk((task or {}).get("steps"))
return task
def save_upload(data, name="", now=None):
"""Keeps picture bytes `data` (the file's own name `name`, for its stem) in output/ai_inbox.
Returns its "ai_inbox/name.ext"."""
if not data or len(data) > MAX_UPLOAD:
raise PicturesError(f"A picture up to {MAX_UPLOAD // (1024 * 1024)} MB")
try:
with Image.open(io.BytesIO(data)) as image:
kind = image.format
image.verify()
except (OSError, ValueError, SyntaxError, Image.DecompressionBombError):
raise PicturesError("Not a picture Anomalous can read (PNG, JPEG, WebP or GIF)")
if kind not in UPLOAD_TYPES:
raise PicturesError("Not a picture Anomalous can read (PNG, JPEG, WebP or GIF)")
stem = re.sub(r"[^\w\- ]+", "", os.path.splitext(os.path.basename(str(name or "")))[0]).strip()[:60] or "pasted"
folder = os.path.join(ai_tasks.folder_paths.get_output_directory(), INBOX)
os.makedirs(folder, exist_ok=True)
stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(time.time() if now is None else now))
for count in range(1, 100):
filename = f"{stem}_{stamp}{'' if count == 1 else f' ({count})'}{UPLOAD_TYPES[kind]}"
try:
with open(os.path.join(folder, filename), "xb") as out: # never over a file
out.write(data)
except FileExistsError:
continue
return f"{INBOX}/{filename}"
raise PicturesError("No free name for the picture")
def hand(task_id, images, text="", now=None):
"""Hands output pictures `images` ("subfolder/name.png") to task `task_id`'s AI. Returns the step."""
if not isinstance(images, list) or not 1 <= len(images) <= MAX_PICTURES:
raise PicturesError(f"Hand 1 to {MAX_PICTURES} pictures")
pictures = []
for image in images:
try:
rel = _resolve_image(image)[0]
except ToolError as error:
raise PicturesError(str(error))
subfolder, _, filename = rel.rpartition("/")
picture = {"type": "image", "folder": "output", "filename": filename, "subfolder": subfolder}
if picture not in pictures:
pictures.append(picture)
now = time.time() if now is None else now
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
if not task or task.get("ended"):
raise PicturesError("No such open task")
last = task["steps"][-1] if task["steps"] else None
if last and last.get("tool") == "user_pictures" and not last.get("delivered") and not text:
step = last # the AI has not looked yet: one step with all of them
step["results"] = (step["results"] + [item for item in pictures if item not in step["results"]])[:MAX_PICTURES]
step["at"] = now
else:
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "user_pictures", "kind": "user", "ok": True,
"state": "done", "summary": "user_pictures", "delivered": False, "results": pictures,
"text": ai_tasks._short(str(text or "").strip(), 1000)}
task["steps"].append(step)
del task["steps"][:-ai_tasks.MAX_STEPS]
task["updated"] = max(task.get("updated", 0), now)
ai_tasks._save(task)
ai_tasks._notify(task_id)
return step
def to_input(image):
"""Picture `image` as a file in ComfyUI's input folder, where LoadImage reads (a control block's
reference, run_workflow's image): its name there. One already there stays itself; an output picture
("subfolder/name.png") is copied, its transparency kept (LoadImage's MASK), named by its content so
the same picture is one file, never over another. Not "name.png [output]": ComfyUI's page flags that
as a missing file once its run history is gone, though LoadImage reads it."""
from .mcp_tools import _resolve_folder_image
rel = str(image or "").replace("\\", "/").strip("/")
if rel.endswith(" [output]"): # ComfyUI's mark for an output picture, as Load Image shows it
rel = rel[: -len(" [output]")]
try:
return _resolve_folder_image(rel, "input")[0]
except ToolError:
pass
with open(_resolve_image(rel)[1], "rb") as source:
data = source.read()
name = f"anomalous_reference_{hashlib.sha1(data).hexdigest()[:12]}.png"
target = os.path.join(ai_tasks.folder_paths.get_input_directory(), name)
if not os.path.isfile(target):
try:
with Image.open(io.BytesIO(data)) as picture, open(target, "xb") as out:
clear = "A" in picture.getbands() or "transparency" in picture.info
picture.convert("RGBA" if clear else "RGB").save(out, "PNG")
except FileExistsError:
pass # the same picture, copied a moment ago
except OSError:
raise ToolError(f"{image} cannot be read as a picture.")
return name
def message_of(step):
"""The AI's message for a pictures step: the pictures' paths and what made each; their files go
in "_pictures" for ai_marks.image_items to attach."""
paths, briefs, files = [], [], []
for item in step.get("results") or []:
rel = "/".join(part for part in (item.get("subfolder"), item["filename"]) if part)
try:
path = _resolve_image(rel)[1]
except ToolError:
continue # deleted since: the rest still go
paths.append(rel)
briefs.append({"image": rel, **brief(path)})
files.append(path)
words = step.get("text") or "The user handed you these pictures in Anomalous."
return {"kind": "pictures", "text": words, "images": paths, "made_with": briefs, "_pictures": files}
+102
View File
@@ -0,0 +1,102 @@
"""Privacy mode (隐私模式): a task the user keeps private sends the AI none of its pictures and no prompt
text. The AI still builds and runs workflows and knows the models; it hears how many pictures came out
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).
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
step (run pictures, the user's messages) put a picture or a prompt in it. A call that
cannot be tied to a task is private while the default is on or any private task is unfinished:
when in doubt, nothing is shown. The card in the AI app (mcp_apps.py) shows no pictures either.
"""
import json
import os
import time
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.")
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."""
try:
with open(_path(), encoding="utf-8") as source:
return json.load(source).get("default") is True
except (OSError, ValueError, AttributeError):
return False
def set_default(on):
"""The default for new tasks. Turned off, the tasks that were private by it stay private."""
if not isinstance(on, bool):
raise ValueError("privacy is true or false")
with ai_tasks._lock:
if not on and default_on():
for task in ai_tasks._load().values():
if "privacy" not in task:
task["privacy"] = True
ai_tasks._save(task)
atomic_write_json(_path(), {"default": on})
return on
def is_private(task):
"""The task's own choice, else the default."""
value = (task or {}).get("privacy")
return value if isinstance(value, bool) else default_on()
def set_private(task_id, on):
"""The user's choice for one task, told to the AI as a step of theirs. Returns it."""
if not isinstance(on, bool):
raise ValueError("privacy is true or false")
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
if not task:
raise ValueError("No such task")
if is_private(task) != on:
ai_tasks._add_step(task, {"id": uuid.uuid4().hex[:10], "at": time.time(), "ms": 0, "tool": "user_privacy", "kind": "user",
"ok": True, "state": "done", "summary": "user_privacy", "privacy": on, "delivered": False, "results": [],
"text": "The user turned privacy mode on for this task. " + NOTE if on else
"The user turned privacy mode off for this task: you get the pictures again."})
task["privacy"] = on
ai_tasks._save(task)
ai_tasks._notify(task_id)
return on
def applies(task_id):
"""Whether a call of task `task_id` (None: none known) is private."""
task = ai_tasks.get_task(task_id) if task_id else None
if task:
return is_private(task)
if default_on():
return True
with ai_tasks._lock:
return any(item.get("privacy") is True and not item.get("ended") for item in ai_tasks._load().values())
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)."""
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")]
if len(content) != len(result.get("content") or []):
content.append({"type": "text", "text": NOTE})
result["content"] = content
if isinstance(result.get("structuredContent"), dict):
result["structuredContent"] = {**result["structuredContent"], "privacy": NOTE}
return ai_sealed.mask(result, task_id, info, tool)
+184
View File
@@ -0,0 +1,184 @@
"""Where to repaint a picture the user marked, as the AI proposes it and the user agrees on the AI
workbench (the propose_repaint_area tool, mcp_task_tools.py).
The user's marks (ai_marks.py) are rough, and the AI sees the marked picture: it may move, reshape,
grow and soften the area: `areas` (boxes or ellipses as fractions of the picture, x/y the top left
as in the marks), `grow` (pixels added around each) and `feather` (pixels of soft edge). Anomalous
draws the user's area and the AI's over the picture into the task's marks folder and puts both side
by side on a panel (block "area") with three answers: the AI's, the user's own, or marking it
again; a line of their own says what to change. When the user picks an area, the picture goes
into ComfyUI's input folder with that area as its alpha (ai_marks.write_inpaint), so LoadImage
gives the picture and the area as its MASK; the answer the AI reads carries the file's name and a
short note of the usual repaint nodes. How to build the repaint on the canvas stays the AI's.
"""
import os
import uuid
import folder_paths
from . import ai_marks, ai_panels, ai_tasks
SHAPES = ("box", "ellipse")
MAX_AREAS = 8
MAX_GROW = 256
MAX_FEATHER = 128
PREVIEW_EDGE = 1024
TINT = (255, 64, 64)
CHOICES = ( # id, label for the AI, the page's words
("ai", "Use the AI's area", "aiRepaintUseAi"),
("mine", "Use the user's own marks", "aiRepaintUseMine"),
("again", "Mark it again", "aiRepaintAgain"),
)
AGAIN_NOTE = "The user marks the picture again: wait for their new marks with get_answers, then propose again."
WORDS_NOTE = "The user said what to change about the area: propose_repaint_area again with it."
class RepaintError(ValueError):
pass
def _number(value, low, high, what):
if isinstance(value, bool) or not isinstance(value, (int, float)) or not low <= value <= high:
raise RepaintError(f"{what} is a number from {low} to {high}")
return value
def _areas(areas):
if not isinstance(areas, list) or not 0 < len(areas) <= MAX_AREAS:
raise RepaintError(f"areas lists 1 to {MAX_AREAS} areas")
out = []
for at, area in enumerate(areas):
where = f"areas[{at}]"
if not isinstance(area, dict) or area.get("shape", "box") not in SHAPES:
raise RepaintError(f"{where} is {{shape: box | ellipse, x, y, w, h}}")
x, y = (_number(area.get(key), 0, 1, f"{where}.{key}") for key in ("x", "y"))
w, h = (_number(area.get(key), 0.005, 1, f"{where}.{key}") for key in ("w", "h"))
out.append({"shape": area.get("shape", "box"), "x": round(x, 4), "y": round(y, 4),
"w": round(min(w, 1 - x), 4), "h": round(min(h, 1 - y), 4)})
return out
def _from_marks(marks):
"""The user's marks as areas: a circle an ellipse in its box, a line or an arrow the box around it."""
return [{"shape": "ellipse" if mark["shape"] == "circle" else "box", **{key: mark[key] for key in ("x", "y", "w", "h")}}
for mark in marks if mark["w"] > 0 and mark["h"] > 0]
def area_image(size, areas, grow=0, feather=0):
"""An "L" image of `size`, white over `areas` grown by `grow` pixels, `feather` pixels soft at the edge."""
from PIL import Image, ImageDraw, ImageFilter
width, height = size
area = Image.new("L", size, 0)
draw = ImageDraw.Draw(area)
for shape in areas:
box = [shape["x"] * width - grow, shape["y"] * height - grow,
(shape["x"] + shape["w"]) * width + grow, (shape["y"] + shape["h"]) * height + grow]
(draw.ellipse if shape["shape"] == "ellipse" else draw.rectangle)(box, fill=255)
return area.filter(ImageFilter.GaussianBlur(feather / 2)) if feather else area
def _user_area(marks_step):
"""How to make the user's own area: what they painted when they asked for only the marked areas, else their marks."""
painted = marks_step.get("inpaint")
if painted:
def from_painted(size):
from PIL import Image, ImageOps
with Image.open(os.path.join(folder_paths.get_input_directory(), painted)) as kept:
return ImageOps.invert(kept.getchannel("A")).resize(size)
return from_painted
areas = _from_marks(marks_step.get("marks") or [])
if not areas:
raise RepaintError("These marks hold no area to repaint")
return lambda size: area_image(size, areas)
def _preview(task_id, picture, area_of):
"""The picture, made small, with the area tinted, kept in the task's marks folder: its name."""
from PIL import Image
try:
with Image.open(ai_marks.picture_path(picture)) as original:
base = original.convert("RGB")
except OSError:
raise RepaintError("The marked picture cannot be read")
base.thumbnail((PREVIEW_EDGE, PREVIEW_EDGE))
area = area_of(base.size).point(lambda value: value * 55 // 100)
shown = Image.composite(Image.new("RGB", base.size, TINT), base, area)
name = f"{uuid.uuid4().hex[:10]}.png"
os.makedirs(ai_tasks.marks_dir(task_id), exist_ok=True)
with open(os.path.join(ai_tasks.marks_dir(task_id), name), "xb") as target:
shown.save(target, "PNG")
return name
def propose(client, title, marks_id, areas=None, grow=12, feather=8, note="", session=""):
"""Shows the user their area and the AI's for marks `marks_id`; returns the panel to wait on."""
with ai_tasks._lock:
found = next(((task, step) for task in ai_tasks._load().values() for step in task.get("steps") or []
if step.get("id") == marks_id and step.get("tool") == "user_marks"), None)
if not found:
raise RepaintError("marks is the id of the user's marks (user_messages[].id, kind marks)")
task, marks_step = found
grow = int(_number(grow, 0, MAX_GROW, "grow"))
feather = int(_number(feather, 0, MAX_FEATHER, "feather"))
shapes = _areas(areas) if areas else _from_marks(marks_step.get("marks") or [])
if not shapes:
raise RepaintError("These marks hold no area: give areas")
picture = marks_step["image"]
with_edges = {"grow": grow, "feather": feather}
try:
from PIL import Image
with Image.open(ai_marks.picture_path(picture)) as original:
scale = min(1, PREVIEW_EDGE / max(original.size)) # grow and feather are pixels of the whole picture
except OSError:
raise RepaintError("The marked picture cannot be read")
yours = _preview(task["id"], picture, _user_area(marks_step))
ai = _preview(task["id"], picture, lambda size: area_image(size, shapes, grow * scale, feather * scale))
blocks = [{"type": "area", "yours": yours, "ai": ai, **with_edges}]
if note:
blocks.append({"type": "text", "text": note})
blocks.append({"type": "choices", "id": "answer", "other": True,
"options": [{"id": key, "label": label, "label_key": words, **({"recommended": True} if key == "ai" else {})}
for key, label, words in CHOICES]})
try:
return ai_panels.show(client, title, blocks, session=session, task_id=task["id"], tool="propose_repaint_area",
extra={"repaint": {"marks": marks_id, "areas": shapes, **with_edges}})
except ai_panels.PanelError as error:
raise RepaintError(str(error))
def prepare(task_id, step_id, choice, text, skip=False):
"""What the user's answer to a repaint panel brings the AI, made before it is recorded: the repaint
picture for an area picked (inpaint_image and its note; "your call" takes the AI's), else what to
do now; {} for other panels."""
step = next((item for item in (ai_tasks.get_task(task_id) or {}).get("steps") or [] if item.get("id") == step_id), None)
asked = (step or {}).get("repaint")
if not asked:
return {}
picked = "ai" if skip else (choice[0] if isinstance(choice, list) and choice else choice)
if picked not in ("ai", "mine"):
return {"note": WORDS_NOTE if str(text or "").strip() and not picked else AGAIN_NOTE}
marks_step = next((item for task in ai_tasks._load().values() for item in task.get("steps") or []
if item.get("id") == asked["marks"]), None)
if not marks_step:
raise RepaintError("The marks are gone")
area_of = (_user_area(marks_step) if picked == "mine"
else lambda size: area_image(size, asked["areas"], asked["grow"], asked["feather"]))
try:
name = ai_marks.write_inpaint(marks_step["image"], area_of, uuid.uuid4().hex[:10])
except ai_marks.MarksError as error:
raise RepaintError(str(error))
return {"inpaint_image": name, "note": ai_panels.INPAINT_NOTE}
def discard(prepared):
"""The repaint picture `prepare` made, when the answer was refused after all (the plugin's own file)."""
if prepared.get("inpaint_image"):
try:
os.remove(os.path.join(folder_paths.get_input_directory(), prepared["inpaint_image"]))
except OSError:
pass
+90
View File
@@ -0,0 +1,90 @@
"""Stopping a run an AI started (the workbench's stop button, the AI's stop_run): its prompts still
waiting leave ComfyUI's queue and the one running is interrupted, only while it is still that one
(ComfyUI's own cancel; never anyone else's prompt). The run's watcher (ai_task_runs.py) then ends
the step as "cancelled" with who stopped it; when the user did, the AI is told with its next answer.
And the user's word on a finished run (set_verdict, the buttons under its pictures): it turned out
well, or not, kept on the run's step and told to the AI as their message."""
import time
import uuid
from . import ai_task_runs, ai_tasks
VERDICTS = ("good", "bad")
class RunControlError(ValueError):
pass
def _interrupt(queue, prompt_id):
"""Interrupts `prompt_id` if it is the prompt running now; True when it was."""
if hasattr(queue, "interrupt_if_running"):
return bool(queue.interrupt_if_running(prompt_id))
running, _ = queue.get_current_queue() # an older ComfyUI: check, then the global interrupt
if not any(item[1] == prompt_id for item in running):
return False
import nodes
nodes.interrupt_processing()
return True
def stop(step_id, by="user", task_id=None, now=None):
"""Stops run step `step_id` (in task `task_id` when given). Returns {"task", "removed", "interrupted"}."""
now = time.time() if now is None else now
with ai_tasks._lock:
tasks = ai_tasks._load()
task = next((item for item in tasks.values() if (not task_id or item["id"] == task_id)
and any(step["id"] == step_id for step in item.get("steps") or [])), 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"):
raise RunControlError("This run is not going any more")
prompts = list(step["prompt_ids"])
step["stop"] = by
if by == "user":
task["steps"].append({
"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "user_stop", "kind": "user", "ok": True,
"state": "done", "summary": "user_stop", "stopped": step_id, "delivered": False, "results": [],
"text": "The user stopped your run in Anomalous. Do not start it again unless they ask; ask_user what to change.",
})
del task["steps"][:-ai_tasks.MAX_STEPS]
task["updated"] = max(task.get("updated", 0), now)
ai_tasks._save(task)
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))
ai_tasks._notify(task["id"])
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."""
if verdict not in (*VERDICTS, None):
raise RunControlError("verdict is good, bad or null")
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:
return verdict
if verdict:
step["verdict"] = verdict
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.")})
else:
step.pop("verdict", None)
ai_tasks._save(task)
ai_tasks._notify(task_id)
return verdict
+180
View File
@@ -0,0 +1,180 @@
"""The shape of a workflow run in a few boxes, so the user sees what the AI really ran: read from
the prompt ComfyUI queued (ai_task_runs.py keeps it on the run's step as "map"), never from
what the AI says. Plumbing (reroutes, primitives, notes) is left out and wired through; each
box has a role (model, lora, prompt, size, sampler, image, upscale, decode, save, other) and a
few facts the page words in the user's language (ui_ai_run_map.js); the links carry ComfyUI's
types. changes() says what differs from the run before it: a setting, a node added or gone.
"""
import os
from .model_constants import MODEL_EXTENSIONS
MAX_BOXES = 24 # what a stage diagram holds (ai_panels.MAX_NODES)
MAX_EDGES = 40
MAX_CHANGES = 10
HIDDEN = {"Reroute", "Note", "MarkdownNote", "PrimitiveNode", "PrimitiveInt", "PrimitiveFloat", "PrimitiveString",
"PrimitiveStringMultiline", "PrimitiveBoolean"}
SEEDS = {"seed", "noise_seed"}
SAMPLER_FACTS = ("steps", "cfg", "sampler_name", "scheduler", "denoise")
TEXT_CHARS = 80
VALUE_CHARS = 60
def _linked(value):
return isinstance(value, list) and len(value) == 2 and isinstance(value[0], (str, int))
def _literal(value):
return isinstance(value, (str, int, float, bool)) and not isinstance(value, list)
def _file_name(value):
return os.path.splitext(os.path.basename(str(value).replace("\\", "/")))[0]
def _output_type(class_type, index):
"""ComfyUI's type of output `index` of `class_type` ("MODEL", "LATENT"...), or ""."""
try:
import nodes
types = getattr(nodes.NODE_CLASS_MAPPINGS.get(class_type), "RETURN_TYPES", None) or ()
return str(types[int(index)]) if int(index) < len(types) else ""
except Exception: # noqa: BLE001 - no ComfyUI (tests) or an odd node: the link stays unnamed
return ""
def _role(class_type, inputs):
name = class_type.lower()
if "lora_name" in inputs:
return "lora"
if _linked(inputs.get("latent_image")) or "sampler" in name and not name.endswith("select"):
return "sampler"
if name == "loadimage" or name.startswith("loadimage"):
return "image"
if "upscale" in name:
return "upscale"
if "loader" in name:
return "model"
if isinstance(inputs.get("text"), str) or "textencode" in name:
return "prompt"
if name.startswith("empty") and "latent" in name:
return "size"
if name.startswith("vaedecode"):
return "decode"
if name.startswith(("save", "preview")):
return "save"
return "other"
def _facts(role, inputs):
literal = {key: value for key, value in inputs.items() if _literal(value)}
facts = {}
model = next((value for value in literal.values() if isinstance(value, str) and value.lower().endswith(MODEL_EXTENSIONS)), None)
if model:
facts["file"] = _file_name(model)[:VALUE_CHARS]
if role == "lora":
for key in ("strength_model", "strength"):
if isinstance(literal.get(key), (int, float)):
facts["strength"] = round(float(literal[key]), 3)
break
elif role == "prompt":
text = next((value for key, value in literal.items() if isinstance(value, str) and key.startswith("text")), "")
facts["text"] = " ".join(text.split())[:TEXT_CHARS]
elif role == "sampler":
facts.update({key: literal[key] for key in SAMPLER_FACTS if key in literal})
elif role == "image":
facts["file"] = str(literal.get("image") or "")[:VALUE_CHARS]
elif role == "save":
facts["prefix"] = str(literal.get("filename_prefix") or "")[:VALUE_CHARS]
if all(isinstance(literal.get(key), (int, float)) and literal[key] > 0 for key in ("width", "height")):
facts.update(width=int(literal["width"]), height=int(literal["height"]))
if isinstance(literal.get("batch_size"), int) and literal["batch_size"] > 1:
facts["batch"] = literal["batch_size"]
if role == "other" and not facts:
facts["values"] = [[key, str(value)[:VALUE_CHARS]] for key, value in literal.items()
if key not in SEEDS and str(value).strip()][:2]
return facts
def map_of(prompt):
"""{"boxes": [{id, type, title, role, facts}], "links": [{from, to, type}], "params": {...}} of API prompt `prompt`."""
nodes = {str(key): node for key, node in (prompt or {}).items() if isinstance(node, dict) and node.get("class_type")}
shown = [key for key in nodes if nodes[key]["class_type"] not in HIDDEN]
if len(shown) > MAX_BOXES: # keep what makes the picture; drop the helpers first
rank = {"other": 2, "save": 1}
shown = sorted(shown, key=lambda key: rank.get(_role(nodes[key]["class_type"], nodes[key].get("inputs") or {}), 0))[:MAX_BOXES]
keep = set(shown)
def source(link, seen=()):
"""The shown node (and the output's type) a link comes from, wired through hidden nodes."""
key, index = str(link[0]), link[1]
if key in keep:
return key, _output_type(nodes[key]["class_type"], index)
node = nodes.get(key)
if not node or key in seen:
return None, ""
upstream = next((value for value in (node.get("inputs") or {}).values() if _linked(value)), None)
return source(upstream, (*seen, key)) if upstream else (None, "")
boxes, links, params = [], [], {}
for key in sorted(shown, key=lambda item: (len(item), item)):
node = nodes[key]
inputs = node.get("inputs") or {}
role = _role(node["class_type"], inputs)
boxes.append({"id": key, "type": node["class_type"][:80], "role": role, "facts": _facts(role, inputs),
"title": str((node.get("_meta") or {}).get("title") or node["class_type"])[:80]})
for name, value in inputs.items():
if _linked(value):
found, kind = source(value)
if found and found != key and len(links) < MAX_EDGES:
links.append({"from": found, "to": key, "type": kind or name.upper(), "input": name})
elif _literal(value) and name not in SEEDS:
params[f"{key}.{name}"] = str(value)[:300]
_polarity(boxes, links)
return {"boxes": boxes, "links": links, "params": params}
def _polarity(boxes, links):
"""Marks prompt boxes positive or negative by the sampler input they reach."""
by_id = {box["id"]: box for box in boxes}
feeds = {}
for link in links:
feeds.setdefault(link["from"], []).append(link)
for box in boxes:
if box["role"] != "prompt":
continue
todo, seen = [box["id"]], set()
while todo and "polarity" not in box["facts"]:
here = todo.pop()
seen.add(here)
for link in feeds.get(here, []):
if link["input"] in ("positive", "negative") and by_id[link["to"]]["role"] == "sampler":
box["facts"]["polarity"] = link["input"]
break
if link["to"] not in seen:
todo.append(link["to"])
def changes(before, after):
"""What differs in map `after` from map `before`: [{node, title, key, from, to}] or a node added / gone."""
if not before or not after:
return []
titles = {box["id"]: box["title"] for box in before.get("boxes", [])}
titles.update({box["id"]: box["title"] for box in after.get("boxes", [])})
old_ids = {box["id"]: box["type"] for box in before.get("boxes", [])}
new_ids = {box["id"]: box["type"] for box in after.get("boxes", [])}
out = []
for key, kind in new_ids.items():
if old_ids.get(key) != kind:
out.append({"node": key, "title": titles[key], "change": "added"})
for key, kind in old_ids.items():
if new_ids.get(key) != kind:
out.append({"node": key, "title": titles[key], "change": "removed"})
gone = {item["node"] for item in out}
old, new = before.get("params", {}), after.get("params", {})
for name in sorted(set(old) | set(new), key=lambda item: (len(item.split(".")[0]), item)):
node, key = name.split(".", 1)
if node not in gone and old.get(name) != new.get(name):
out.append({"node": node, "title": titles.get(node, node), "key": key,
"from": (old.get(name) or "")[:VALUE_CHARS], "to": (new.get(name) or "")[:VALUE_CHARS]})
return out[:MAX_CHANGES]
+72
View File
@@ -0,0 +1,72 @@
"""Attach explicitly identified external ComfyUI submissions to an AI task; never guess ownership."""
import json
import math
import time
from . import ai_task_runs, ai_tasks
from .mcp_tools import ToolError
def checked_wait(value):
"""wait_seconds as seconds from 0 to 55: one call waits no longer, so a longer wait is cut to that."""
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
raise ToolError("wait_seconds is a number of seconds, up to 55.")
return min(max(value, 0), 55)
def track_run(ctx, prompt_ids, task=None, wait_seconds=15):
wait_seconds = checked_wait(wait_seconds)
if (not isinstance(prompt_ids, list) or not 1 <= len(prompt_ids) <= 8
or any(not isinstance(pid, str) or not pid or len(pid) > 128 for pid in prompt_ids)
or len(set(prompt_ids)) != len(prompt_ids)):
raise ToolError("Give 1–8 unique prompt_ids returned by your own ComfyUI /prompt submissions.")
client, session = ctx.get("client") or "AI", ctx.get("session") or ""
task_id = task or ctx.get("task") or ai_tasks.active_task_id(client, session)
with ai_tasks._lock:
target = ai_tasks._load().get(task_id)
if not target or target.get("ended"):
raise ToolError("Begin or resume an open task before attaching a run; pass its task ID.")
existing = None
for candidate in ai_tasks._load().values():
for step in candidate.get("steps", []):
ids = step.get("prompt_ids") or []
if set(ids).intersection(prompt_ids):
if candidate["id"] != task_id or ids != prompt_ids:
raise ToolError("A prompt ID is already attached to another run. Do not reassign it.")
existing = step
if existing is None:
try:
queue = ai_task_runs._queue()
running, pending = queue.get_current_queue()
known = {item[1] for item in [*running, *pending]}
missing = [pid for pid in prompt_ids if pid not in known and not queue.get_history(prompt_id=pid).get(pid)]
except Exception as error:
raise ToolError("Cannot read ComfyUI's queue/history; nothing was attached.") from error
if missing:
raise ToolError("Unknown prompt IDs: " + ", ".join(missing) + ". Use this server's submission receipts.")
receipt = {"prompt_ids": prompt_ids, "queued": len(prompt_ids)}
result = {"structuredContent": receipt, "content": []}
step = ai_tasks.record(client, "run_workflow", {}, result, time.time(), session=session, task_id=task_id)
if not step:
raise ToolError("Could not record the run; nothing was queued by this tool.")
step.update(prompt_ids=list(prompt_ids), source="external")
ai_tasks._save(target)
ai_task_runs.watch(step["id"], receipt)
existing = step
answer = ai_task_runs.status(client, session, task_id, wait_seconds, step_id=existing["id"])
return {"task": task_id, "attached": True, **answer}
def wait_for_receipt(result, client, session, task_id, wait_seconds=15, step_id=None):
"""Return authoritative execution state alongside submission receipts, with a bounded wait."""
answer = ai_task_runs.status(client, session, task_id, wait_seconds, step_id=step_id)
data = {**result.get("structuredContent", {}), **answer, "task": task_id}
result["structuredContent"] = data
content = list(result.get("content") or [])
text = {"type": "text", "text": json.dumps(data, ensure_ascii=False)}
if content and content[0].get("type") == "text":
content[0] = text
else:
content.insert(0, text)
result["content"] = content
return result
+239
View File
@@ -0,0 +1,239 @@
"""What a run an AI started shows beyond the node and its steps (ai_task_runs.py adds it to the
run's progress, for the AI and the workbench): ComfyUI's console since the run began (the
progress bar of the node running with its own note, "Model Initializing", the models it loads:
what the user sees in the console window), the time per step and the time left, the graphics
card's memory, and once per run estimate(): the size each sampler works at, read from the queued
prompt (never changed), with warnings for a run that will be very slow or will not fit, such as a
picture upscaled by a model and then sampled again at full size."""
import math
import re
import time
from datetime import datetime
ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
# tqdm's bar: " 12%|## | 6/50 [00:03<00:22, 1.95it/s, a note]"
BAR = re.compile(r"(\d+)/(\d+)\s*\[([^<\]]*)<([^,\]]*),\s*([^,\]]*)(?:,\s*([^\]]*))?\]")
CONSOLE_LINES = 5
LINE_CHARS = 200
# Megapixels a sampler can work at comfortably on a 12 GB card; scaled by the card's memory. 8.4:
# on 2026-10-29 a 6 GB card (RTX 4050) sampled 4 megapixels (Anima, 10 steps) in about 100 s.
COMFORT_MP_12GB = 8.4
DECODE_MP_12GB = 8.4 # what a plain VAEDecode takes comfortably on a 12 GB card, likewise scaled
BIG_SAVE_MP = 64 # a saved picture past this (8192x8192) is heavy for the gallery and the viewers
PNG_BYTES_PER_PIXEL = 1.2 # what a ComfyUI PNG of a generated picture takes, roughly
LATENT_INPUTS = ("latent_image", "samples", "latent", "pixels", "image", "images")
NOT_PICTURES = {"model", "clip", "vae", "upscale_model", "positive", "negative", "conditioning", "control_net",
"mask", "sampler", "sigmas", "guider", "noise", "clip_vision", "style_model"}
def _logs():
try:
from app import logger
except ImportError:
return []
return list(logger.get_logs() or [])
def console_since(started, entries=None, limit=CONSOLE_LINES):
"""The console's last `limit` lines written since `started` (a time.time()), as shown: colours
gone, a progress bar redrawn in place kept at its latest."""
written = []
for entry in _logs() if entries is None else entries:
try:
at = datetime.fromisoformat(entry["t"]).timestamp()
except (KeyError, TypeError, ValueError):
continue
if at >= started - 1:
written.append(str(entry.get("m") or ""))
lines = []
for raw in "".join(written).split("\n"):
parts = [part for part in raw.split("\r") if part.strip()]
line = ANSI.sub("", parts[-1]).strip() if parts else ""
if line:
lines.append(line[:LINE_CHARS])
return lines[-limit:]
def _seconds(text):
"""tqdm's "01:23" / "1:02:03" in seconds, or None ("?")."""
try:
parts = [int(part) for part in text.strip().split(":")]
except ValueError:
return None
return sum(part * 60 ** index for index, part in enumerate(reversed(parts))) if 1 < len(parts) <= 3 else None
def bar_of(lines):
"""The progress bar on the console's last line: steps, seconds left and per step, its note; or None."""
match = BAR.search(lines[-1]) if lines else None
if not match:
return None
out = {"value": int(match[1]), "max": int(match[2])}
left = _seconds(match[4])
if left is not None and out["value"]:
out["left"] = left
rate = match[5].strip()
number = re.match(r"([\d.]+)\s*(s/it|it/s)", rate)
if number and float(number[1]) > 0:
out["rate"] = round(float(number[1]) if number[2] == "s/it" else 1 / float(number[1]), 2)
if match[6] and match[6].strip():
out["note"] = match[6].strip()[:80]
return out
class Pace:
"""Seconds per step and seconds left of the node running, from the steps seen over time
(for nodes whose bar is not on the console)."""
def __init__(self):
self.key, self.first = None, None
def update(self, key, value, most, now=None):
now = time.monotonic() if now is None else now
if key != self.key or self.first is None or value < self.first[1]:
self.key, self.first = key, (now, value)
return None
done = value - self.first[1]
if done <= 0:
return None
rate = (now - self.first[0]) / done
return {"rate": round(rate, 2), "left": max(0, round(rate * (most - value)))}
def vram():
"""The graphics card's free and total memory in MB, or None (no card, or no ComfyUI)."""
try:
import comfy.model_management as management
device = management.get_torch_device()
if getattr(device, "type", "") in ("cpu", "mps"):
return None
total, free = management.get_total_memory(device), management.get_free_memory(device)
except Exception: # noqa: BLE001 - memory is a hint; the run goes on without it
return None
return {"free_mb": int(free // 2 ** 26) * 64, "total_mb": int(total // 2 ** 20)}
def _image_size(name):
"""(width, height) of picture `name` in ComfyUI's input folder, or None."""
try:
import folder_paths
from PIL import Image
with Image.open(folder_paths.get_annotated_filepath(str(name))) as picture:
return picture.size
except Exception: # noqa: BLE001 - an unreadable picture only leaves the size unknown
return None
def _number(value):
return value if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) else None
def _linked(value):
return isinstance(value, list) and len(value) == 2 and isinstance(value[0], (str, int))
def _model_factor(prompt, link):
"""How many times an upscale model enlarges, from its file name ("4x-UltraSharp", "x2plus"); 4 if unsaid."""
name = str(((prompt.get(str(link[0])) or {}).get("inputs") or {}).get("model_name") or "") if _linked(link) else ""
match = re.search(r"(?<![0-9])([1-8])x|x([1-8])(?![0-9])", name, re.I)
return int(match[1] or match[2]) if match else 4
def estimate(prompt, vram_total_mb=None, image_size=_image_size):
"""The size each sampler of API prompt `prompt` works at, and warnings: {"samplers", "warnings"}."""
sizes = {}
def size(node_id, seen):
node_id = str(node_id)
if node_id in sizes:
return sizes[node_id]
node = prompt.get(node_id)
if not isinstance(node, dict) or node_id in seen:
return None
seen.add(node_id)
kind, inputs = str(node.get("class_type") or ""), node.get("inputs") or {}
names = sorted((name for name, value in inputs.items() if _linked(value) and name not in NOT_PICTURES),
key=lambda name: LATENT_INPUTS.index(name) if name in LATENT_INPUTS else len(LATENT_INPUTS))
upstream = next((found for found in (size(inputs[name][0], seen) for name in names) if found), None)
width, height = _number(inputs.get("width")), _number(inputs.get("height"))
scale = _number(inputs.get("scale_by"))
out = upstream
if kind == "LoadImage":
dims = image_size(inputs.get("image"))
out = {"width": dims[0], "height": dims[1], "batch": 1, "upscaled": False} if dims else None
elif width and height and width > 0 and height > 0:
batch = _number(inputs.get("batch_size")) or (upstream or {}).get("batch", 1)
out = {"width": width, "height": height, "batch": int(batch), "upscaled": (upstream or {}).get("upscaled", False)}
elif upstream and (width or height): # one side given: the other keeps the picture's shape
ratio = (width or 0) / upstream["width"] if width else height / upstream["height"]
out = {**upstream, "width": upstream["width"] * ratio, "height": upstream["height"] * ratio}
elif upstream and scale:
out = {**upstream, "width": upstream["width"] * scale, "height": upstream["height"] * scale}
elif upstream and kind == "ImageScaleToTotalPixels" and _number(inputs.get("megapixels")):
ratio = math.sqrt(inputs["megapixels"] * 1024 * 1024 / (upstream["width"] * upstream["height"]))
out = {**upstream, "width": upstream["width"] * ratio, "height": upstream["height"] * ratio}
elif upstream and kind == "ImageUpscaleWithModel":
factor = _model_factor(prompt, inputs.get("upscale_model"))
out = {**upstream, "width": upstream["width"] * factor, "height": upstream["height"] * factor, "upscaled": True}
sizes[node_id] = out
return out
def steps_of(inputs):
if _number(inputs.get("steps")):
return int(inputs["steps"])
source = prompt.get(str(inputs["sigmas"][0])) if _linked(inputs.get("sigmas")) else None
return int(source["inputs"]["steps"]) if source and _number((source.get("inputs") or {}).get("steps")) else None
memory = max(0.5, min(3.0, (vram_total_mb or 12288) / 12288))
comfort = COMFORT_MP_12GB * memory
samplers, warnings = [], []
for node_id, node in sorted(prompt.items(), key=lambda item: str(item[0])):
if not isinstance(node, dict):
continue
inputs = node.get("inputs") or {}
title = str((node.get("_meta") or {}).get("title") or node.get("class_type") or node_id)[:80]
if _linked(inputs.get("latent_image")):
found = size(inputs["latent_image"][0], set())
if not found:
continue
entry = {"node": str(node_id), "title": title, "width": round(found["width"]), "height": round(found["height"]),
"batch": found["batch"], "steps": steps_of(inputs), "upscaled": found["upscaled"]}
samplers.append(entry)
megapixels = entry["width"] * entry["height"] * entry["batch"] / 1048576
if megapixels > comfort:
warnings.append({"kind": "upscale_resample" if found["upscaled"] else "big_sample",
**entry, "megapixels": round(megapixels, 1)})
elif node.get("class_type") == "VAEDecode" and _linked(inputs.get("samples")):
found = size(inputs["samples"][0], set())
if found and found["width"] * found["height"] * found["batch"] / 1048576 > DECODE_MP_12GB * memory:
warnings.append({"kind": "big_decode", "node": str(node_id), "title": title,
"width": round(found["width"]), "height": round(found["height"])})
elif "Save" in str(node.get("class_type")) and _linked(inputs.get("images")):
# What is finally saved: an upscale model after the last sampler can make it far larger.
found = size(inputs["images"][0], set())
if found and found["width"] * found["height"] / 1048576 > BIG_SAVE_MP:
pixels = found["width"] * found["height"]
warnings.append({"kind": "big_save", "node": str(node_id), "title": title, "width": round(found["width"]),
"height": round(found["height"]), "megapixels": round(pixels / 1048576),
"megabytes": round(pixels * PNG_BYTES_PER_PIXEL / 1048576, -1)})
return {"samplers": samplers[:12], "warnings": warnings[:6], "comfort_mp": round(comfort, 1)}
def warning_words(warning):
"""One sentence for the AI on a warning of estimate()."""
where = f"{warning['title']} (node {warning['node']}) works at {warning['width']}x{warning['height']}"
if warning["kind"] == "upscale_resample":
return (f"{where}{' for ' + str(warning['steps']) + ' steps' if warning.get('steps') else ''} after an upscale "
"model enlarged the picture: very slow and may not fit in memory. If the user only wants it bigger, the "
"upscale model alone (ImageUpscaleWithModel -> SaveImage, no sampler after it) is enough; else sample "
"smaller (scale down before VAEEncode), use fewer steps or a lower denoise, or a tiled sampler.")
if warning["kind"] == "big_save":
return (f"{warning['title']} (node {warning['node']}) saves a {warning['width']}x{warning['height']} picture "
f"({warning['megapixels']} megapixels, about {warning['megabytes']:.0f} MB as PNG): heavy to look at in the "
"gallery and image viewers. Unless the user asked for that size, upscale less (2x instead of 4x) or start "
"from a smaller picture.")
if warning["kind"] == "big_sample":
return (f"{where}: {warning['megapixels']} megapixels, more than this card is comfortable with; expect it to be "
"very slow or run out of memory. Consider a smaller size and upscaling afterwards.")
return f"{where} when decoded: VAEDecodeTiled saves memory there."
+185
View File
@@ -0,0 +1,185 @@
"""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.
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.
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.
"""
import json
import re
from . import ai_tasks
from .mcp_tools import tool_text
TOKEN = re.compile(r"\[(?:P|img)\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"}}
MIN_SEALED = 3 # shorter texts are not looked for elsewhere (they would match inside other words)
MAX_PROMPTS = 8
MAX_PROMPT = 4000
MAX_PICTURES = 4
_loose = {} # tokens of calls tied to no task, while this ComfyUI runs
class Seals:
"""The tokens of task `task_id` (None: the calls tied to no task); save() keeps new ones."""
def __init__(self, task_id):
self.task_id = task_id
with ai_tasks._lock:
task = ai_tasks._load().get(task_id) if task_id else None
self.tokens = dict(task.get("sealed") or {}) if task else _loose
self.texts = {text: token for token, text in self.tokens.items()}
self.changed = False
def seal(self, text, kind="P"):
known = self.texts.get(text)
if known and known.startswith(f"[{kind}"):
return known
token = f"[{kind}{1 + sum(1 for item in self.tokens if item.startswith(f'[{kind}'))}]"
self.tokens[token], self.texts[text] = text, token
self.changed = True
return token
def save(self):
if not self.changed or not self.task_id:
return
with ai_tasks._lock:
task = ai_tasks._load().get(self.task_id)
if task:
task["sealed"] = self.tokens
ai_tasks._save(task)
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)."""
tokens = Seals(task_id).tokens
def text(words):
for _ in range(3):
words, before = TOKEN.sub(lambda match: tokens.get(match.group(0), match.group(0)), words), words
if words == before:
break
return words
return _strings(value, text)
def _sealable(text):
"""Whether `text` holds words: not only tokens (sealed already), numbers or ids (a node's "6")."""
return bool(WORD.search(TOKEN.sub("", text)))
def _text_input(info, node_type, key, value):
"""Whether input `key` of a `node_type` node is text the user wrote (STRING in ComfyUI's node list).
A setting the page adds to a known node ("control_after_generate"): only words with a space in them;
a node type it does not know: any string but a model file's name."""
spec = (info.get(node_type) or {}).get("input")
for group in ("required", "optional"):
found = ((spec or {}).get(group) or {}).get(key)
if found is not None:
return isinstance(found, (list, tuple)) and bool(found) and found[0] == "STRING"
if spec is not None:
return any(mark in value for mark in (" ", ","))
return not value.lower().endswith(MODEL_FILE)
def seal_text(seals, value):
"""A prompt field's value sealed (a string, or strings in a list); a model file's name stays."""
if isinstance(value, str):
return value if not _sealable(value) or value.lower().endswith(MODEL_FILE) else seals.seal(value)
if isinstance(value, list):
return [seal_text(seals, item) for item in value]
return value
def _walk(value, seals, info, keys):
if isinstance(value, list):
return [_walk(item, seals, info, keys) for item in value]
if not isinstance(value, dict):
return value
out = {}
node_type = value.get("class_type") or value.get("type")
for key, item in value.items():
if key in ("settings", "inputs", "widgets") and isinstance(item, dict) and isinstance(node_type, str):
out[key] = {name: seals.seal(text) if isinstance(text, str) and _sealable(text) and _text_input(info, node_type, name, text)
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)
else:
out[key] = _walk(item, seals, info, keys)
return out
def _strings(value, change):
if isinstance(value, dict):
return {key: _strings(item, change) for key, item in value.items()}
if isinstance(value, list):
return [_strings(item, change) for item in value]
return change(value) if isinstance(value, str) else value
def _hide(seals, text):
"""`text` with every picture name in it, then every sealed text (longest first), as tokens."""
text = PICTURE.sub(lambda match: seals.seal(match.group(0), "img"), text)
for sealed in sorted((item for item in seals.texts if len(item) >= MIN_SEALED), key=len, reverse=True):
if sealed in text:
text = text.replace(sealed, seals.texts[sealed])
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."""
if not isinstance(result, dict):
return result
seals = Seals(task_id)
data = result.get("structuredContent")
masked = None
if isinstance(data, (dict, list)):
masked = _strings(_walk(data, seals, info or {}, PROMPT_KEYS | TOOL_KEYS.get(tool, set())), lambda text: _hide(seals, text))
result["structuredContent"] = masked
content = []
for item in result.get("content") or []:
if isinstance(item, dict) and item.get("type") == "text":
try:
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 ""))}
content.append(item)
if "content" in result:
result["content"] = content
seals.save()
return result
def seal_answer(task_id, prompts, negative="", pictures=None):
"""The user's prompt box: {prompts, negative, pictures} as tokens, the texts and picture names sealed
in the task, 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")
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)
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 []))
return answer
+155
View File
@@ -0,0 +1,155 @@
"""Skills: advice on how to do a kind of job, which an AI fetches when the job comes up, beside the
ready-made blocks (reference_blocks.py), which build nodes. A block is something AMB does; a skill
is something the AI reads (how to write a prompt a model can draw). Each skill is a data file in
api/skills/ (<name>.json): what the AI is told it is for (short) and the advice itself (text, a
line a point), and what the user's Ways panel shows (title, about, in both languages,
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).
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
offered it and get_toolbox refuses it. A skill marked private_only is for private tasks
(ai_privacy.py) and is all a private task gets: begin_task points at it there instead of listing
the others, and get refuses each kind in the other kind of task. A by_model skill's
for_user words say the same to the user, on the prompt box of a private task. Each skill fetched is a step of the AI's task (note_use), with
the form its advice came in, so the user sees what the AI went by; the run after it says so too.
"""
import json
import os
import threading
import time
import uuid
FOLDER = os.path.join(os.path.dirname(__file__), "skills")
NOTE = "Advice: get_toolbox(skill=name) when its job comes up: "
_lock = threading.Lock()
class SkillError(ValueError):
pass
def _load():
found = {}
for file in sorted(os.listdir(FOLDER)):
if file.endswith(".json"):
with open(os.path.join(FOLDER, file), encoding="utf-8") as source:
found[file[:-5]] = json.load(source)
return found
SKILLS = _load()
def _off_path():
from . import ai_tasks
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_skills.json")
def turned_off():
"""The skills the user turned off in the panel."""
try:
with open(_off_path(), encoding="utf-8") as file:
return {name for name in json.load(file).get("off", []) if name in SKILLS}
except (OSError, ValueError, AttributeError):
return set()
def set_on(name, on):
"""Turns skill `name` on or off for the AI; the skills now off."""
from .path_utils import atomic_write_json
if name not in SKILLS:
raise SkillError(f"No skill {name!r}.")
with _lock:
off = turned_off() - {name} if on else turned_off() | {name}
atomic_write_json(_off_path(), {"off": sorted(off)})
return off
def status():
"""Every skill as the user's panel shows it."""
off = turned_off()
return [{"name": name, "title": skill["title"], "about": skill["about"], "on": name not in off,
"private_only": skill.get("private_only") is True} for name, skill in SKILLS.items()]
def directory():
"""The skills on, "name (short)" each after a note on fetching them, a line for begin_task; "" when none is on."""
off = turned_off()
listed = [f"{name} ({skill['short']})" for name, skill in SKILLS.items() if name not in off and not skill.get("private_only")]
return NOTE + "; ".join(listed) if listed else ""
def by_model(name):
"""Whether skill `name`'s advice depends on the canvas's main model."""
return bool(SKILLS.get(name, {}).get("by_model"))
def _form(forms, model):
"""Which form of `forms` (a skill's by_model) is for `model` {name, params}: large, small or unknown."""
params = (model or {}).get("params") or 0
return "unknown" if not params else "large" if params >= forms["large_from"] else "small"
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):
"""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)."""
if name not in SKILLS:
raise SkillError(f"No skill {name!r}; begin_task lists them: {', '.join(SKILLS)}.")
if name in turned_off():
raise SkillError(f"The user turned the skill {name!r} off: do the job your own way.")
if private and not SKILLS[name].get("private_only"):
raise SkillError("This task is private: the user writes the prompts. Its skill is get_toolbox(skill=\"privacy\").")
if not private and SKILLS[name].get("private_only"):
raise SkillError(f"The skill {name!r} is for private tasks; this one is not.")
skill = SKILLS[name]
lines = list(skill["text"])
answer = {"skill": name, "advice": lines}
forms = skill.get("by_model")
if forms:
form = _form(forms, model)
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}
return answer
def for_user(model, name="prompt_pose"):
"""Skill `name`'s words for the user on `model` {name, params} (its for_user form, {zh, en}); None when it is off."""
skill = SKILLS.get(name) or {}
forms = (skill.get("by_model") or {}).get("for_user")
if not forms or name in turned_off():
return None
form = _form(skill["by_model"], model)
billions = f"{model['params'] / 1e9:.1f}" if form != "unknown" else ""
return {lang: text.format(model=_model_name(model), billions=billions) for lang, text in forms[form].items()}
def note_use(client, answer, session="", task_id=None, now=None):
"""Skill `answer` (get's) fetched by the AI, as a step of its task: which skill and, for one that goes
by the model, the form it came in. The same skill in the same form again before a run adds nothing."""
from . import ai_tasks
skill = SKILLS[answer["skill"]]
now = time.time() if now is None else now
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "use_skill", "kind": "skill", "ok": True, "state": "done",
"summary": "use_skill", "skill": answer["skill"], "title": skill["title"], "for_model": answer.get("for_model"), "results": []}
with ai_tasks._lock:
task = ai_tasks.current_task(client, now, session=session, task_id=task_id)
for earlier in reversed(task.get("steps") or []):
if earlier.get("tool") == "run_workflow":
break
if earlier.get("tool") == "use_skill" and earlier.get("skill") == step["skill"] and earlier.get("for_model") == step["for_model"]:
return earlier
ai_tasks._add_step(task, step)
ai_tasks._save(task)
ai_tasks._notify(task["id"])
return step
+129
View File
@@ -0,0 +1,129 @@
"""Each task's own canvas tab, kept by Anomalous rather than left to the AI: with "new" (the
default, set in the Gates window; each task can change it) the page copies the workflow open
now into a new tab named after the task before the AI's first change, and brings that tab back
before each later change, read or run (web/modules/mcp_task_tab.js); new_workflow and
open_workflow make the tab they open the task's. The user's own tab is never changed. With
"current" the AI works on whatever tab is open, as before.
mcp_server.py marks each tool call with its task (begin_call); mcp_bridge.ask_page adds the
task's canvas to the page actions that touch the canvas (spec) and keeps the tab the page
reports (remember); tell() tells the AI once when a tab was made for it.
"""
import contextvars
import json
import os
import time
import uuid
from . import ai_tasks
from .path_utils import atomic_write_json
MODES = ("new", "current")
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"}
_call = contextvars.ContextVar("anomalous_task_canvas", default=None)
def _path():
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_canvas.json")
def default_mode():
try:
with open(_path(), encoding="utf-8") as source:
mode = json.load(source).get("mode")
except (OSError, ValueError, AttributeError):
mode = None
return mode if mode in MODES else DEFAULT
def set_default(mode):
if mode not in MODES:
raise ValueError("canvas_tab is new or current")
atomic_write_json(_path(), {"mode": mode})
return mode
def mode_of(task):
mode = ((task or {}).get("canvas") or {}).get("mode")
return mode if mode in MODES else default_mode()
def set_mode(task_id, mode):
"""The user's choice for one task. Returns the task's canvas record."""
if mode not in MODES:
raise ValueError("canvas is new or current")
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
if not task:
raise ValueError("No such task")
if (task.get("canvas") or {}).get("mode", default_mode()) != mode:
# The user's choice: the AI hears of it, and it is one of the task's decisions (ai_decisions.py).
now = time.time()
ai_tasks._add_step(task, {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "user_canvas", "kind": "user", "ok": True,
"state": "done", "summary": "user_canvas", "canvas": mode, "delivered": False, "results": [],
"text": "The user chose that you work " + ("in a tab of the task's own." if mode == "new" else "on the canvas they have open.")})
task["canvas"] = {**(task.get("canvas") or {}), "mode": mode}
ai_tasks._save(task)
canvas = dict(task["canvas"])
ai_tasks._notify(task_id)
return canvas
def begin_call(task_id):
"""Marks the tool call running now as task `task_id`'s; the holder says later what the page opened."""
holder = {"task": task_id, "opened": None}
_call.set(holder)
return holder
def spec(action):
"""What the page needs for page action `action` of the call running now: {mode, tab, title}, or None."""
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]}
def remember(tab):
"""Keeps the tab the page made or opened for the call's task ({path, name, made?})."""
holder = _call.get()
if not holder or not holder.get("task") or not isinstance(tab, dict) or not isinstance(tab.get("path"), str):
return
kept = {"path": tab["path"][:300], "name": str(tab.get("name") or "")[:120], "at": time.time()}
with ai_tasks._lock:
task = ai_tasks._load().get(holder["task"])
if not task:
return
task["canvas"] = {**(task.get("canvas") or {}), "tab": kept}
ai_tasks._save(task)
ai_tasks._notify(holder["task"])
holder["opened"] = {**kept, "made": bool(tab.get("made"))}
def tell(result, holder):
"""Says in the tool's answer that Anomalous made the task a tab of its own (once, when it did)."""
opened = (holder or {}).get("opened")
if not opened or not opened["made"] or not isinstance(result, dict) or result.get("isError"):
return result
data = dict(result.get("structuredContent") or {})
data["canvas_tab"] = {"name": opened["name"], "note": (
f"Anomalous copied the user's open workflow to a new tab \"{opened['name']}\" for this task (the user's "
"setting): your canvas changes, reads and runs go there; the user's own tab stays as it was. Do not switch "
"tabs yourself.")}
result["structuredContent"] = data
content = list(result.get("content") or [])
text = {"type": "text", "text": json.dumps(data, ensure_ascii=False)}
if content and isinstance(content[0], dict) and content[0].get("type") == "text":
content[0] = text
else:
content.insert(0, text)
result["content"] = content
return result
+68
View File
@@ -0,0 +1,68 @@
"""The user's own changes in Anomalous while an AI works on a task: each activity log entry the
user makes (activity_log.py tells note_activity of every new entry) becomes a step of the task
the AI called within USER_STEP_SECONDS, and the entry names that task back. The AI is told of it
with its next call (ai_panels.attach_pending, a message of kind "change"), so it keeps the user's
change instead of working over it. The AI's own canvas changes (via mcp) are its steps already;
which actions count, and their stage, is action_catalog.USER_ACTIONS. A private task's
(ai_privacy.py) are told with what was typed sealed (ai_sealed.py): the AI hears that a prompt
changed, not what it says."""
import time
import uuid
from . import action_catalog, activity_log, ai_privacy, ai_sealed, ai_tasks
USER_STEP_SECONDS = 10 * 60
def _live_task(now):
"""The task an AI is working on now (it called within USER_STEP_SECONDS), or None."""
live = [task for task in ai_tasks._load().values() if not task.get("ended") and not task.get("waiting")
and now - task.get("updated", 0) < USER_STEP_SECONDS]
return max(live, key=lambda task: task.get("updated", 0)) if live else None
def _change_text(entry, seal=lambda value: value):
"""What the user changed, in words for the AI; `seal(value)` for what was before and after."""
if entry.get("action") == "image_delete" and entry.get("target"):
return (f"Deleted the picture {entry['target']} (to the Recycle Bin): it is gone from the output folder, "
"so do not use, show or hand it in again; another picture is needed for what it was for.")
if entry.get("source") != "canvas":
words = str(entry.get("action", "")).replace("_", " ")
return f"{words}: {entry['target']}" if entry.get("target") else words
lines = []
for change in (entry.get("detail") or {}).get("changes") or []:
if change.get("kind") == "changed":
lines.append(f"{change.get('node')} {change.get('widget')}: {seal(change.get('before'))} -> {seal(change.get('after'))}")
elif change.get("kind") == "opened":
lines.append(f"opened the workflow {change.get('node')}")
else:
lines.append(f"{change.get('kind')} {change.get('node')}")
return "Changed on the canvas: " + "; ".join(lines[:8])
def note_activity(entry):
"""A new activity log entry: a step of the AI's task when the user made it while the AI worked."""
stage = action_catalog.USER_ACTIONS.get(entry.get("action"))
if stage is None or (entry.get("detail") or {}).get("via") == "mcp":
return None
at = entry.get("time") or time.time()
with ai_tasks._lock:
task = _live_task(at)
if not task:
return None
seals = ai_sealed.Seals(task["id"]) if ai_privacy.is_private(task) else None
text = _change_text(entry, (lambda value: ai_sealed.seal_text(seals, value)) if seals else (lambda value: value))
if seals:
seals.save()
step = {"id": uuid.uuid4().hex[:10], "at": at, "ms": 0, "tool": "user_activity", "kind": "user", "stage": stage,
"ok": True, "state": "done", "summary": entry.get("action", ""),
"text": ai_tasks._short(text, ai_tasks.MAX_NOTE), "activity": entry["id"],
"delivered": False, "results": []}
# Appended without moving "updated": the user's own changes do not keep the AI's task open.
task["steps"].append(step)
del task["steps"][:-ai_tasks.MAX_STEPS]
ai_tasks._save(task)
activity_log.tag(entry["id"], task["id"])
ai_tasks._notify(task["id"])
return step
+91
View File
@@ -0,0 +1,91 @@
"""Select a task to resume from unread user input, without changing task state or ownership;
whether a look belongs to the task just done; how many of a task's failures still stand.
Called under ai_tasks' lock when a caller has no recent open task. Explicit task selection
and the user's task waiting for the next AI take precedence in ai_tasks.
"""
import json
REPLIES = {"user_message", "user_marks", "user_pictures", "user_toolbox"}
REPLY_FRESH = 2 * 3600
AFTER_DONE = 5 * 60 # a look this soon after the AI said its task is done is part of that task, not a new one
def _reply_at(task, now):
"""Latest unread user input; a tool still running keeps its task from automatic takeover."""
if any(step.get("state") == "running" for step in task["steps"]):
return 0
replies = []
for step in task["steps"]:
if step.get("kind") == "user" and step.get("tool") in REPLIES:
replies.append(step)
elif step.get("kind") == "panel":
replies.extend((step.get("answers") or {}).values())
return max((r.get("at", 0) for r in replies if not r.get("delivered") and not r.get("recommended_on_timeout")
and 0 <= now - r.get("at", 0) < REPLY_FRESH), default=0)
def latest_replied(tasks, client, now):
"""The task of this app with the latest eligible reply, or None."""
candidates = ((at, task) for task in tasks.values() if task.get("client") == client and (at := _reply_at(task, now)))
_, task = max(candidates, key=lambda item: item[0], default=(0, None))
return task
def just_done(task, tasks, waiting, client, now, explicit=False):
"""`task` (the app's last one) when it said it is done (report done) moments ago: a look the AI
takes right after (checking its result) goes there, the task staying ended, and what it shows or
asks the user about it (a before and after, "happy with it?") opens it again; no new task is made
for either. Automatic selection yields to a waiting task or unread reply; an explicit
task id keeps the same precedence as selecting an open task."""
if not task or not task.get("ended") or not 0 <= now - task["ended"] < AFTER_DONE:
return None
if not any(step.get("tool") == "report" and step.get("at") == task["ended"] for step in task["steps"]):
return None # it ended by a pause or another task, not by the AI saying it is done
if not explicit and ((waiting and not waiting.get("ended")) or latest_replied(tasks, client, now)):
return None
return task
RECOVERY_TARGETS = {
"set_model": ("node_id",), "set_prompt": ("node_id", "role"), "set_values": ("node_id",),
"connect_nodes": ("from_node_id", "to_node_id", "input"), "remove_nodes": ("node_ids",),
"run_workflow": ("workflow",), "open_workflow": ("workflow",),
"get_model": ("model_id",), "move_model": ("model_id",), "download_model": ("url",),
"get_image_info": ("image",), "zoom_image": ("image",),
}
def recovery_target(tool, arguments):
"""Keep target identity, not prompt text or changed values, to distinguish independent retries."""
target = {key: arguments[key] for key in RECOVERY_TARGETS.get(tool, ()) if arguments.get(key) is not None}
if tool == "set_prompt":
target["role"] = arguments.get("role") or "positive"
for key, value in target.items():
if key.endswith("node_id"):
target[key] = str(value)
elif key == "node_ids" and isinstance(value, list):
target[key] = sorted(map(str, value))
if tool == "set_values" and isinstance(arguments.get("values"), dict):
values = arguments["values"]
if arguments.get("node_id") is None and values and all(isinstance(value, dict) for value in values.values()):
target["settings"] = {str(node): sorted(settings) for node, settings in values.items()}
else:
target["settings"] = sorted(values)
return json.dumps(target, sort_keys=True, ensure_ascii=False) if target else None
def unresolved(steps):
"""Only completed successful calls to the same recorded target resolve an earlier failure. A
failure that named no target (its arguments missing or misnamed, as a light model's first try
often is) is resolved by any later completed success of the same tool."""
later_ok, later_tools, count = set(), set(), 0
for step in reversed(steps):
key = (step.get("tool"), step.get("recovery_target"))
if step.get("ok") and step.get("state") in (None, "done"):
later_ok.add(key)
later_tools.add(key[0])
elif not step.get("ok") and key not in later_ok and not (key[1] is None and key[0] in later_tools):
count += 1
return count
+71
View File
@@ -0,0 +1,71 @@
"""Which task a task goes on from ("接着"), so work split over tasks is found together: a task an
AI begins as new while on another (ai_tasks.begin_task), or one the user starts while looking at
another (ai_tasks.start_task), keeps that one as its "from". The user can link a task to another
or take the link off (set_from); links() gives a task's own "from" and the tasks that go on from it,
and an AI beginning a task that goes on from another hears what was decided there. A task the user
removes (remove) leaves no gap: those that went on from it go on from what it went on from."""
from . import ai_decisions, ai_tasks
class LinkError(ValueError):
pass
def set_from(task_id, parent_id):
"""Task `task_id` goes on from `parent_id` (None: from none). Refuses a loop. Returns the parent id."""
with ai_tasks._lock:
tasks = ai_tasks._load()
task = tasks.get(task_id)
if not task:
raise LinkError("No such task")
if parent_id is not None:
if parent_id not in tasks or parent_id == task_id:
raise LinkError("No such other task")
seen, at = set(), parent_id
while at and at not in seen: # going up from the parent must not come back here
if at == task_id:
raise LinkError("That task already goes on from this one")
seen.add(at)
at = (tasks.get(at) or {}).get("from")
task["from"] = parent_id
else:
task.pop("from", None)
ai_tasks._save(task)
ai_tasks._notify(task_id)
return parent_id
def remove(task_id):
"""Takes task `task_id` off the list; those that went on from it now go on from what it went on from."""
with ai_tasks._lock:
tasks = ai_tasks._load()
parent = (tasks.get(task_id) or {}).get("from")
for task in tasks.values():
if task.get("from") == task_id:
if parent:
task["from"] = parent
else:
task.pop("from")
ai_tasks._save(task)
return ai_tasks.remove_task(task_id)
def links(task_id):
"""{from: {id, title} | None, next: [{id, title}]} of task `task_id`, the next oldest first."""
with ai_tasks._lock:
tasks = ai_tasks._load()
task = tasks.get(task_id) or {}
brief = lambda item: {"id": item["id"], "title": item.get("title") or ""} # noqa: E731
parent = tasks.get(task.get("from"))
later = sorted((item for item in tasks.values() if item.get("from") == task_id), key=lambda item: item.get("started", 0))
return {"from": brief(parent) if parent else None, "next": [brief(item) for item in later]}
def carried(task):
"""For an AI beginning `task`: the task it goes on from and what was decided there, or None."""
parent = ai_tasks.get_task(task.get("from")) if task.get("from") else None
if not parent:
return None
return {"task": parent["id"], "title": parent.get("title") or "", "decisions": ai_decisions.lines(parent),
"note": "This task goes on from that one; keep to what was decided there unless the user says otherwise."}
+471
View File
@@ -0,0 +1,471 @@
"""What a workflow run an AI started made: after run_workflow (mcp_server.py), the prompts
ComfyUI queued for it are watched in its history until they end, and the pictures and videos
they saved become the step's results (ai_tasks.py keeps the step). A thread per run, polling
every RUN_POLL_SECONDS, for at most ai_tasks.RUN_WATCH_SECONDS; nothing runs when no AI ran
anything. While it runs, where it stands (queued and how many are ahead, the node running and
its steps, nodes done; read from ComfyUI's queue and its progress registry, never changed) is
the step's "progress", sent to the page with each change, and status() answers the AI's
run_status with it, waiting for the end when asked. tell() puts the same news in the AI's
other answers: where the run stands while it goes, and once that it ended. The console's lines,
the time left and the card's memory join the progress, and the size each sampler works at with
its warnings is the step's "estimate" (ai_run_watch.py), the run's shape in boxes its "map" with
what changed since the task's run before it (ai_run_map.py); a run the user or the AI stopped
(ai_run_control.py) ends "cancelled"."""
import threading
import time
from . import ai_bases, ai_gates, ai_run_map, ai_run_watch, ai_tasks
from .mcp_tools import ToolError, _image_content, _resolve_image, retext, tool_text
RUN_POLL_SECONDS = 2.0
MAX_STATUS_WAIT = 55
MAX_PREVIEWS = 8 # the last PreviewImage pictures of a run the AI is told of
MAX_SHOWN = 4 # thumbnails of a finished run's pictures that come with the answer first telling of its end
MAX_TEXTS = 4 # what a run's nodes showed as words (a tagger's tags), the last few
TEXT_CHARS = 2000
def _queue():
from server import PromptServer
return PromptServer.instance.prompt_queue
def outputs_of(record_, folder="output"):
"""Saved pictures and videos in one history record; with `folder` "temp", what PreviewImage
nodes showed (a pose skeleton, a mask): the AI's to look at, not the user's results."""
out = []
for output in (record_.get("outputs") or {}).values():
for key in ("images", "gifs", "videos"):
for item in output.get(key) or []:
if isinstance(item, dict) and item.get("type") == folder and item.get("filename"):
out.append({"type": "image", "filename": item["filename"], "subfolder": item.get("subfolder") or "",
"folder": folder})
return out
def texts_of(record_):
"""What a history record's nodes showed as words (a tagger's tags, a text display): [{node, text}]."""
out = []
for node, output in (record_.get("outputs") or {}).items():
for key in ("tags", "text", "string"):
words = [item for item in output.get(key) or [] if isinstance(item, str) and item.strip()]
if words:
out.append({"node": str(node), "text": " | ".join(words)[:TEXT_CHARS]})
return out
def _registry():
"""ComfyUI's per-node progress of the prompt running now, or None (an older ComfyUI has none)."""
try:
from comfy_execution import progress
except ImportError:
return None
return progress.global_progress_registry
def progress_of(prompts):
"""Where the run of `prompts` stands: queued (how many are ahead), running (the node and its
steps, nodes done of all), or None when ComfyUI cannot say."""
try:
running, pending = _queue().get_current_queue()
except Exception: # noqa: BLE001 - no server (tests)
return None
running_ids = [item[1] for item in running]
order = [item[1] for item in sorted(pending, key=lambda item: item[0])]
current = next((pid for pid in prompts if pid in running_ids), None)
if not current:
waiting = [pid for pid in prompts if pid in order]
if not waiting:
return None
return {"state": "queued", "ahead": order.index(waiting[0]) + len(running_ids)}
out = {"state": "running", "prompt": prompts.index(current) + 1, "prompts": len(prompts)}
registry = _registry()
if registry is None or registry.prompt_id != current:
return out
nodes = dict(registry.nodes)
out["nodes_done"] = sum(1 for node in nodes.values() if node["state"].value == "finished")
out["nodes"] = len(registry.dynprompt.original_prompt)
active = next(((node_id, node) for node_id, node in nodes.items() if node["state"].value == "running"), None)
if active:
try:
info = registry.dynprompt.get_node(active[0]) or {}
except Exception: # noqa: BLE001 - a node of the run that is gone already
info = {}
out["node"] = str((info.get("_meta") or {}).get("title") or info.get("class_type") or active[0])[:80]
out["value"], out["max"] = int(active[1]["value"]), int(active[1]["max"])
# Like ComfyUI's own tab title: the running node's steps; without steps, the nodes done.
if out.get("max", 0) > 1:
out["percent"] = max(0, min(100, round(100 * out["value"] / out["max"])))
elif out["nodes"]:
out["percent"] = max(0, min(100, round(100 * out["nodes_done"] / out["nodes"])))
return out
def _show_progress(task_id, step_id, progress):
"""The run's progress on its step (kept in memory; the page hears of each change)."""
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
if step and step.get("stop"):
progress = {**progress, "stopping": step["stop"]}
if not step or step.get("progress") == progress or step.get("state") != "running":
return
step["progress"] = progress
ai_tasks._notify(task_id, {"progress": {"step": step_id, **progress}})
def _finish_run(task_id, step_id, prompts, history, queued=None):
if not prompts:
_stop_run(task_id, step_id, "没有找到已排队的运行 / No queued run was found; completion cannot be confirmed")
return True
images, previews, texts, failed, done = [], [], [], False, 0
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
stopped = next((item.get("stop") for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
for prompt_id in prompts:
record_ = history.get(prompt_id)
if not record_:
if stopped and queued is not None and prompt_id not in queued:
done += 1 # taken out of the queue before it started: it never gets a history
continue
if stopped and (record_.get("status") or {}).get("status_str") == "error":
done += 1 # the interrupt ends a prompt "error"; it was stopped, not broken
continue
status = record_.get("status") or {}
if status.get("status_str") == "error":
failed = True
done += 1
elif status.get("completed"):
done += 1
for image in outputs_of(record_):
if image not in images: # a prompt ComfyUI answers from its cache repeats the same file
images.append(image)
previews += [image for image in outputs_of(record_, "temp") if image not in previews]
texts += [text for text in texts_of(record_) if text not in texts]
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
if not step:
return True
failed = failed or bool(step.get("submission_error"))
stopped = step.get("stop")
results = [item for item in step["results"] if item["type"] != "image"] + images[:ai_tasks.MAX_RESULTS]
previews, texts = previews[-MAX_PREVIEWS:], texts[-MAX_TEXTS:]
if (done < len(prompts) and results == step["results"] and step.get("prompts") == len(prompts)
and previews == step.get("previews", []) and texts == step.get("texts", [])
and (not failed or step.get("ok") is False)):
return False # nothing new to keep: no write
step["results"] = results
if previews:
step["previews"] = previews
if texts:
step["texts"] = texts
step["state"] = "running" if done < len(prompts) else ("cancelled" if stopped else "error" if failed else "done")
step["prompts"] = len(prompts)
if failed:
step["ok"] = False
step["error"] = step.get("submission_error") or "ComfyUI 运行出错 / The run failed"
if done >= len(prompts):
step.pop("progress", None)
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 # the workbench offers the user a line to paste to the AI
task["updated"] = max(task["updated"], time.time())
ai_tasks._save(task)
ai_tasks._notify(task_id)
return done >= len(prompts)
def _stop_run(task_id, step_id, reason):
"""Monitoring ended without proof of completion; never pretend the run succeeded."""
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
if not step or step.get("state") != "running":
return
step.update(state="stopped", ok=False, error=reason, ms=max(0, int((time.time() - step["at"]) * 1000)))
step.pop("progress", None)
task["updated"] = max(task["updated"], time.time())
ai_tasks._save(task)
ai_tasks._notify(task_id)
def watch(step_id, receipt):
"""Follow only IDs returned by this submission, in batch order; never infer ownership from the queue."""
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)
if not task_id:
return
prompts = receipt.get("prompt_ids") or []
if not prompts:
_stop_run(task_id, step_id, "缺少运行 ID / Missing submission IDs; refresh ComfyUI before retrying")
return
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
if not step:
return # The user may remove the task while its submission is returning.
step["prompt_ids"] = list(prompts)
for key, value in (("bases", receipt.get("base_only")), ("base_check", receipt.get("base_check"))):
if value:
step[key] = value # ai_bases.py: bases to offer when it ends, or the picked one to compare
step_at = step["at"]
for other in task.get("steps", []): # a new run: no line to paste for an older one any more
if other is not step:
other.pop("call_ai", None)
if receipt.get("submission_error"):
step.update(submission_error=receipt["submission_error"], error=receipt["submission_error"], ok=False)
ai_tasks._save(task)
def follow():
start = time.monotonic()
pace, estimated = ai_run_watch.Pace(), False
while time.monotonic() - start < ai_tasks.RUN_WATCH_SECONDS:
try:
history = {pid: _queue().get_history(prompt_id=pid).get(pid) for pid in prompts}
except Exception: # noqa: BLE001 - stop the receipt, not the actual ComfyUI run
_stop_run(task_id, step_id, "无法读取运行记录 / Run history could not be read; check ComfyUI's queue")
return
try:
running, pending = _queue().get_current_queue()
queued = {item[1]: item for item in [*running, *pending]}
except Exception: # noqa: BLE001 - the queue only helps (estimate, a stopped run); history decides
queued = None
if not estimated: # from the queue, or from the history of a run that was quicker than this look
item = next((queued[pid] for pid in prompts if pid in (queued or {})), None) or next(((history.get(pid) or {}).get("prompt") for pid in prompts if (history.get(pid) or {}).get("prompt")), None)
estimated = _estimate(task_id, step_id, item)
if _finish_run(task_id, step_id, prompts, {k: v for k, v in history.items() if v},
None if queued is None else set(queued)):
ai_bases.offer(task_id, step_id, prompts, history)
ai_bases.check(task_id, step_id, prompts, history)
return
try:
progress = progress_of(prompts)
if progress and progress["state"] == "running":
_more_progress(progress, step_at, pace)
except (AttributeError, KeyError, TypeError, ValueError):
progress = None # progress is optional; keep watching the authoritative history
if progress:
_show_progress(task_id, step_id, progress)
time.sleep(RUN_POLL_SECONDS)
_stop_run(task_id, step_id, "运行跟踪已超时,未停止出图 / Monitoring timed out; the workflow was not cancelled. Check ComfyUI's queue")
threading.Thread(target=follow, name="anomalous-ai-run", daemon=True).start()
def _estimate(task_id, step_id, item):
"""The run's estimate (ai_run_watch.estimate) and map (ai_run_map) from its queued prompt `item`,
kept on the step once, with what changed since the task's run before."""
if not item or len(item) < 3 or not isinstance(item[2], dict):
return False
memory = ai_run_watch.vram()
try:
found = ai_run_watch.estimate(item[2], (memory or {}).get("total_mb"))
shape = ai_run_map.map_of(item[2])
except Exception: # noqa: BLE001 - an odd prompt leaves the run without an estimate, nothing more
return True
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
steps = (task or {}).get("steps", [])
at = next((index for index, entry in enumerate(steps) if entry["id"] == step_id), None)
if at is None:
return True
before = next((entry["map"] for entry in reversed(steps[:at]) if entry.get("tool") == "run_workflow" and entry.get("map")), None)
steps[at].update(estimate=found, map=shape, changes=ai_run_map.changes(before, shape))
ai_tasks._save(task)
ai_tasks._notify(task_id)
return True
def _more_progress(progress, started, pace):
"""The console's last lines, its bar's time left (else the pace of the steps), the card's memory."""
lines = ai_run_watch.console_since(started)
if lines:
progress["console"] = lines
bar = ai_run_watch.bar_of(lines)
if bar and bar.get("note"):
progress["note"] = bar["note"]
timing = None
if bar and bar["max"] == progress.get("max"):
timing = {key: bar[key] for key in ("left", "rate") if key in bar}
if not timing and progress.get("max", 0) > 1:
timing = pace.update((progress.get("prompt"), progress.get("node")), progress["value"], progress["max"])
if timing:
progress.update(timing)
memory = ai_run_watch.vram()
if memory:
progress["vram"] = memory
def summary(run):
"""One sentence for the AI on where run step `run` stands."""
state, progress = run.get("state"), run.get("progress") or {}
warned = " ".join(ai_run_watch.warning_words(item) for item in (run.get("estimate") or {}).get("warnings", []))
warning = (f" Warning: {warned} Tell the user (report) and ask_user whether to stop it (stop_run)." if warned and state == "running" else "")
if state == "running" and progress.get("state") == "queued":
return f"Your run is queued, {progress.get('ahead', 0)} ahead of it; it has not started. Do not run it again.{warning}"
if state == "running":
parts = []
if progress.get("prompts", 0) > 1:
parts.append(f"run {progress['prompt']} of {progress['prompts']}")
if progress.get("node"):
parts.append(f"{progress['node']} step {progress['value']}/{progress['max']}" if progress.get("max", 0) > 1
else progress["node"])
if "percent" in progress:
parts.append(f"{progress['percent']}%")
if progress.get("nodes"):
parts.append(f"{progress['nodes_done']} of {progress['nodes']} nodes done")
if progress.get("left") is not None:
parts.append(f"about {progress['left']} s left in this node")
parts.append(f"{round(time.time() - run['at'])} s so far")
if progress.get("note"):
parts.append(f"console says: {progress['note']}")
return (f"Your run is still going ({', '.join(parts)}). It has not finished: {waiting_words()}; "
"do not look in the output folder or guess." + warning)
if state == "cancelled":
return ("The user stopped your run in Anomalous; do not start it again unless they ask." if run.get("stop") == "user"
else "Your run was stopped (stop_run).")
if state == "done":
pictures = [item["filename"] for item in run.get("results") or [] if item.get("type") == "image"]
made = f"{len(pictures)} picture(s): {', '.join(pictures[:8])}" if pictures else "no pictures"
words = f" and words from {len(run['texts'])} node(s) (texts)" if run.get("texts") else ""
base = ai_bases.words(run)
return f"Your run finished in {round(run.get('ms', 0) / 1000)} s with {made}{words}.{' ' + base if base else ''}"
if state in ("asking", "allowed"):
return "Your run waits for the user's OK in Anomalous; it has not started."
return f"Your run did not finish: {run.get('error') or state}."
def news(task_id):
"""The run news for the AI working on task `task_id`: while its latest run goes, and once after it ends."""
with ai_tasks._lock:
task = ai_tasks._load().get(task_id) if task_id else None
run = next((step for step in reversed((task or {}).get("steps", [])) if step.get("tool") == "run_workflow"), None)
if not run or not run.get("prompt_ids") or run.get("told"):
return None
answer = {"state": run.get("state"), "summary": summary(run)}
if run.get("state") == "running" and run.get("progress"):
answer["progress"] = run["progress"]
if run.get("state") not in ("running", "asking", "allowed"):
run["told"], run["shown"] = True, False # show_pictures attaches them to this answer
run.pop("call_ai", None)
ai_tasks._save(task)
return answer
def waiting_words():
"""How the AI is to wait for a run still going, as the user chose (ai_gates.run_wait)."""
if ai_gates.run_wait() == "call":
return ("the user tells you when it is done (Anomalous gives them a line to paste to you): say it is running "
"and end your turn; do not wait with run_status")
return "call run_status with wait_seconds to wait for it"
def show_pictures(result, task_id):
"""Attaches thumbnails of the latest run's pictures to the answer that first tells the AI it ended
(status() or news() marked it), so it sees what came out without get_image_info; once per run."""
if not isinstance(result, dict) or result.get("isError") or not task_id:
return result
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
run = next((step for step in reversed((task or {}).get("steps", [])) if step.get("tool") == "run_workflow"), None)
if not run or run.get("shown") is not False:
return result
run["shown"] = True
ai_tasks._save(task)
shown, items = [], []
for item in run.get("results") or []:
rel = "/".join(part for part in (item.get("subfolder"), item.get("filename")) if part)
if item.get("type") != "image" or len(items) >= MAX_SHOWN:
continue
try:
picture = _image_content(_resolve_image(rel)[1])
except ToolError:
picture = None
if picture:
shown.append(rel)
items.append(picture)
if not items:
return result
data = dict(result.get("structuredContent") or {})
data["pictures_attached"] = shown
retext(result, data)
result["content"] = [*result["content"], *items]
return result
def tell(result, task_id):
"""Puts the run news (news()) into a tool result, so the AI need not go and look."""
run = news(task_id) if isinstance(result, dict) and not result.get("isError") else None
if not run:
return result
data = result.get("structuredContent")
data = dict(data) if isinstance(data, dict) else {}
data["your_run"] = run
retext(result, data)
return result
def status(client, session="", task_id=None, wait_seconds=0, step_id=None):
"""The AI's run_status: its task's latest run, where it stands, and what it made; with
`wait_seconds`, waits (up to MAX_STATUS_WAIT) until the run ends. No run yet is an answer, not
an error (an AI asks before it runs, and the task would show a failed step)."""
task_id = ai_tasks.active_task_id(client, session, task_id)
deadline = time.monotonic() + max(0.0, min(float(wait_seconds or 0), MAX_STATUS_WAIT))
while True:
task = ai_tasks.get_task(task_id) if task_id else None
run = next((step for step in reversed((task or {}).get("steps", []))
if step.get("tool") == "run_workflow" and (not step_id or step["id"] == step_id)), None)
if not run:
return {"state": "none", "summary": "No run in this task yet.",
"next": "run_workflow starts one (workflow: a saved one, with the prompt, seed or size to change)."}
if run.get("state") not in ("running", "asking", "allowed") or time.monotonic() >= deadline:
break
time.sleep(1)
answer = {"state": run.get("state"), "seconds": round(run.get("ms", 0) / 1000) if run.get("state") != "running"
else round(time.time() - run["at"]), "summary": summary(run)}
if run.get("state") not in ("running", "asking", "allowed") and not run.get("told"):
with ai_tasks._lock: # told here: the next answers need not repeat it
task = ai_tasks._load().get(task_id)
step = next((item for item in (task or {}).get("steps", []) if item["id"] == run["id"]), None)
if step is not None:
step["told"], step["shown"] = True, False # show_pictures attaches them to this answer
step.pop("call_ai", None)
ai_tasks._save(task)
if run.get("progress"):
answer["progress"] = run["progress"]
if (run.get("estimate") or {}).get("samplers"):
answer["estimate"] = run["estimate"]
if run.get("prompt_ids"):
answer["prompt_ids"] = run["prompt_ids"]
pictures = ["/".join(part for part in (item.get("subfolder"), item["filename"]) if part)
for item in run.get("results") or [] if item.get("type") == "image"]
if pictures:
answer["pictures"] = pictures
previews = ["/".join(part for part in (item.get("subfolder"), item["filename"]) if part) for item in run.get("previews") or []]
if previews:
answer["previews"] = previews
answer["previews_note"] = ("What the run's PreviewImage nodes showed (pose skeletons, masks…), in ComfyUI's temp "
"folder: zoom_image(image, folder='temp') or get_image_info(image, folder='temp', "
"include_image=true) shows them; there is no need to run a preprocessor yourself.")
if run.get("texts"):
answer["texts"] = run["texts"] # what nodes showed as words: a tagger's tags
if run.get("error"):
answer["error"] = run["error"]
# What to do now, in one line a small model can follow (get_situation and the canvas tools say theirs too).
answer["next"] = {
"running": (f"It is still running: {waiting_words()}. Do not guess it is done or run it again."
if ai_gates.run_wait() == "call" else
"run_status(wait_seconds=55): it is still running; do not guess it is done or run it again."),
"allowed": "run_status(wait_seconds=55): it is about to run.",
"done": "get_answers(wait_seconds=55): the user picks a base on Anomalous's panel." if run.get("bases") else
("The first answer telling you it ended has its pictures attached (pictures_attached): look at them, "
"then report what came out with images= its pictures; show_panel only to compare or ask. "
"zoom_image looks closer.") if pictures or not run.get("texts") else
("It made no pictures: texts has what its nodes showed (a tagger's tags); use them (set_prompt), then report."),
"asking": "get_answers(wait_seconds=55): the run waits for the user's OK in Anomalous.",
"cancelled": "It was stopped and nothing more comes of it: ask_user what to change before running again.",
}.get(run.get("state"), "get_situation shows what went wrong (a missing model: check_workflow_models); fix it, then run again.")
return answer
+596
View File
@@ -0,0 +1,596 @@
"""AI tasks: what AI apps did through Anomalous's MCP endpoint, kept for the AI workbench page.
Every tool call an AI app makes (mcp_server.py) becomes a step of a task: a task is one piece
of work, named by the AI with begin_task, else the calls of one app that follow each other
without a long pause. A step starts in state "running" when the call begins and is updated in
place when it finishes: what was done (the tool, a short summary of its arguments), whether it
worked, and what came out of it, so the page can show it live and lead there: the models it
used, the combo it placed, the canvas change it made (by its activity log entry, for undo),
the pictures a run made (ai_task_runs.py watches ComfyUI's history until the run ends), the
audio it spoke. The AI can also leave a note (report), with pictures it made another way. What
each tool is (kind, stage of the work, gate) is action_catalog.py's; the user's own changes
made while an AI works join its task through ai_task_changes.py. A step a restart left
"running" is marked stopped when the record is read again.
The user can start a task first (New task, POST /anomalous/ai_tasks): it waits for an AI, and
the next app that calls takes it over, so the work it does next starts there instead of joining
the app's last task. Likewise an app whose task ended or paused goes on in the task the user wrote
to (a message, marks, pictures, materials) that it has not read yet, rather than starting one.
Tasks are files in the ComfyUI user folder (anomalous/ai_tasks/<id>.json), the newest
MAX_TASKS kept. Recording never fails a tool call: the record is a convenience. The page
learns of a change through the "anomalous.ai_task" event. Reading is open like the other
pages; renaming and removing are writes (local_guard.py keeps them to this computer). The
routes are in ai_tasks_routes.py.
"""
import json
import logging
import os
import re
import shutil
import threading
import time
import uuid
import folder_paths
from . import action_catalog, activity_log
from .ai_task_handoff import just_done, latest_replied, recovery_target, unresolved
from .path_utils import atomic_write_json
FORMAT = 1
MAX_TASKS = 300
MAX_STEPS = 400
MAX_TASK_BYTES = 2 * 1024 * 1024
IDLE_SECONDS = 20 * 60 # a longer pause between calls of one app starts a new task
MAX_TEXT = 300
MAX_NOTE = 2000
MAX_TITLE = 80
MAX_RESULTS = 40
RUN_WATCH_SECONDS = 30 * 60
MEDIA_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".webm"}
# What each tool is (its kind, stage and gate) is action_catalog.py's.
OWN_STEPS = {"report", "log_decision", "log_outside", "show_panel", "ask_user", "show_run_map", "propose_repaint_area"} # make their step themselves (with its text or panel)
_lock = threading.RLock()
_tasks = None # id -> task
_current = {} # client/session key -> id of the task its calls go to
_waiting = [None] # id of the task the user started that no AI has taken yet
log = logging.getLogger(__name__)
# ---------------------------------------------------------------- storage
def tasks_dir():
user_dir = folder_paths.get_user_directory() if hasattr(folder_paths, "get_user_directory") else None
if not user_dir:
user_dir = os.path.join(folder_paths.base_path, "user")
directory = os.path.join(user_dir, "anomalous", "ai_tasks")
os.makedirs(directory, exist_ok=True)
return directory
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 _forget(task_id):
"""The task's own files: its record and its marked pictures (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)
def _load():
global _tasks
if _tasks is None:
_tasks = {}
for name in os.listdir(tasks_dir()):
if not name.endswith(".json"):
continue
try:
with open(os.path.join(tasks_dir(), name), encoding="utf-8") as source:
task = json.load(source)
except (OSError, ValueError):
continue
if isinstance(task, dict) and task.get("format") == FORMAT and isinstance(task.get("id"), str):
_tasks[task["id"]] = task
if _stop_running(task):
_save(task)
# A task the user started before ComfyUI restarted still waits for the next AI.
waiting = [task for task in _tasks.values() if task.get("waiting") and not task.get("ended")]
if waiting and _waiting[0] is None:
_waiting[0] = max(waiting, key=lambda task: task.get("started", 0))["id"]
return _tasks
def _stop_running(task):
"""Steps a call or a run left "running" when ComfyUI stopped: nothing watches them any more."""
stopped = False
for step in task.get("steps") or []:
if step.get("state") == "asking" and step.get("gate") and not step["gate"].get("timed_out"):
step["gate"]["timed_out"] = True # no old process is still waiting for this decision
stopped = True
if step.get("state") == "running":
step.update(state="stopped", ok=False, error="ComfyUI 重启时这一步还没结束 / ComfyUI restarted before this step ended")
stopped = True
return stopped
def _save(task):
try:
atomic_write_json(_path(task["id"]), task, MAX_TASK_BYTES)
except (OSError, ValueError) as error:
log.warning("Anomalous AI tasks: %s not saved: %s", task["id"], error)
def _drop_oldest():
tasks = _load()
if len(tasks) <= MAX_TASKS:
return
for task in sorted(tasks.values(), key=lambda item: item.get("updated", 0))[: len(tasks) - MAX_TASKS]:
tasks.pop(task["id"], None)
_forget(task["id"]) # the pictures and files it made stay
def _notify(task_id, extra=None):
try:
from server import PromptServer
PromptServer.instance.send_sync("anomalous.ai_task", {"id": task_id, **(extra or {})})
except Exception: # noqa: BLE001 - tests and early start have no server; the page polls on open
pass
# ---------------------------------------------------------------- who is calling (named in ai_callers.py)
def _owner_key(client, session=""):
return f"{client}::{session}" if session else client
def _bind_owner(client, session, task_id):
_current[_owner_key(client, session)] = task_id
if not session or client not in _current:
_current[client] = task_id
# ---------------------------------------------------------------- tasks and steps
def _short(value, limit=MAX_TEXT):
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
return text if len(text) <= limit else text[:limit] + "…"
def _new_task(client, title="", now=None, session=""):
now = time.time() if now is None else now
task = {"format": FORMAT, "id": f"{int(now)}_{uuid.uuid4().hex[:8]}", "title": _short(title, MAX_TITLE),
"title_from": "ai" if title else "auto", "client": client, "started": now, "updated": now,
"ended": None, "steps": []}
if session:
task["session"] = session
_load()[task["id"]] = task
_bind_owner(client, session, task["id"])
_drop_oldest()
return task
def _claim_explicit(tasks, task_id, client, session=""):
if not task_id or task_id not in tasks or tasks[task_id].get("ended"):
return None
picked = tasks[task_id]
if picked.get("waiting"):
if _waiting[0] == task_id:
_waiting[0] = None
picked["waiting"] = False
if not picked.get("client"):
picked["client"] = client
_bind_owner(client, session, task_id)
return picked
def _take_waiting(client, now, session=""):
"""The task the user started, now this app's (its own open task ends), or None."""
task = _load().get(_waiting[0])
_waiting[0] = None
if not task or task.get("ended"): # it waits until an AI takes it, as its page says, or the user starts another
return None
owner = _owner_key(client, session)
previous = _load().get(_current.get(owner) or (None if session else _current.get(client)))
if previous and previous is not task and not previous.get("ended"):
previous["ended"] = previous.get("updated")
_save(previous)
task["client"] = client
if session:
task["session"] = session
task["waiting"] = False
_bind_owner(client, session, task["id"])
return task
ABOUT_TYPES = {"model": "model_id", "image": "image", "combo": "name", "workflow": "name"} # what a task the user starts can be about
def _about(items):
"""What the user handed to the AI with a task (a model, an output image, a combo), checked: plain strings only."""
out = []
for item in items if isinstance(items, list) else []:
key = ABOUT_TYPES.get(item.get("type")) if isinstance(item, dict) else None
if key and isinstance(item.get(key), str) and item[key].strip():
out.append({"type": item["type"], key: _short(item[key].strip(), 300),
**({"label": _short(item["label"], 120)} if isinstance(item.get("label"), str) else {})})
return out[:5]
def start_task(title="", now=None, about=None, from_task=None):
"""A task the user starts; it waits for the next AI app that calls. `about`: what the user
handed to the AI with it (from a model page, an image or a combo); `from_task`: the task it
goes on from (ai_task_links.py). Returns the task."""
now = time.time() if now is None else now
with _lock:
before = _load().get(_waiting[0])
if before and not before.get("steps"): # started before and never taken: this one replaces it
remove_task(before["id"])
task = {"format": FORMAT, "id": f"{int(now)}_{uuid.uuid4().hex[:8]}", "title": _short(str(title or "").strip(), MAX_TITLE),
"title_from": "user", "client": "", "started": now, "updated": now, "ended": None, "steps": [], "waiting": True}
if not task["title"]:
task["title_from"] = "auto"
if _about(about):
task["about"] = _about(about)
if from_task in _load():
task["from"] = from_task
_load()[task["id"]] = task
_waiting[0] = task["id"]
_drop_oldest()
_save(task)
_notify(task["id"])
return task
def active_task_id(client, session="", task_id=None):
"""The active task id for this caller without creating a new task."""
with _lock:
tasks = _load()
if task_id and task_id in tasks:
return task_id
found = _current.get(_owner_key(client, session)) or (None if session else _current.get(client))
return found if found in tasks else None
def current_task(client, now=None, session="", task_id=None, follow_up=False, read_only=False):
"""The task this app's calls go to now: an explicit task, one the user started for the next
AI, else its open one, unless it paused too long. `follow_up`: a panel, question or note, which
right after the AI said its task is done is about that task (ai_task_handoff.just_done).
`read_only` keeps such a task ended while a follow-up reopens it; both honor an explicit id."""
now = time.time() if now is None else now
with _lock:
tasks = _load()
task = tasks.get(task_id or _current.get(_owner_key(client, session)))
if (follow_up or read_only) and just_done(task, tasks, tasks.get(_waiting[0]), client, now, explicit=bool(task_id)):
_bind_owner(client, session, task["id"])
if follow_up:
task["ended"] = None
_save(task)
return task
picked = _claim_explicit(tasks, task_id, client, session)
if picked:
return picked
waiting = _take_waiting(client, now, session=session)
if waiting:
return waiting
task = tasks.get(_current.get(_owner_key(client, session)) or (None if session else _current.get(client)))
if task and not task.get("ended") and now - task.get("updated", 0) < IDLE_SECONDS:
_bind_owner(client, session, task["id"])
return task
if task and not task.get("ended"):
task["ended"] = task.get("updated")
_save(task)
return _take_replied(tasks, client, now, session) or _new_task(client, now=now, session=session)
def _take_replied(tasks, client, now, session="", previous=None):
"""The newest task of this app the user wrote to lately, since its AI last read (an ended one
opens again), now this app's instead of a new one (its own open task `previous` ends), or None."""
replied = latest_replied(tasks, client, now)
if replied is None:
return None
if previous and previous is not replied and not previous.get("ended"):
previous["ended"] = previous.get("updated")
_save(previous)
replied["ended"] = None
_bind_owner(client, session, replied["id"])
_save(replied)
return replied
def begin_task(client, title, now=None, new_task=False, session="", task_id=None):
"""Name or resume the active task; only an explicit new piece of work splits it."""
now = time.time() if now is None else now
with _lock:
tasks = _load()
previous = tasks.get(_current.get(_owner_key(client, session)) or (None if session else _current.get(client)))
task = None if new_task else _claim_explicit(tasks, task_id, client, session)
if task is None:
task = _take_waiting(client, now, session=session)
if task is None and not new_task and previous and not previous.get("ended") and now - previous.get("updated", 0) < IDLE_SECONDS:
task = previous
if task is None and not new_task:
task = _take_replied(tasks, client, now, session, previous)
if task is None:
if previous and not previous.get("ended"):
previous["ended"] = now
_save(previous)
task = _new_task(client, title, now, session=session)
if new_task and previous:
task["from"] = previous["id"] # begun from that one (ai_task_links.py)
elif not task.get("title") or task.get("title_from") == "auto":
said = next((step["text"] for step in task["steps"] if step.get("tool") == "user_message" and step.get("text")), "")
task["title"] = _short(said or title, MAX_TITLE)
task["title_from"] = "user" if said else "ai"
_bind_owner(client, session, task["id"])
task["updated"] = now
_save(task)
_notify(task["id"])
return task
def _add_step(task, step):
task["steps"].append(step)
del task["steps"][:-MAX_STEPS]
task["updated"] = max(task.get("updated", 0), step["at"] + step.get("ms", 0) / 1000)
if task.get("title_from") == "auto" and not task.get("title") and step["kind"] not in ("look", "note", "skill") and step["ok"]:
task["title"] = step["summary"][:MAX_TITLE]
def summary_of(tool, arguments):
"""A few words on what a call asked for: its main argument."""
for key in ("title", "query", "text", "name", "workflow", "character", "model_id", "url", "what", "names", "type", "types"):
if arguments.get(key) not in (None, "", []):
value = arguments[key]
if key == "model_id": # "type:0:folder/file.safetensors": the file, and where it moves to
value = str(value).split(":", 2)[-1].rsplit("/", 1)[-1] + (f" → {arguments['to_type']}" if arguments.get("to_type") else "")
return _short(f"{tool}: {_short(value, 120)}", 160)
return tool
def _model_result(model_id):
from .mcp_tools import _resolve_model
try:
folder_type, path_idx, base_dir, file_path = _resolve_model(model_id)
except Exception: # noqa: BLE001 - a model that went away is still worth its id
return {"type": "model", "model_id": str(model_id)}
rel = os.path.relpath(file_path, base_dir).replace(os.sep, "/")
return {"type": "model", "model_id": str(model_id), "folder": folder_type, "path_idx": path_idx, "rel": rel,
"name": os.path.basename(file_path)}
def results_of(tool, arguments, data):
"""What came out of a call, for the page to show and lead to."""
data = data if isinstance(data, dict) else {}
out = []
if tool in ("set_model", "add_lora", "open_in_anomalous") and arguments.get("model_id"):
out.append(_model_result(arguments["model_id"]))
if tool == "place_combo" and arguments.get("name"):
out.append({"type": "combo", "name": _short(arguments["name"], 120)})
if isinstance(data.get("_activity"), str):
out.append({"type": "canvas", "activity": data["_activity"]})
if tool == "speak":
out.extend({"type": "audio", "file": _short(f, 200)} for f in data.get("files") or [] if isinstance(f, str))
if tool == "scan_models" and data.get("started"):
out.append({"type": "scan"})
if tool == "download_model" and data.get("started"):
out.append({**_download_result(data), "name": _short(str(data.get("model") or ""), 160)})
if tool == "download_missing_models":
out.extend(_download_result(item) for item in data.get("started") or [] if isinstance(item, dict))
return out[:MAX_RESULTS]
def _download_result(started):
"""A download the AI started (mcp_actions' answer): the file it makes, and its size when the site said."""
size = started.get("size")
return {"type": "download", "file": _short(str(started.get("to") or "").rsplit("/", 1)[-1], 200),
"size": size if isinstance(size, int) and size > 0 else None}
def _count_of(data):
"""How many things a read found: the length of its first list."""
if isinstance(data, dict):
for value in data.values():
if isinstance(value, list):
return len(value)
return None
def begin_step(client, tool, arguments, started=None, read_only=False, session="", task_id=None):
"""Starts an in-flight step in state 'running' before a tool executes. Returns (task_id, step_id) or None."""
if not action_catalog.is_step(tool) or tool in OWN_STEPS:
return None
try:
started = time.time() if started is None else started
args = arguments if isinstance(arguments, dict) else {}
what = action_catalog.entry(tool, read_only)
step = {"id": uuid.uuid4().hex[:10], "at": started, "ms": 0, "tool": tool, "kind": what.kind, "stage": what.stage,
"ok": True, "state": "running", "summary": summary_of(tool, args), "results": [],
"recovery_target": recovery_target(tool, args)}
with _lock:
task = current_task(client, started, session=session, task_id=task_id, read_only=read_only)
_add_step(task, step)
_save(task)
_notify(task["id"])
return task["id"], step["id"]
except Exception: # noqa: BLE001 - the record must never fail the AI's call
log.warning("Anomalous AI tasks: begin_step of %s failed", tool, exc_info=True)
return None
def finish_step(client, tool, arguments, result, started, read_only=False, step_ref=None, session="", task_id=None):
"""Completes an in-flight step (or creates one if step_ref is None) with its final outcome. Never raises."""
if not action_catalog.is_step(tool) or tool in OWN_STEPS:
return None
try:
args = arguments if isinstance(arguments, dict) else {}
data = result.get("structuredContent") if isinstance(result, dict) else None
failed = not isinstance(result, dict) or bool(result.get("isError"))
error = _short(" ".join(item.get("text", "") for item in result.get("content") or [] if isinstance(item, dict))) if failed and isinstance(result, dict) else ""
ms = max(0, int((time.time() - started) * 1000))
what = action_catalog.entry(tool, read_only)
kind = what.kind
waiting = isinstance(data, dict) and data.get("waiting_for_user") # a gate the user has not answered (ai_gates.py)
state = "error" if failed else "asking" if waiting else "running" if tool == "run_workflow" else "done"
summary = summary_of(tool, args)
results = [] if failed else results_of(tool, args, data)
with _lock:
task = _load().get(step_ref[0]) if isinstance(step_ref, tuple) and len(step_ref) == 2 else None
step = next((item for item in (task or {}).get("steps", []) if item.get("id") == step_ref[1]), None) if task else None
# A user can answer just after check() times out, before this call returns.
gate = (step or {}).get("gate") or {}
if waiting and gate.get("decision"):
failed = gate["decision"] == "deny"
state = "error" if failed else "done" if gate.get("used") else "allowed"
error = (step.get("error") or "你没有允许 / You said no") if failed else ""
if task is None:
task = current_task(client, started, session=session, task_id=task_id, read_only=read_only)
if step is None:
step = {"id": uuid.uuid4().hex[:10], "at": started, "ms": ms, "tool": tool, "kind": kind, "stage": what.stage,
"ok": not failed, "state": state, "summary": summary, "results": results}
_add_step(task, step)
else:
step.update(ms=ms, ok=not failed, state=state, summary=summary, results=results)
step["recovery_target"] = recovery_target(tool, args)
if error:
step["error"] = error
else:
step.pop("error", None)
if kind == "look" and not failed and tool.startswith(("search_", "list_")):
step["count"] = _count_of(data)
task["updated"] = max(task.get("updated", 0), step["at"] + ms / 1000)
if task.get("title_from") == "auto" and not task.get("title") and kind not in ("look", "note") and step["ok"]:
task["title"] = summary[:MAX_TITLE]
_save(task)
for result in results:
if result["type"] == "canvas":
activity_log.tag(result["activity"], task["id"]) # the activity page links back to the task
_notify(task["id"])
return step
except Exception: # noqa: BLE001 - the record must never fail the AI's call
log.warning("Anomalous AI tasks: step of %s not recorded", tool, exc_info=True)
return None
def record(client, tool, arguments, result, started, read_only=False, session="", task_id=None):
"""One tool call as a step of the app's current task. Never raises."""
return finish_step(client, tool, arguments, result, started, read_only, session=session, task_id=task_id)
# ---------------------------------------------------------------- notes from the AI
def _output_media(path, folder="output"):
"""An output-folder file the AI names ("subfolder/name.png"), as an image result, or None; with
`folder` "temp", a run's preview there (gone when ComfyUI restarts)."""
root = os.path.realpath(folder_paths.get_temp_directory() if folder == "temp" else folder_paths.get_output_directory())
full = os.path.realpath(os.path.join(root, str(path).replace("\\", "/").lstrip("/")))
if os.path.commonpath((root, full)) != root or not os.path.isfile(full):
return None
if os.path.splitext(full)[1].lower() not in MEDIA_EXTS:
return None
rel = os.path.relpath(full, root).replace(os.sep, "/")
subfolder, _, filename = rel.rpartition("/")
return {"type": "image", "filename": filename, "subfolder": subfolder, "folder": "temp" if folder == "temp" else "output"}
def report(client, text, images=None, done=False, now=None, session="", task_id=None, reply_to=None):
"""A note from the AI, with pictures it made (in ComfyUI's output folder); `reply_to`: the user's
message or marks it answers (both steps then point at each other). Returns the step."""
text = str(text or "").strip()
if not text and not images:
raise ValueError("text or images is needed")
results, missing = [], []
for path in (images or [])[:MAX_RESULTS]:
item = _output_media(path)
(results.append(item) if item else missing.append(str(path)))
now = time.time() if now is None else now
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "report", "kind": "note", "ok": True,
"state": "done", "summary": "report", "text": _short(text, MAX_NOTE), "results": results}
with _lock:
task = current_task(client, now, session=session, task_id=task_id, follow_up=True)
asked = next((item for item in task["steps"] if reply_to and item["id"] == reply_to and item.get("kind") == "user"), None)
if reply_to and not asked:
raise ValueError("reply_to is the id of the user's message or marks (user_messages[].id) in this task")
if asked:
step["reply_to"], asked["replied"] = asked["id"], step["id"]
_add_step(task, step)
if done:
task["ended"] = now
_save(task)
_notify(task["id"])
return step, missing
# ---------------------------------------------------------------- the page's view
def task_summary(task, now=None):
now = time.time() if now is None else now
steps = task.get("steps") or []
kinds = {}
for step in steps:
kinds[step["kind"]] = kinds.get(step["kind"], 0) + 1
images = [item for step in steps for item in step.get("results") or [] if item.get("type") == "image"]
made = {(item.get("folder"), item.get("subfolder"), item.get("filename")) for step in steps if step.get("kind") == "run"
for item in step.get("results") or [] if item.get("type") == "image"} # not a picture a report only shows
asks = sum(1 for step in steps if step.get("kind") == "panel" and not step["panel"].get("closed")
for block in step["panel"]["blocks"] if block.get("type") in ("choices", "input", "picks")
and block.get("id") and block["id"] not in step.get("answers", {}))
asks += sum(1 for step in steps if step.get("state") == "asking")
return {
"id": task["id"], "title": task.get("title") or "", "client": task.get("client") or ("" if task.get("waiting") else "AI"),
"started": task.get("started"), "updated": task.get("updated"), "ended": task.get("ended"),
"waiting": bool(task.get("waiting")) and not task.get("ended"),
"live": any(step.get("state") == "running" and now - step.get("at", 0) < RUN_WATCH_SECONDS for step in steps),
"steps": len(steps), "kinds": kinds, "errors": unresolved(steps),
"images": len(made), "cover": images[-1] if images else None, "asks": asks, "from": task.get("from"),
}
def list_tasks(limit=100):
with _lock:
tasks = sorted(_load().values(), key=lambda task: task.get("updated", 0), reverse=True)
return [task_summary(task) for task in tasks[:limit]]
def get_task(task_id):
with _lock:
task = _load().get(task_id)
return json.loads(json.dumps(task)) if task else None
def rename_task(task_id, title):
with _lock:
task = _load().get(task_id)
if not task:
return None
task["title"] = _short(str(title or "").strip(), MAX_TITLE)
task["title_from"] = "user" if task["title"] else "auto"
_save(task)
return task
def remove_task(task_id):
with _lock:
task = _load().pop(task_id, None)
if not task:
return False
for client, current in list(_current.items()):
if current == task_id:
_current.pop(client, None)
if _waiting[0] == task_id:
_waiting[0] = None
_forget(task_id)
return True
+369
View File
@@ -0,0 +1,369 @@
"""The AI workbench's routes over ai_tasks.py (the record) and ai_panels.py (panels, answers,
messages): the list and one task (reads, open like the other pages), and the writes the page
makes - start a task, rename or remove one, answer a panel, write to the AI, answer a gate and
set the gates' policies (ai_gates.py), stop a run or say how it turned out (ai_run_control.py),
hand the AI pictures (ai_pictures.py) - (local_guard.py keeps the writes to this computer)."""
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
async def api_list(request):
try:
limit = max(1, min(300, int(request.query.get("limit", 100))))
except ValueError:
limit = 100
return web.json_response({"tasks": ai_tasks.list_tasks(limit)})
async def api_start(request):
"""POST /anomalous/ai_tasks: {title, about?, from?} - the user starts a task for the next AI app that
calls; `about` names what it is about ([{type: model | image | combo, model_id | image | name}]),
`from` the task it goes on from."""
try:
body = await request.json()
except (ValueError, UnicodeDecodeError):
body = {}
body = body if isinstance(body, dict) else {}
title = body.get("title") if isinstance(body.get("title"), str) else ""
parent = body.get("from") if isinstance(body.get("from"), str) else None
return web.json_response({"task": ai_tasks.task_summary(ai_tasks.start_task(title, about=body.get("about"), from_task=parent))})
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["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_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}
whether its pictures are kept from the AI (ai_privacy.py), {"from": id | null}
links it to the task it goes on from (or takes the link off)."""
task_id = request.match_info["task_id"]
try:
body = await request.json()
except (ValueError, UnicodeDecodeError):
body = None
if not isinstance(body, dict):
return web.json_response({"error": "Expected {title} or {remove: true}"}, status=400)
if body.get("remove") is True:
return web.json_response({"removed": ai_task_links.remove(task_id)})
if "canvas" in body:
try:
return web.json_response({"canvas": ai_task_canvas.set_mode(task_id, body["canvas"])})
except ValueError as error:
return web.json_response({"error": str(error)}, status=400)
if "privacy" in body:
try:
return web.json_response({"privacy": ai_privacy.set_private(task_id, body["privacy"])})
except ValueError as error:
return web.json_response({"error": str(error)}, status=400)
if "from" in body:
try:
return web.json_response({"from": ai_task_links.set_from(task_id, body["from"] if isinstance(body["from"], str) else None)})
except ai_task_links.LinkError as error:
return web.json_response({"error": str(error)}, status=400)
if "title" in body:
task = ai_tasks.rename_task(task_id, body["title"])
return web.json_response({"task": ai_tasks.task_summary(task)}) if task else web.json_response({"error": "not_found"}, status=404)
return web.json_response({"error": "Expected {title} or {remove: true}"}, status=400)
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
try:
body = await request.json()
except (ValueError, UnicodeDecodeError):
body = None
if not isinstance(body, dict):
return web.json_response({"error": "Expected {step, block, choice | text}"}, status=400)
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"))
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))
except (ai_repaint.RepaintError, ValueError, OSError) as error:
return web.json_response({"error": str(error)}, status=400)
try:
answers = ai_panels.answer(task_id, step_id, str(body.get("block")), body.get("choice"), body.get("text"), skip=skip, extra=extra)
except ai_panels.PanelError as error:
ai_repaint.discard(extra)
ai_bases.discard(extra)
return web.json_response({"error": str(error)}, status=400)
await asyncio.to_thread(ai_bases.hold, task_id, extra)
return web.json_response({"answers": answers})
async def api_typing(request):
"""POST /anomalous/ai_tasks/{id}/typing: the user is typing an answer or a message (ai_answer_wait.py)."""
from . import ai_answer_wait
if not ai_answer_wait.typing(request.match_info["task_id"]):
return web.json_response({"error": "not_found"}, status=404)
return web.json_response({"ok": True})
async def api_message(request):
"""POST /anomalous/ai_tasks/{id}/message: {text, refs?} - the user writes to the AI on that task (refs: its "@")."""
from . import ai_panels
try:
body = await request.json()
except (ValueError, UnicodeDecodeError):
body = None
if not isinstance(body, dict):
return web.json_response({"error": "Expected {text}"}, status=400)
try:
step = ai_panels.leave_message(request.match_info["task_id"], body.get("text"), body.get("refs"))
except ai_panels.PanelError as error:
return web.json_response({"error": str(error)}, status=400)
return web.json_response({"step": step})
async def _body(request):
try:
body = await request.json()
except (ValueError, UnicodeDecodeError):
return None
return body if isinstance(body, dict) else None
async def api_gate(request):
"""POST /anomalous/ai_tasks/{id}/gate: {step, decision: allow | deny, text?, remember?}."""
body = await _body(request)
if body is None:
return web.json_response({"error": "Expected {step, decision}"}, status=400)
try:
step = ai_gates.decide(request.match_info["task_id"], str(body.get("step")), body.get("decision"),
body.get("text") or "", bool(body.get("remember")))
except ValueError as error:
return web.json_response({"error": str(error)}, status=400)
return web.json_response({"step": step})
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 {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,
POST {nodes: any | ways} which custom nodes the AI adds itself (ai_gates.node_rule),
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(),
"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()}
if request.method == "GET":
return web.json_response(settings())
body = await _body(request)
try:
if "canvas_tab" in (body or {}):
ai_task_canvas.set_default(body["canvas_tab"])
elif "privacy" in (body or {}):
ai_privacy.set_default(body["privacy"])
elif "runs" in (body or {}):
ai_gates.set_run_wait(body["runs"])
elif "answer_wait" in (body or {}):
ai_gates.set_answer_wait(body["answer_wait"])
elif "no_answer" in (body or {}):
ai_gates.set_no_answer(body["no_answer"])
elif "nodes" in (body or {}):
ai_gates.set_node_rule(body["nodes"])
elif "routes" in (body or {}):
ai_gates.set_route_rule(body["routes"])
else:
ai_gates.set_policy((body or {}).get("gate"), (body or {}).get("policy"))
except ValueError as error:
return web.json_response({"error": str(error)}, status=400)
return web.json_response(settings())
async def api_marks(request):
"""POST /anomalous/ai_tasks/{id}/marks: {image, png, marks, text, mode, mask} - the user's marks on a picture for the AI."""
from . import ai_panels
body = await _body(request)
if body is None:
return web.json_response({"error": "Expected {image, png, marks}"}, status=400)
try:
step = await asyncio.to_thread(ai_marks.save, request.match_info["task_id"], body.get("image"),
body.get("png"), body.get("marks"), body.get("text"), None, body.get("mask"), body.get("mode"))
except ai_marks.MarksError as error:
return web.json_response({"error": str(error)}, status=400)
ai_panels.wake()
return web.json_response({"step": step})
async def api_marked(request):
"""GET /anomalous/ai_tasks/{id}/marks/{name}: a marked picture the user sent."""
path = ai_marks.path_of(request.match_info["task_id"], request.match_info["name"])
if not path:
return web.json_response({"error": "Not found"}, status=404)
return web.FileResponse(path, headers={"Cache-Control": "private, max-age=86400"})
async def api_way_pick(request):
"""POST /anomalous/ai_tasks/{id}/way: {name, on} - a way (做法) the user picks for this task, or takes back."""
from . import ai_panels
body = await _body(request) or {}
try:
step = ai_toolbox.pick_way(request.match_info["task_id"], str(body.get("name") or ""), bool(body.get("on")))
except ai_toolbox.ToolboxError as error:
return web.json_response({"error": str(error)}, status=400)
ai_panels.wake()
return web.json_response({"step": step})
async def api_toolbox_pick(request):
"""POST /anomalous/ai_tasks/{id}/toolbox: {mode, nodes, combos, workflows, prompts, models} - what the user hands the AI."""
from . import ai_panels
body = await _body(request)
try:
step = ai_toolbox.pick(request.match_info["task_id"], body if body is not None else None, (body or {}).get("mode", "prefer"))
except ai_toolbox.ToolboxError as error:
return web.json_response({"error": str(error)}, status=400)
ai_panels.wake()
return web.json_response({"step": step})
async def api_pictures(request):
"""POST /anomalous/ai_tasks/{id}/pictures: {images: ["subfolder/name.png"], text?} - the user hands the AI pictures."""
from . import ai_panels, ai_pictures
body = await _body(request)
try:
step = await asyncio.to_thread(ai_pictures.hand, request.match_info["task_id"], (body or {}).get("images"), (body or {}).get("text") or "")
except ai_pictures.PicturesError as error:
return web.json_response({"error": str(error)}, status=400)
ai_panels.wake()
return web.json_response({"step": step})
async def api_upload(request):
"""POST /anomalous/ai_pictures/upload?name= with a picture's bytes - one the user dropped or pasted
into a message, kept in the output folder (ai_pictures.save_upload): {image, filename, subfolder}."""
from . import ai_pictures
data = await request.read()
try:
image = await asyncio.to_thread(ai_pictures.save_upload, data, request.query.get("name", ""))
except ai_pictures.PicturesError as error:
return web.json_response({"error": str(error)}, status=400)
except OSError as error:
return web.json_response({"error": f"Could not keep the picture: {error}"}, status=500)
subfolder, _, filename = image.rpartition("/")
return web.json_response({"image": image, "filename": filename, "subfolder": subfolder})
async def api_stop(request):
"""POST /anomalous/ai_tasks/{id}/runs/{step}/stop - the user stops a run the AI started."""
from . import ai_panels
try:
answer = await asyncio.to_thread(ai_run_control.stop, request.match_info["step_id"], "user", request.match_info["task_id"])
except ai_run_control.RunControlError as error:
return web.json_response({"error": str(error)}, status=400)
ai_panels.wake()
return web.json_response(answer)
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."""
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"))
except ai_run_control.RunControlError as error:
return web.json_response({"error": str(error)}, status=400)
ai_panels.wake()
return web.json_response({"verdict": verdict})
async def api_toolbox(request):
"""GET /anomalous/ai_toolbox?kind=all: what the picker lists; GET/POST /anomalous/ai_toolbox/favorites."""
if request.path.endswith("/favorites"):
if request.method == "GET":
return web.json_response({"favorites": ai_toolbox.favorites()})
body = await _body(request)
try:
return web.json_response({"favorites": ai_toolbox.set_favorites(body if body is not None else None)})
except ai_toolbox.ToolboxError as error:
return web.json_response({"error": str(error)}, status=400)
try:
return web.json_response(await asyncio.to_thread(ai_toolbox.catalog, request.query.get("kind", "")))
except ai_toolbox.ToolboxError as error:
return web.json_response({"error": str(error)}, status=400)
except Exception as error: # noqa: BLE001 - ComfyUI's node list or a store unreadable: say so to the page
return web.json_response({"error": str(error)}, status=502)
async def api_blocks(request):
"""GET /anomalous/ai_blocks: every ready-made block and skill as the 做法 panel shows them
(reference_blocks.status, ai_skills.status); POST {name, on} or {skill, on}: the user turns one on or off for the AI."""
from . import ai_skills, mcp_canvas_edit, reference_blocks
from .mcp_tools import ToolError
if request.method == "POST":
body = await _body(request) or {}
try:
if "skill" in body:
ai_skills.set_on(str(body.get("skill") or ""), bool(body.get("on")))
else:
reference_blocks.set_on(str(body.get("name") or ""), bool(body.get("on")))
except (reference_blocks.BlockError, ai_skills.SkillError) as error:
return web.json_response({"error": str(error)}, status=400)
try:
info = await asyncio.to_thread(mcp_canvas_edit.object_info, {"base_url": f"{request.scheme}://{request.host}"})
except ToolError as error:
return web.json_response({"error": str(error)}, status=502)
return web.json_response({"blocks": reference_blocks.status(info), "jobs": list(reference_blocks.JOBS),
"skills": ai_skills.status()})
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_get("/anomalous/ai_tasks/{task_id}", api_get)
app.router.add_post("/anomalous/ai_tasks/{task_id}", api_change)
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)
app.router.add_post("/anomalous/ai_tasks/{task_id}/gate", api_gate)
app.router.add_post("/anomalous/ai_tasks/{task_id}/marks", api_marks)
app.router.add_get("/anomalous/ai_tasks/{task_id}/marks/{name}", api_marked)
app.router.add_post("/anomalous/ai_tasks/{task_id}/toolbox", api_toolbox_pick)
app.router.add_post("/anomalous/ai_tasks/{task_id}/way", api_way_pick)
app.router.add_post("/anomalous/ai_tasks/{task_id}/runs/{step_id}/stop", api_stop)
app.router.add_post("/anomalous/ai_tasks/{task_id}/runs/{step_id}/verdict", api_verdict)
app.router.add_post("/anomalous/ai_tasks/{task_id}/pictures", api_pictures)
app.router.add_post("/anomalous/ai_pictures/upload", api_upload)
app.router.add_get("/anomalous/ai_toolbox", api_toolbox)
app.router.add_get("/anomalous/ai_toolbox/favorites", api_toolbox)
app.router.add_post("/anomalous/ai_toolbox/favorites", api_toolbox)
app.router.add_get("/anomalous/ai_blocks", api_blocks)
app.router.add_post("/anomalous/ai_blocks", api_blocks)
app.router.add_get("/anomalous/ai_gates", api_gates)
app.router.add_post("/anomalous/ai_gates", api_gates)
+175
View File
@@ -0,0 +1,175 @@
"""What the user hands the AI to work with ("给 AI 的素材", web/modules/ui_ai_toolbox.js): node
types, whole custom node packs (by folder: every node type in it), combos, workflows, saved
prompts and models picked for a task, and how strictly: prefer
(the AI is told to use them first) or only (add_node and new_workflow refuse other node types,
except ComfyUI's own built-in ones and the helpers that live only in the page). Ways too
(reference_blocks.py, picked in the 做法 view: pick_way): add_nodes builds the picked one for its use. Favourites
(user/anomalous/ai_toolbox.json) are what a task has until the user picks for it. A pick is a
"user_toolbox" step the AI receives like a message (kind toolbox, ai_panels.py); begin_task
names the task's picks and get_toolbox gives them in full. The catalogue the picker shows uses
the same names the AI's tools use: node types from ComfyUI's /object_info (mcp_canvas_edit.py),
the rest from ai_toolbox_catalog.py."""
import json
import os
import threading
import time
import uuid
from . import ai_tasks
from .path_utils import atomic_write_json
KINDS = ("nodes", "packs", "combos", "workflows", "prompts", "models", "ways")
MODES = ("prefer", "only")
MAX_ITEMS = 60
MAX_NAME = 300
_lock = threading.Lock()
class ToolboxError(ValueError):
pass
def _path():
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_toolbox.json")
def clean(picks):
"""{kind: [names]} for KINDS: strings only, each kind once per name, at most MAX_ITEMS."""
if not isinstance(picks, dict):
raise ToolboxError("Expected {nodes, packs, combos, workflows, prompts, models, ways}")
out = {}
for kind in KINDS:
items = picks.get(kind) or []
if not isinstance(items, list) or len(items) > MAX_ITEMS:
raise ToolboxError(f"{kind}: a list of up to {MAX_ITEMS} names")
names = []
for item in items:
if not isinstance(item, str) or not item.strip() or len(item) > MAX_NAME:
raise ToolboxError(f"{kind}: names are text of up to {MAX_NAME} characters")
if item.strip() not in names:
names.append(item.strip())
out[kind] = names
return out
def count(box):
return sum(len((box or {}).get(kind) or []) for kind in KINDS)
def favorites():
try:
with open(_path(), encoding="utf-8") as source:
return clean(json.load(source))
except (OSError, ValueError):
return clean({})
def set_favorites(picks):
picks = clean(picks)
with _lock:
atomic_write_json(_path(), picks)
return picks
def effective(task):
"""The task's picks ({mode, kinds…, "from": task|favorites}), or None when it has none."""
box = (task or {}).get("toolbox")
if box:
return {**box, "from": "task"}
liked = favorites()
return {"mode": "prefer", **liked, "from": "favorites"} if count(liked) else None
def words(box):
"""The picks in a sentence or two for the AI."""
from . import reference_blocks
parts = []
labels = {"nodes": "node types", "packs": "custom node packs (every node type in these folders; get_toolbox lists them)",
"combos": "combos (place_combo)", "workflows": "workflows (open_workflow opens one in a new tab)",
"prompts": "prompts (combo:<name> / workflow:<name> are the prompts inside that combo or workflow)",
"models": "models (model_id for set_model / add_lora / get_model)",
"ways": "ways of working (for that use add_nodes builds the one picked)"}
for kind in KINDS:
if box.get(kind):
names = [reference_blocks.call(name) or name for name in box[kind]] if kind == "ways" else box[kind]
parts.append(f"{labels[kind]}: {', '.join(names)}")
how = ("Use ONLY these node types (and those of the picked packs) when you add nodes; ComfyUI's own built-in nodes stay allowed. "
if box.get("mode") == "only" else "Use these first. ")
return f"The user picked these for you to work with. {how}{'; '.join(parts)}. get_toolbox gives them in full."
def pick(task_id, picks, mode="prefer", now=None):
"""The user's picks for task `task_id`: kept on the task and given to the AI as a step. Returns the step."""
if mode not in MODES:
raise ToolboxError("mode is prefer or only")
picks = clean(picks)
now = time.time() if now is None else now
box = {"mode": mode, **picks}
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
if not task:
raise ToolboxError("No such task")
task["toolbox"] = box
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "user_toolbox", "kind": "user", "ok": True,
"state": "done", "summary": "user_toolbox", "toolbox": box,
"text": words(box) if count(box) else "The user took back the things they picked for you; use what fits.",
"delivered": False, "results": []}
task["steps"].append(step)
del task["steps"][:-ai_tasks.MAX_STEPS]
task["updated"] = max(task.get("updated", 0), now)
ai_tasks._save(task)
ai_tasks._notify(task_id)
return step
def pick_way(task_id, name, on):
"""Way `name` onto the picks of task `task_id` (`on`) or off them, the rest as they stand. Returns the step."""
from . import reference_blocks
if name not in reference_blocks.BLOCKS:
raise ToolboxError("No such way")
box = effective(ai_tasks.get_task(task_id)) or {}
ways = [item for item in box.get("ways") or [] if item != name] + ([name] if on else [])
return pick(task_id, {**box, "ways": ways}, box.get("mode", "prefer"))
def builtin(info):
"""Whether a node type (its /object_info entry) is ComfyUI's own and free: not a custom node, not a paid API node."""
module = str((info or {}).get("python_module") or "")
return module == "nodes" or module.startswith("comfy_extras")
def chosen(box, name, info):
"""Whether node type `name` (its /object_info entry) is among picks `box`: itself, or its pack."""
return bool(box) and (name in (box.get("nodes") or []) or source_of(info) in (box.get("packs") or []))
def refused(task_id, types, info):
"""The node types among `types` the task's "only" picks do not allow (page-only helpers, unknown to /object_info, pass;
so do those of a way picked)."""
from . import reference_blocks
box = effective(ai_tasks.get_task(task_id)) if task_id else None
if not box or box.get("mode") != "only":
return []
ways = reference_blocks.node_types(box.get("ways") or [])
return sorted({name for name in types if name in info and name not in ways and not chosen(box, name, info[name]) and not builtin(info[name])})
def source_of(info):
"""Where a node type comes from: its custom node folder, "builtin", or "api" (paid API nodes)."""
module = str((info or {}).get("python_module") or "")
if module.startswith("custom_nodes."):
return module.split(".")[1]
return "api" if module.startswith("comfy_api_nodes") else "builtin"
def catalog(kind):
"""What the picker lists besides node types (ai_toolbox_catalog.py); the page has those itself."""
from .ai_toolbox_catalog import everything
if kind != "all":
raise ToolboxError("kind is all; node types come from the page")
return everything()
+90
View File
@@ -0,0 +1,90 @@
"""Everything the materials picker (web/modules/ui_ai_toolbox.js) lists besides node types, in
one answer (GET /anomalous/ai_toolbox?kind=all): combos with their cover and prompt, saved
workflows with their cover, tags and prompts, prompts (the saved ones, and the ones inside
combos and workflows, ids "combo:<name>" and "workflow:<name>"), and models with their cover and
what the Models page groups them by (`metadata`: its version page, its base model),
by the names and ids the AI's tools use, and what the ways are called, for a picked way's chip. Read in one
go and kept a few seconds, so the picker opens at once; node types come from the page's own node definitions. prompt_entries() also
gives get_toolbox the text of picked prompts."""
import threading
import time
from . import mcp_tools, reference_blocks
from .model_catalog import _collect_all_scan_models
from .notebooks import _list_notebooks
from .quick_index import compact_models
from .recipe_store import _list_recipes, get_recipes_dir
CACHE_SECONDS = 15
MAX_TEXT = 1500
MAX_THUMBNAIL = 60_000 # an inline thumbnail larger than this is left out; the cover asset or the default stands in
_cache = {"at": 0.0, "data": None}
_lock = threading.Lock()
def _join(texts, limit=MAX_TEXT):
text = "\n".join(str(item).strip() for item in texts or [] if str(item or "").strip())
return text if len(text) <= limit else text[:limit] + "…"
def _combos():
out = []
for note in _list_notebooks():
data = note.get("data") or {}
combo, loras = mcp_tools.combo_view(note)
out.append({"name": combo["name"], "model": combo["main_model"], "loras": loras[:12], "base": combo.get("base_model") or "",
"prompt": combo["prompt"], "structure": data.get("kind") == "nodes",
"cover": (data.get("mainModel") or {}).get("preview_url") or "" if isinstance(data.get("mainModel"), dict) else "",
"image": data.get("source_image") if isinstance(data.get("source_image"), dict) else None})
return out
def _workflows():
out = []
for recipe in _list_recipes(get_recipes_dir()):
data = recipe.get("data") or {}
params = data.get("params") if isinstance(data.get("params"), dict) else {}
thumbnail = data.get("thumbnail") if isinstance(data.get("thumbnail"), str) and len(data["thumbnail"]) <= MAX_THUMBNAIL else None
out.append({"name": data.get("name") or recipe["filename"], "filename": recipe["filename"], "tags": (data.get("tags") or [])[:8],
"notes": str(data.get("notes") or "")[:300], "partial": data.get("workflow_scope") == "partial",
"nodes": len(params.get("nodes") or []), "base": params.get("baseModel") or "",
"prompt": _join(params.get("promptPositive")), "negative": _join(params.get("promptNegative"), 600),
"cover_asset": (data.get("presentation") or {}).get("cover_asset_id"), "thumbnail": thumbnail,
"image": data.get("source_image") if isinstance(data.get("source_image"), dict) else None,
"saved": data.get("timestamp") or 0})
return out
def _models():
out = []
for model in compact_models(_collect_all_scan_models(1, 0).get("models") or []):
place = f"{model['subfolder']}/{model['filename']}" if model["subfolder"] else model["filename"]
out.append({"id": f"{model['type']}:{model['path_idx']}:{place}", "name": model["name"] or model["civitai_name"] or model["filename"],
"file": model["filename"], "type": model["type"], "base": model["base"], "words": model["words"],
"cover": model["preview_url"], "folder": model["subfolder"],
"metadata": {"model_id": model["page"], "version_id": model["version"], "version_name": model["version_name"],
"baseModel": model["base"], "base_guess": model["base_guess"]}})
return out
def prompt_entries(combos, workflows):
"""The prompts the picker lists: saved ones (id = name), then those of combos and workflows."""
entries = [{"id": prompt["name"], "name": prompt["name"], "source": "saved", "tags": prompt["tags"],
"positive": prompt["positive"], "negative": prompt["negative"]} for prompt in mcp_tools.saved_prompts()]
entries += [{"id": f"combo:{combo['name']}", "name": combo["name"], "source": "combo", "tags": [],
"positive": combo["prompt"], "negative": ""} for combo in combos if combo["prompt"]]
entries += [{"id": f"workflow:{flow['name']}", "name": flow["name"], "source": "workflow", "tags": flow["tags"],
"positive": flow["prompt"], "negative": flow["negative"]} for flow in workflows if flow["prompt"] or flow["negative"]]
return entries
def everything(fresh=False):
"""{combos, workflows, prompts, models, ways}, read at most every CACHE_SECONDS."""
with _lock:
if fresh or _cache["data"] is None or time.monotonic() - _cache["at"] > CACHE_SECONDS:
combos, workflows = _combos(), _workflows()
_cache.update(at=time.monotonic(), data={"combos": combos, "workflows": workflows,
"ways": {name: block["title"] for name, block in reference_blocks.BLOCKS.items()},
"prompts": prompt_entries(combos, workflows), "models": _models()})
return _cache["data"]
+593
View File
@@ -0,0 +1,593 @@
"""Backups of the user's own data (Settings -> Backup): one .zip to keep anywhere (a cloud
drive, a USB stick), and putting it back on this or another computer.
A backup holds the plugin's stores (Workflow Recipes, combos, materials and saved prompts,
parameter sets), optionally ComfyUI's own saved workflows, the plugin settings without the
Civitai key, what the user set on models: <model>.anomalous.json (name, notes, link) and
the user's cover (any cover that is not a copy of Civitai's <model>.civitai_bak.*), what a
scan found (<model>.info; Civitai's cover only when asked, it can be hundreds of MB), and the
model list (each model's folder, path, size, SHA-256 and link) so the models a new computer
lacks can be downloaded again. Models are found again by SHA-256, else by models folder, path
and size, so they match after a move.
Putting back never erases: a file missing here is added; a file that differs stays unless
"replace" is chosen, and then the one here goes to the Recycle Bin first. A model's user file
is merged field by field (empty fields are filled; "replace" takes the backup's values), and
the backup's cover takes the place of a Civitai cover (whose copy stays as civitai_bak).
"""
import asyncio
import hashlib
import json
import os
import shutil
import tempfile
import time
import uuid
import zipfile
import folder_paths
from aiohttp import web
from .activity_log import add_entry
from .folder_types import config_path
from .material_store import get_materials_dir
from .metadata import USER_FIELDS, clear_metadata_cache, get_metadata
from .model_constants import CIVITAI_BACKUP_SUFFIXES, MEDIA_EXTENSIONS, PREVIEW_SUFFIXES, RESOLVABLE_MODEL_TYPES, is_model_file
from .model_download import settings_path as download_settings_path
from .notebooks import get_notebooks_dir
from .parameters import _invalidate_parameter_notebooks_cache, get_parameters_dir
from .workflow_saves import saves_dir
from .path_utils import atomic_write_json, require_filename, resolve_within
from .recipe_store import get_recipes_dir
from .trash import TrashUnavailable, move_to_trash, trash_failure
from . import version_manager
try:
from ..model_identity import USER_INFO_SUFFIX, read_json, scan_info_path
from .. import model_sidecars as sidecars
except ImportError:
from model_identity import USER_INFO_SUFFIX, read_json, scan_info_path
import model_sidecars as sidecars
FORMAT = 1
APP = "Anomalous Model Browser"
ACTIVE_COVER_SUFFIXES = PREVIEW_SUFFIXES + MEDIA_EXTENSIONS # as scraper.py: <model>.preview.png or <model>.png
SECRET_KEYS = {"CIVITAI_API_KEY", "DEEPL_API_KEY"} # never leave this computer
SCAN_INFO_SUFFIXES = (".info", ".civitai.info")
SKIP_FILES = {".legacy_imported.json"} # this computer's own migration marker
MAX_BACKUP_BYTES = 4 * 1024 ** 3 # unpacked size of a backup that is still read
MAX_MEMBERS = 200_000
KEEP_EXPORTS = 3 # finished exports kept in the temp folder for downloading
CHUNK = 1024 * 1024
PART_ORDER = ("recipes", "combos", "materials", "parameters", "saves", "comfy_workflows")
def _comfy_workflows_dir():
return os.path.join(folder_paths.get_user_directory(), "default", "workflows")
def part_dirs():
"""Each kept store and where it lives on this computer."""
return {
"recipes": get_recipes_dir(),
"combos": get_notebooks_dir(),
"materials": get_materials_dir(),
"parameters": get_parameters_dir(),
"saves": saves_dir(),
"comfy_workflows": _comfy_workflows_dir(),
}
def work_dir():
path = os.path.join(folder_paths.get_temp_directory(), "anomalous_backups")
os.makedirs(path, exist_ok=True)
return path
def _sha256_file(path):
digest = hashlib.sha256()
with open(path, "rb") as handle:
for block in iter(lambda: handle.read(CHUNK), b""):
digest.update(block)
return digest.hexdigest()
def _store_files(directory):
"""(path, '/'-relative path) of every file kept in a store."""
if not os.path.isdir(directory):
return
for root, dirs, files in os.walk(directory):
dirs[:] = sorted(d for d in dirs if d != "__pycache__")
for name in sorted(files):
if name in SKIP_FILES or name.endswith(".tmp"):
continue
path = os.path.join(root, name)
yield path, os.path.relpath(path, directory).replace(os.sep, "/")
def _model_roots():
"""(models folder type, folder) once per folder: clip and text_encoders often share one."""
seen = set()
for folder_type in RESOLVABLE_MODEL_TYPES:
try:
roots = folder_paths.get_folder_paths(folder_type) or []
except Exception: # noqa: BLE001 - a type this ComfyUI does not know
continue
for root in roots:
key = os.path.normcase(os.path.abspath(root))
if key in seen or not os.path.isdir(root):
continue
seen.add(key)
yield folder_type, root
def _models():
"""(type, root, path, '/'-relative path) of every model file."""
for folder_type, root in _model_roots():
for walk_root, dirs, files in os.walk(root):
dirs.sort()
for name in sorted(files):
if is_model_file(name):
path = os.path.join(walk_root, name)
yield folder_type, root, path, os.path.relpath(path, root).replace(os.sep, "/")
def _model_facts(path):
"""(SHA-256, download page) from the model's sidecars; nothing is computed."""
try:
meta = get_metadata(path) or {}
except Exception: # noqa: BLE001 - an unreadable sidecar only means "match by path"
return "", ""
return str(meta.get("hash") or "").lower(), str(meta.get("source_url") or meta.get("civitai_url") or "")
def _model_hash(path):
return _model_facts(path)[0]
def _settings_for_backup():
data = read_json(config_path()) or {}
return {key: value for key, value in data.items() if key not in SECRET_KEYS}
def _version():
try:
return version_manager.current_state().get("label") or ""
except Exception: # noqa: BLE001 - a copy that is no git checkout
return ""
# ---------- export ----------
def build_backup(include_models=True, include_comfy=True, include_library=True, include_scan=True, include_civitai_covers=False):
"""Writes the backup .zip into the temp folder: (path, manifest)."""
stamp = time.strftime("%Y%m%d-%H%M%S")
path = os.path.join(work_dir(), f"AMB-backup-{stamp}.zip")
manifest = {"format": FORMAT, "app": APP, "version": _version(), "created": time.time(), "parts": {}, "settings": False, "models": []}
parts = [part for part in PART_ORDER if include_comfy or part != "comfy_workflows"]
dirs = part_dirs()
temporary = path + ".tmp"
try:
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
for part in parts:
count = 0
for file_path, rel in _store_files(dirs[part]):
archive.write(file_path, f"stores/{part}/{rel}")
count += 1
manifest["parts"][part] = count
archive.writestr("settings/config.json", json.dumps(_settings_for_backup(), ensure_ascii=False))
if os.path.isfile(download_settings_path()):
archive.write(download_settings_path(), "settings/download_settings.json")
manifest["settings"] = True
if include_library:
manifest["library"] = []
if include_models or include_library:
for folder_type, _root, model_path, rel in _models():
if include_library:
digest, page = _model_facts(model_path)
manifest["library"].append({"type": folder_type, "rel": rel, "size": os.path.getsize(model_path),
"sha256": digest, "url": page})
if not include_models:
continue
base = os.path.splitext(model_path)[0]
user_file = sidecars.find(base, USER_INFO_SUFFIX)
user_data = read_json(user_file) if user_file else None
civitai, covers = sidecars.cover_owners(base)
scan_info = scan_info_path(base) if include_scan else None
backups = sidecars.backups(base)
civitai_cover = civitai[0] if include_civitai_covers and civitai else None
if not user_data and not covers and not scan_info and not civitai_cover:
continue
entry = {"id": len(manifest["models"]), "type": folder_type, "rel": rel,
"size": os.path.getsize(model_path), "sha256": _model_hash(model_path), "user": bool(user_data), "cover": ""}
folder = f"models/{entry['id']}/"
if user_data:
archive.writestr(folder + "user.json", json.dumps(user_data, ensure_ascii=False))
if covers:
entry["cover"] = covers[0][len(base):]
archive.write(covers[0], f"{folder}cover{entry['cover']}")
if scan_info:
entry["info"] = sidecars.suffix_of(scan_info, base)
archive.write(scan_info, f"{folder}scan{entry['info']}")
if civitai_cover:
# One copy: it is both the cover and Civitai's picture (kept aside or not).
picture = backups[0] if backups else civitai_cover
entry["civitai_cover"] = civitai_cover[len(base):]
entry["civitai_bak"] = sidecars.suffix_of(backups[0], base) if backups else ".civitai_bak" + os.path.splitext(civitai_cover)[1]
archive.write(picture, f"{folder}civitai{entry['civitai_bak']}")
manifest["models"].append(entry)
archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=1))
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.remove(temporary)
_prune_exports()
return path, manifest
def _prune_exports():
"""Keeps the newest finished exports; older ones are this plugin's own temp files."""
exports = sorted((name for name in os.listdir(work_dir()) if name.startswith("AMB-backup-") and name.endswith(".zip")), reverse=True)
for name in exports[KEEP_EXPORTS:]:
try:
os.remove(os.path.join(work_dir(), name))
except OSError:
pass
# ---------- reading a backup ----------
class BackupError(ValueError):
"""A file that is no backup of this plugin, or one that cannot be read safely."""
def _safe_rel(rel):
parts = rel.split("/")
if not rel or rel.startswith("/") or "\\" in rel or ":" in rel or any(part in ("", ".", "..") for part in parts):
raise BackupError(f"unsafe path in the backup: {rel}")
return parts
def open_backup(path):
"""The zip and its manifest, after checking it is a backup and safe to unpack."""
try:
archive = zipfile.ZipFile(path)
except (zipfile.BadZipFile, OSError) as error:
raise BackupError("not a zip file") from error
try:
infos = archive.infolist()
if len(infos) > MAX_MEMBERS or sum(info.file_size for info in infos) > MAX_BACKUP_BYTES:
raise BackupError("the backup is too large")
try:
manifest = json.loads(archive.read("manifest.json"))
except (KeyError, ValueError) as error:
raise BackupError("no manifest: not a backup of this plugin") from error
if not isinstance(manifest, dict) or manifest.get("app") != APP or manifest.get("format") != FORMAT:
raise BackupError("not a backup of this plugin, or one from a newer version")
for info in infos:
if not info.is_dir():
_safe_rel(info.filename)
return archive, manifest
except Exception:
archive.close()
raise
def _store_members(archive):
"""(part, '/'-relative path, ZipInfo) of each kept store file in the backup."""
for info in archive.infolist():
name = info.filename
if info.is_dir() or not name.startswith("stores/"):
continue
bits = name.split("/", 2)
if len(bits) == 3 and bits[1] in PART_ORDER:
_, part, rel = bits
_safe_rel(rel)
yield part, rel, info
def _member_sha256(archive, info):
digest = hashlib.sha256()
with archive.open(info) as handle:
for block in iter(lambda: handle.read(CHUNK), b""):
digest.update(block)
return digest.hexdigest()
def _file_state(archive, info, target):
if not os.path.isfile(target):
return "new"
if os.path.getsize(target) == info.file_size and _sha256_file(target) == _member_sha256(archive, info):
return "same"
return "differs"
def _local_models():
"""This computer's models: by SHA-256 and by (type, path) -> (path, size)."""
by_hash, by_rel = {}, {}
for folder_type, _root, path, rel in _models():
size = os.path.getsize(path)
digest = _model_hash(path)
if digest:
by_hash.setdefault(digest, path)
by_rel[(folder_type, rel.lower())] = (path, size)
return by_hash, by_rel
def _match(entry, by_hash, by_rel):
digest = str(entry.get("sha256") or "").lower()
if digest and digest in by_hash:
return by_hash[digest]
found = by_rel.get((entry.get("type"), str(entry.get("rel") or "").lower()))
return found[0] if found and found[1] == entry.get("size") else None
def inspect_backup(path):
"""What putting the backup back would do, before anything is written."""
archive, manifest = open_backup(path)
with archive:
dirs = part_dirs()
parts = {}
for part, rel, info in _store_members(archive):
counts = parts.setdefault(part, {"new": 0, "same": 0, "differs": 0})
counts[_file_state(archive, info, resolve_within(dirs[part], *rel.split("/")))] += 1
models = {"total": len(manifest.get("models") or []), "matched": 0, "missing": []}
library = manifest.get("library") if isinstance(manifest.get("library"), list) else None
summary = {"created": manifest.get("created"), "version": manifest.get("version") or "",
"parts": {part: parts[part] for part in PART_ORDER if part in parts},
"settings": bool(manifest.get("settings")), "models": models}
if models["total"] or library:
by_hash, by_rel = _local_models()
for entry in manifest.get("models") or []:
if _match(entry, by_hash, by_rel):
models["matched"] += 1
else:
models["missing"].append(entry.get("rel") or "")
if library is not None:
# The models the old computer had and this one lacks: what can be downloaded again
# (two copies of one file there are one download here).
missing, seen = [], set()
for entry in library:
if not isinstance(entry, dict) or _match(entry, by_hash, by_rel):
continue
digest = str(entry.get("sha256") or "").lower()
if digest and digest in seen:
continue
seen.add(digest)
missing.append({key: entry.get(key) for key in ("type", "rel", "size", "sha256", "url")})
summary["library"] = {"total": len(library), "missing": missing}
return summary
# ---------- putting back ----------
def _write_member(archive, info, target):
os.makedirs(os.path.dirname(target), exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".restore-", suffix=".tmp", dir=os.path.dirname(target))
try:
with os.fdopen(fd, "wb") as output, archive.open(info) as source:
shutil.copyfileobj(source, output, CHUNK)
os.replace(temporary, target)
finally:
if os.path.exists(temporary):
os.remove(temporary)
def _restore_stores(archive, wanted, replace, result):
dirs = part_dirs()
for part, rel, info in _store_members(archive):
if part not in wanted:
continue
target = resolve_within(dirs[part], *rel.split("/"))
state = _file_state(archive, info, target)
if state == "same" or (state == "differs" and not replace):
result["skipped"] += 1
continue
if state == "differs":
try:
move_to_trash(target)
except (TrashUnavailable, OSError) as error:
result["failed"].append(f"{part}/{rel}: {trash_failure(error)}")
continue
result["replaced"] += 1
else:
result["added"] += 1
_write_member(archive, info, target)
def _restore_scan(archive, entry, base, folder, replace, result):
"""What a scan found: the scan file where there is none (or replaced on request), and
Civitai's cover where the model has no cover at all."""
suffix = str(entry.get("info") or "")
if suffix:
if suffix not in SCAN_INFO_SUFFIXES:
raise BackupError(f"unknown scan file name: {suffix}")
info = archive.getinfo(folder + "scan" + suffix)
local = scan_info_path(base)
if not local or (replace and _sha256_file(local) != _member_sha256(archive, info)):
if local:
move_to_trash(local)
_write_member(archive, info, sidecars.target(base, suffix))
result["model_infos"] += 1
cover, saved = str(entry.get("civitai_cover") or ""), str(entry.get("civitai_bak") or "")
if cover and saved:
if cover not in ACTIVE_COVER_SUFFIXES or saved not in CIVITAI_BACKUP_SUFFIXES:
raise BackupError(f"unknown cover name: {cover}")
info = archive.getinfo(folder + "civitai" + saved)
civitai, mine = sidecars.cover_owners(base)
if not civitai and not mine and not entry.get("cover"):
# Civitai's picture becomes the cover, noted as Civitai's (no second copy aside).
_write_member(archive, info, base + cover)
if not sidecars.note_cover_hash(base, base + cover) and not sidecars.backups(base):
_write_member(archive, info, sidecars.target(base, saved))
result["model_civitai_covers"] += 1
elif mine and not sidecars.backups(base):
_write_member(archive, info, sidecars.target(base, saved)) # aside, while the user's cover shows
def _restore_model(archive, entry, model_path, replace, result):
base = os.path.splitext(model_path)[0]
folder = f"models/{int(entry['id'])}/"
_restore_scan(archive, entry, base, folder, replace, result)
if entry.get("user"):
backup = json.loads(archive.read(folder + "user.json"))
user_file = sidecars.target(base, USER_INFO_SUFFIX)
local = read_json(user_file) if os.path.isfile(user_file) else None
merged = dict(local or {"format": 1})
for key in USER_FIELDS:
value = str(backup.get(key) or "").strip()
if value and (replace or not str(merged.get(key) or "").strip()):
merged[key] = backup[key]
if merged != (local or {"format": 1}):
if local and replace:
move_to_trash(user_file) # the old values stay recoverable
atomic_write_json(user_file, merged)
result["model_notes"] += 1
suffix = str(entry.get("cover") or "")
if suffix:
if suffix not in ACTIVE_COVER_SUFFIXES:
raise BackupError(f"unknown cover name: {suffix}")
info = archive.getinfo(folder + "cover" + suffix)
civitai, mine = sidecars.cover_owners(base)
member_hash = _member_sha256(archive, info)
if any(_sha256_file(cover) == member_hash for cover in mine):
return
if mine and not replace:
result["covers_kept"] += 1
return
if civitai and not sidecars.backups(base):
sidecars.keep_aside(base, civitai.pop(0)) # Civitai's picture stays, to bring back
for cover in mine + civitai: # Civitai's own copy stays as <model>.civitai_bak.*
move_to_trash(cover)
_write_member(archive, info, base + suffix)
result["model_covers"] += 1
def _restore_settings(archive, result):
names = set(archive.namelist())
if "settings/config.json" in names:
backup = json.loads(archive.read("settings/config.json"))
local = read_json(config_path()) or {}
merged = {**{key: value for key, value in backup.items() if key not in SECRET_KEYS},
**{key: local[key] for key in SECRET_KEYS if key in local}}
atomic_write_json(config_path(), merged)
result["settings"] = True
if "settings/download_settings.json" in names:
os.makedirs(os.path.dirname(download_settings_path()), exist_ok=True)
atomic_write_json(download_settings_path(), json.loads(archive.read("settings/download_settings.json")))
def apply_backup(path, parts=PART_ORDER, models=True, settings=False, replace=False):
"""Puts the backup back as chosen; returns the counts for the user and the activity log."""
archive, manifest = open_backup(path)
result = {"added": 0, "replaced": 0, "skipped": 0, "model_notes": 0, "model_covers": 0, "model_infos": 0,
"model_civitai_covers": 0, "covers_kept": 0, "models_missing": 0, "settings": False, "failed": []}
with archive:
_restore_stores(archive, set(parts) & set(PART_ORDER), replace, result)
if models and manifest.get("models"):
by_hash, by_rel = _local_models()
for entry in manifest["models"]:
model_path = _match(entry, by_hash, by_rel)
if not model_path:
result["models_missing"] += 1
continue
try:
_restore_model(archive, entry, model_path, replace, result)
except (TrashUnavailable, OSError, KeyError, ValueError) as error:
result["failed"].append(f"{entry.get('rel')}: {trash_failure(error) if isinstance(error, (TrashUnavailable, OSError)) else error}")
clear_metadata_cache()
if settings and manifest.get("settings"):
_restore_settings(archive, result)
_invalidate_parameter_notebooks_cache()
return result
# ---------- routes ----------
def _error(message, status=400):
return web.json_response({"status": "error", "error": str(message)}, status=status)
async def api_export(request):
try:
body = await request.json()
except Exception: # noqa: BLE001 - no body: the defaults
body = {}
path, manifest = await asyncio.to_thread(build_backup, body.get("models", True) is not False,
body.get("comfy_workflows", True) is not False, body.get("library", True) is not False,
body.get("scan", True) is not False, body.get("civitai_covers") is True)
counts = {**manifest["parts"], "models": len(manifest["models"]), "library": len(manifest.get("library") or [])}
await asyncio.to_thread(add_entry, "file", "backup_export", os.path.basename(path), {"backup": counts})
return web.json_response({"status": "success", "name": os.path.basename(path), "size": os.path.getsize(path), "counts": counts})
async def api_file(request):
try:
name = require_filename(request.query.get("name", ""))
if not name.startswith("AMB-backup-") or not name.endswith(".zip"):
raise ValueError("not a backup")
path = resolve_within(work_dir(), name)
except ValueError as error:
return _error(error)
if not os.path.isfile(path):
return _error("This backup is gone; export it again.", 404)
return web.FileResponse(path, headers={"Content-Disposition": f'attachment; filename="{name}"'})
async def api_inspect(request):
"""Receives the chosen .zip, keeps it in the temp folder and says what putting it back would do."""
try:
reader = await request.multipart()
field = await reader.next()
except (AssertionError, ValueError): # not a file upload at all
return _error("no file")
if field is None or field.name != "file":
return _error("no file")
token = uuid.uuid4().hex
path = os.path.join(work_dir(), f"incoming-{token}.zip")
size = 0
with open(path, "wb") as output:
while chunk := await field.read_chunk(CHUNK):
size += len(chunk)
if size > MAX_BACKUP_BYTES:
output.close()
os.remove(path)
return _error("the backup is too large")
output.write(chunk)
try:
summary = await asyncio.to_thread(inspect_backup, path)
except BackupError as error:
os.remove(path)
return _error(error)
return web.json_response({"status": "success", "token": token, **summary})
async def api_apply(request):
try:
body = await request.json()
token = str(body.get("token") or "")
if not token.isalnum():
raise ValueError("bad token")
path = resolve_within(work_dir(), f"incoming-{token}.zip")
except Exception as error: # noqa: BLE001
return _error(error)
if not os.path.isfile(path):
return _error("This backup is gone; choose the file again.", 404)
parts = [part for part in body.get("parts") or [] if part in PART_ORDER]
try:
result = await asyncio.to_thread(apply_backup, path, parts, body.get("models") is not False,
body.get("settings") is True, body.get("replace") is True)
except BackupError as error:
return _error(error)
os.remove(path)
await asyncio.to_thread(add_entry, "file", "backup_import", str(body.get("name") or ""), {"backup": {**result, "failed": result["failed"][:20]}})
return web.json_response({"status": "success", **result})
def register_routes(app):
app.router.add_post("/anomalous/backup/export", api_export)
app.router.add_get("/anomalous/backup/file", api_file)
app.router.add_post("/anomalous/backup/inspect", api_inspect)
app.router.add_post("/anomalous/backup/apply", api_apply)
+300
View File
@@ -0,0 +1,300 @@
{
"2x_animesharp_v4": {
"title": "2x-AnimeSharpV4 (RCAN)",
"file": "2x-AnimeSharpV4_RCAN.safetensors",
"folder": "upscale_models",
"subfolder": "",
"size": 31053198,
"sha256": "6470bb91d6622d6acdff81132c1a8615b961b919ce2b9a01ce993378500cfbe1",
"license": "CC-BY-NC-SA-4.0",
"sources": [
{
"site": "github",
"url": "https://github.com/Kim2091/Kim2091-Models/releases/download/2x-AnimeSharpV4/2x-AnimeSharpV4_RCAN.safetensors",
"page": "https://openmodeldb.info/models/2x-AnimeSharpV4"
}
]
},
"4x_ultrasharp": {
"title": "4x-UltraSharp",
"file": "4x-UltraSharp.pth",
"folder": "upscale_models",
"subfolder": "",
"size": 66961958,
"sha256": "a5812231fc936b42af08a5edba784195495d303d5b3248c24489ef0c4021fe01",
"license": "CC-BY-NC-SA-4.0",
"sources": [
{
"site": "huggingface",
"url": "https://huggingface.co/Kim2091/UltraSharp/resolve/main/4x-UltraSharp.pth",
"page": "https://huggingface.co/Kim2091/UltraSharp"
},
{
"site": "civitai",
"url": "https://civitai.com/api/download/models/125843?fileId=90900",
"page": "https://civitai.com/models/116225"
}
]
},
"4x_animesharp": {
"title": "4x-AnimeSharp",
"file": "4x-AnimeSharp.pth",
"folder": "upscale_models",
"subfolder": "",
"size": 67010245,
"sha256": "e7a7de2dafd7331c1992862bbbcd9e9712a9f9f8e6303f0aaa59b4341d359bab",
"license": "CC-BY-NC-SA-4.0",
"sources": [
{
"site": "huggingface",
"url": "https://huggingface.co/Kim2091/AnimeSharp/resolve/main/4x-AnimeSharp.pth",
"page": "https://huggingface.co/Kim2091/AnimeSharp"
}
]
},
"face_yolov8m": {
"title": "face_yolov8m (face detector)",
"file": "face_yolov8m.pt",
"folder": "ultralytics_bbox",
"subfolder": "",
"size": 52026019,
"sha256": "717923c19b3f4bbf5250b728f1fa6b2cb72a33aed1d236ea9caf0e21ad943e5f",
"license": "Apache-2.0",
"sources": [
{
"site": "huggingface",
"url": "https://huggingface.co/Bingsu/adetailer/resolve/main/face_yolov8m.pt",
"page": "https://huggingface.co/Bingsu/adetailer"
}
]
},
"hand_yolov8s": {
"title": "hand_yolov8s (hand detector)",
"file": "hand_yolov8s.pt",
"folder": "ultralytics_bbox",
"subfolder": "",
"size": 22507643,
"sha256": "70b540063fbc385736d8258970744a4afbc4cbf7932134bae3b24cdadeadec06",
"license": "Apache-2.0",
"sources": [
{
"site": "huggingface",
"url": "https://huggingface.co/Bingsu/adetailer/resolve/main/hand_yolov8s.pt",
"page": "https://huggingface.co/Bingsu/adetailer"
}
]
},
"sam3_1": {
"title": "SAM 3.1 (finds things by words)",
"file": "sam3.1_multiplex_fp16.safetensors",
"folder": "checkpoints",
"subfolder": "SAM3",
"size": 1745546848,
"sha256": "9ba99c92703c2e8b4f47de2d34a539bb8e18923049e238b780d70dbe6368eb03",
"license": "SAM License",
"sources": [
{
"site": "huggingface",
"url": "https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors",
"page": "https://huggingface.co/Comfy-Org/sam3.1"
}
]
},
"union_promax_sdxl": {
"title": "xinsir ControlNet Union ProMax (SDXL)",
"file": "xinsir-controlnet-union-sdxl-promax.safetensors",
"folder": "controlnet",
"subfolder": "SDXL_1.0",
"size": 2513342408,
"sha256": "9fae2e50cb431bfcbe05822b59ec2228df545ef27f711dea8949e9f4ed9f7cdc",
"license": "Apache-2.0",
"base": "SDXL 1.0",
"sources": [
{
"site": "huggingface",
"url": "https://huggingface.co/xinsir/controlnet-union-sdxl-1.0/resolve/main/diffusion_pytorch_model_promax.safetensors",
"page": "https://huggingface.co/xinsir/controlnet-union-sdxl-1.0"
},
{
"site": "civitai",
"url": "https://civitai.com/api/download/models/655836?fileId=570909",
"page": "https://civitai.com/models/136070"
}
]
},
"noob_tile": {
"title": "NoobAI XL tile ControlNet (Eugeoter)",
"file": "noob-sdxl-controlnet-tile.fp16.safetensors",
"folder": "controlnet",
"subfolder": "NoobAI",
"size": 2502139136,
"sha256": "d6d676f29153357ac8d57727e35b8edc090aa4de57e861b878872ff45c432615",
"license": "see the model page",
"base": "NoobAI",
"sources": [
{
"site": "huggingface",
"url": "https://huggingface.co/Eugeoter/noob-sdxl-controlnet-tile/resolve/main/diffusion_pytorch_model.fp16.safetensors",
"page": "https://huggingface.co/Eugeoter/noob-sdxl-controlnet-tile"
},
{
"site": "civitai",
"url": "https://civitai.com/api/download/models/1290048?fileId=1194650",
"page": "https://civitai.com/models/136070"
}
]
},
"xinsir_depth_sdxl": {
"title": "xinsir ControlNet depth (SDXL)",
"file": "controlnetxlCNXL_xinsirDepth.safetensors",
"folder": "controlnet",
"subfolder": "SDXL_1.0",
"size": 2502139104,
"sha256": "ea3770af4c33ea8ba8dccc0106ec5d5c5c6e504eb382119a3ccac3c9d973d344",
"license": "Apache-2.0",
"base": "SDXL 1.0",
"sources": [
{
"site": "civitai",
"url": "https://civitai.com/api/download/models/657836?fileId=572833",
"page": "https://civitai.com/models/136070"
}
]
},
"noob_openpose": {
"title": "NoobAI XL ControlNet openpose",
"file": "noobaiXLControlnet_openposeModel.safetensors",
"folder": "controlnet",
"subfolder": "NoobAI",
"size": 2502140008,
"sha256": "918cfc4d7def165ea7a06bee10841fe525332515f8a0486c5cb2a171ec40b873",
"license": "see the model page",
"base": "NoobAI",
"sources": [
{
"site": "civitai",
"url": "https://civitai.com/api/download/models/1077649?fileId=982755",
"page": "https://civitai.com/models/962537"
}
]
},
"thibauld_openpose_sdxl": {
"title": "thibauld openpose LoRA ControlNet (SDXL)",
"file": "controlnetxlCNXL_thibauldOpenposeLora.safetensors",
"folder": "controlnet",
"subfolder": "SDXL_1.0",
"size": 774423024,
"sha256": "8afa079285bf9384eaf8f6322884cb4f24bbe405da490f91f5540d3bff585e75",
"license": "see the model page",
"base": "SDXL 1.0",
"sources": [
{
"site": "civitai",
"url": "https://civitai.com/api/download/models/255155?fileId=197900",
"page": "https://civitai.com/models/136070"
}
]
},
"anima_lllite_depth": {
"title": "anima-lllite-depth-1",
"file": "anima-lllite-depth-1.safetensors",
"folder": "controlnet",
"subfolder": "",
"size": 7864128,
"sha256": "81357e51cd59874b4fbdda1eb3117aa73eafe36cc7efb9cf80a152d6bc8d0602",
"license": "CircleStone Labs Non-Commercial License v1.0",
"base": "Anima",
"sources": [
{
"site": "huggingface",
"url": "https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-depth-1.safetensors",
"page": "https://huggingface.co/kohya-ss/Anima-LLLite"
}
]
},
"anima_lllite_pose": {
"title": "anima-lllite-pose-1",
"file": "anima-lllite-pose-1.safetensors",
"folder": "controlnet",
"subfolder": "",
"size": 23310040,
"sha256": "ddb543be5e74ce8ca79ae45807aa0c1328a4888e00706727c6fd7100dde7c864",
"license": "CircleStone Labs Non-Commercial License v1.0",
"base": "Anima",
"sources": [
{
"site": "huggingface",
"url": "https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-pose-1.safetensors",
"page": "https://huggingface.co/kohya-ss/Anima-LLLite"
}
]
},
"anima_lllite_inpainting": {
"title": "anima-lllite-inpainting-v2",
"file": "anima-lllite-inpainting-v2.safetensors",
"folder": "controlnet",
"subfolder": "",
"size": 65772872,
"sha256": "5242e677d2be34ee70ca7c97c3b14ff5ee49838c03fc1e60ac4852a180db6ef5",
"license": "CircleStone Labs Non-Commercial License v1.0",
"base": "Anima",
"sources": [
{
"site": "huggingface",
"url": "https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-inpainting-v2.safetensors",
"page": "https://huggingface.co/kohya-ss/Anima-LLLite"
}
]
},
"anima_lllite_lineart": {
"title": "anima-lllite-lineart-1",
"file": "anima-lllite-lineart-1.safetensors",
"folder": "controlnet",
"subfolder": "",
"size": 7787888,
"sha256": "1abfde93493445390632788339627c37c69c7792aef1e8104cf15ea5f2d379e9",
"license": "CircleStone Labs Non-Commercial License v1.0",
"base": "Anima",
"sources": [
{
"site": "huggingface",
"url": "https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-lineart-1.safetensors",
"page": "https://huggingface.co/kohya-ss/Anima-LLLite"
}
]
},
"anima_tile_repair": {
"title": "Anima Tile & Repair ControlNet-LLLite v2",
"file": "animaTileRepair_v20.safetensors",
"folder": "controlnet",
"subfolder": "",
"size": 94037744,
"sha256": "be5a9ead6527329237a66a0eb234ccea4cddb140c1d1e1b7d0776e43f5ff1330",
"license": "Civitai: credit not needed, derivatives allowed",
"base": "Anima",
"sources": [
{
"site": "civitai",
"url": "https://civitai.com/api/download/models/3068951?fileId=2947687",
"page": "https://civitai.com/models/2708551"
}
]
},
"anima_turbo_lora": {
"title": "anima-turbo-lora-v0.2 (Anima, fewer steps)",
"file": "anima-turbo-lora-v0.2.safetensors",
"folder": "loras",
"subfolder": "",
"size": 148902616,
"sha256": "1b55e40bdb1d0e5a78cb498f245fccfdaae97823265db957d2aabdcf4cd3caf1",
"license": "CircleStone Labs Non-Commercial License v1.0",
"base": "Anima",
"sources": [
{
"site": "huggingface",
"url": "https://huggingface.co/circlestone-labs/Anima-Official-LoRAs/resolve/main/anima-turbo-lora-v0.2.safetensors",
"page": "https://huggingface.co/circlestone-labs/Anima-Official-LoRAs"
}
]
}
}
+125
View File
@@ -0,0 +1,125 @@
{
"job": "new_background",
"rank": 4,
"short": "new background",
"title": {
"zh": "换场景·人物不变",
"en": "New scene, same character"
},
"about": {
"zh": "自动抠出人物,只按新场景重画背景。",
"en": "Cuts the character out and redraws only the background as the new scene."
},
"source": {
"zh": "常见的“抠图换背景”做法:inspyrenet-rembg 抠人物,反选成背景,用 ComfyUI 自带的局部重绘节点只重画背景",
"en": "The common cut-out-and-replace-the-background way: inspyrenet-rembg finds the character, its inverse is the background, ComfyUI's own inpainting nodes redraw only that"
},
"packs": {
"InspyrenetRembg": "comfyui-inspyrenet-rembg"
},
"takes": "picture",
"nodes": {
"picture": {
"class_type": "LoadImage",
"inputs": {
"image": "$image"
}
},
"cut": {
"class_type": "InspyrenetRembg",
"inputs": {
"image": [
"picture",
0
],
"torchscript_jit": "default"
}
},
"background": {
"class_type": "InvertMask",
"inputs": {
"mask": [
"cut",
1
]
}
},
"edge": {
"class_type": "GrowMask",
"inputs": {
"mask": [
"background",
0
],
"expand": 6,
"tapered_corners": true
}
},
"soft": {
"class_type": "DifferentialDiffusion",
"inputs": {
"model": [
"$sampler.model",
"*"
]
},
"wire_to": "$sampler"
},
"encode": {
"class_type": "VAEEncode",
"inputs": {
"pixels": [
"picture",
0
],
"vae": [
"$decode.vae",
"*"
]
}
},
"area": {
"class_type": "SetLatentNoiseMask",
"inputs": {
"samples": [
"encode",
0
],
"mask": [
"edge",
0
]
},
"wire_to": "$sampler"
},
"look": {
"class_type": "PreviewImage",
"inputs": {
"images": [
"cut",
0
]
}
}
},
"roles": {
"sampler": {
"denoise": 1.0
}
},
"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.",
"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": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"date": "2026-10-28",
"runs": [
{
"model": "SDXL (Illustrious)",
"size": "832×1216",
"seconds": 30
}
]
}
}
+155
View File
@@ -0,0 +1,155 @@
{
"job": "follow",
"want": "lines",
"rank": 4,
"short": "colour lines",
"title": {
"zh": "线稿上色",
"en": "Colour a line drawing"
},
"about": {
"zh": "给黑白线稿上色,颜色和画风写在提示词里。",
"en": "Colours a line drawing; the prompt says the colours and style."
},
"source": {
"zh": "Union 控制网的线稿模式(参考工作流作者的控制网组用的就是 Union)",
"en": "The union ControlNet's lineart mode (the reference workflow's ControlNet group uses a union model)"
},
"packs": {},
"takes": "reference",
"control": "lineart",
"nodes": {
"reference": {
"class_type": "LoadImage",
"inputs": {
"image": "$image"
}
},
"lines": {
"class_type": "ImageInvert",
"inputs": {
"image": [
"reference",
0
]
}
},
"controlnet": {
"class_type": "ControlNetLoader",
"inputs": {
"control_net_name": "$controlnet"
}
},
"steer": {
"class_type": "ControlNetApplyAdvanced",
"inputs": {
"positive": [
"$sampler.positive",
"*"
],
"negative": [
"$sampler.negative",
"*"
],
"control_net": [
"controlnet",
0
],
"image": [
"lines",
0
],
"vae": [
"$decode.vae",
"*"
],
"strength": 0.9,
"start_percent": 0.0,
"end_percent": 0.9
},
"wire_to": "$sampler"
},
"look": {
"class_type": "PreviewImage",
"inputs": {
"images": [
"lines",
0
]
}
}
},
"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.",
"notes": "A drawing is black lines on white; a lineart ControlNet reads white lines on black, hence ImageInvert.",
"models": [
"union_promax_sdxl",
"anima_lllite_lineart"
],
"tested": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"date": "2026-10-27",
"runs": [
{
"model": "SDXL (Illustrious)",
"size": "1216×832",
"seconds": 34
}
]
},
"variants": {
"anima": {
"packs": {
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
},
"control": null,
"lllite": "lineart",
"nodes": {
"reference": {
"class_type": "LoadImage",
"inputs": {
"image": "$image"
}
},
"lines": {
"class_type": "ImageInvert",
"inputs": {
"image": [
"reference",
0
]
}
},
"look": {
"class_type": "PreviewImage",
"inputs": {
"images": [
"lines",
0
]
}
},
"steer": {
"class_type": "AnimaLLLiteApply_sdscripts",
"inputs": {
"model": [
"$sampler.model",
"*"
],
"lllite_name": "$lllite",
"image": [
"lines",
0
],
"strength": 1.0,
"start_percent": 0.0,
"end_percent": 1.0,
"preserve_wrapper": true
},
"wire_to": "$sampler"
}
},
"after": "On this Anima model Anima's lineart LLLite steers it (strength 1.0). 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."
}
}
}
+162
View File
@@ -0,0 +1,162 @@
{
"job": "follow",
"want": "layout",
"rank": 2,
"short": "its layout",
"title": {
"zh": "照着构图画",
"en": "Follow a layout"
},
"about": {
"zh": "读出参考图的深度,照着它的构图画。",
"en": "Reads a picture's depth and draws to its layout."
},
"source": {
"zh": "参考工作流的「底图控制网」组(作者默认用深度)",
"en": "The reference workflow's ControlNet group (depth, its default)"
},
"packs": {
"DepthAnythingV2Preprocessor": "comfyui_controlnet_aux"
},
"takes": "reference",
"control": "depth",
"nodes": {
"reference": {
"class_type": "LoadImage",
"inputs": {
"image": "$image"
}
},
"depth": {
"class_type": "DepthAnythingV2Preprocessor",
"inputs": {
"image": [
"reference",
0
],
"ckpt_name": "depth_anything_v2_vitl.pth",
"resolution": 1024
}
},
"controlnet": {
"class_type": "ControlNetLoader",
"inputs": {
"control_net_name": "$controlnet"
}
},
"steer": {
"class_type": "ControlNetApplyAdvanced",
"inputs": {
"positive": [
"$sampler.positive",
"*"
],
"negative": [
"$sampler.negative",
"*"
],
"control_net": [
"controlnet",
0
],
"image": [
"depth",
0
],
"vae": [
"$decode.vae",
"*"
],
"strength": 0.7,
"start_percent": 0.0,
"end_percent": 0.7
},
"wire_to": "$sampler"
},
"look": {
"class_type": "PreviewImage",
"inputs": {
"images": [
"depth",
0
]
}
}
},
"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.",
"variants": {
"anima": {
"packs": {
"DepthAnythingV2Preprocessor": "comfyui_controlnet_aux",
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
},
"control": null,
"lllite": "depth",
"nodes": {
"reference": {
"class_type": "LoadImage",
"inputs": {
"image": "$image"
}
},
"depth": {
"class_type": "DepthAnythingV2Preprocessor",
"inputs": {
"image": [
"reference",
0
],
"ckpt_name": "depth_anything_v2_vitl.pth",
"resolution": 1024
}
},
"steer": {
"class_type": "AnimaLLLiteApply_sdscripts",
"inputs": {
"model": [
"$sampler.model",
"*"
],
"lllite_name": "$lllite",
"image": [
"depth",
0
],
"strength": 1.0,
"start_percent": 0.0,
"end_percent": 1.0,
"preserve_wrapper": true
},
"wire_to": "$sampler"
},
"look": {
"class_type": "PreviewImage",
"inputs": {
"images": [
"depth",
0
]
}
}
},
"after": "On this Anima model an Anima ControlNet-LLLite steers it, more gently than an SDXL ControlNet: say in the prompt what the picture shows (how many people, what they do), or the prompt wins. Strength 1.0 for all the steps; up to 1.5 holds it closer. run_workflow. 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."
}
},
"models": [
"xinsir_depth_sdxl",
"union_promax_sdxl",
"anima_lllite_depth"
],
"tested": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"date": "2026-10-27",
"runs": [
{
"model": "Anima",
"size": "1216×832",
"seconds": 115
}
]
}
}
+173
View File
@@ -0,0 +1,173 @@
{
"job": "follow",
"want": "pose",
"rank": 1,
"short": "its pose",
"title": {
"zh": "照着姿势画",
"en": "Follow a pose"
},
"about": {
"zh": "读出参考图的姿势,照着这个姿势画。",
"en": "Reads a picture's pose and draws to it."
},
"source": {
"zh": "参考工作流的「底图控制网」组",
"en": "The reference workflow's ControlNet group"
},
"packs": {
"DWPreprocessor": "comfyui_controlnet_aux"
},
"takes": "reference",
"control": "pose",
"nodes": {
"reference": {
"class_type": "LoadImage",
"inputs": {
"image": "$image"
}
},
"pose": {
"class_type": "DWPreprocessor",
"inputs": {
"image": [
"reference",
0
],
"detect_hand": "enable",
"detect_body": "enable",
"detect_face": "enable",
"resolution": 1024,
"bbox_detector": "yolox_l.torchscript.pt",
"pose_estimator": "dw-ll_ucoco_384_bs5.torchscript.pt",
"scale_stick_for_xinsr_cn": "disable"
}
},
"controlnet": {
"class_type": "ControlNetLoader",
"inputs": {
"control_net_name": "$controlnet"
}
},
"steer": {
"class_type": "ControlNetApplyAdvanced",
"inputs": {
"positive": [
"$sampler.positive",
"*"
],
"negative": [
"$sampler.negative",
"*"
],
"control_net": [
"controlnet",
0
],
"image": [
"pose",
0
],
"vae": [
"$decode.vae",
"*"
],
"strength": 0.8,
"start_percent": 0.0,
"end_percent": 0.8
},
"wire_to": "$sampler"
},
"look": {
"class_type": "PreviewImage",
"inputs": {
"images": [
"pose",
0
]
}
}
},
"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.",
"variants": {
"anima": {
"packs": {
"DWPreprocessor": "comfyui_controlnet_aux",
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
},
"control": null,
"lllite": "pose",
"nodes": {
"reference": {
"class_type": "LoadImage",
"inputs": {
"image": "$image"
}
},
"pose": {
"class_type": "DWPreprocessor",
"inputs": {
"image": [
"reference",
0
],
"detect_hand": "enable",
"detect_body": "enable",
"detect_face": "enable",
"resolution": 1024,
"bbox_detector": "yolox_l.torchscript.pt",
"pose_estimator": "dw-ll_ucoco_384_bs5.torchscript.pt",
"scale_stick_for_xinsr_cn": "disable"
}
},
"steer": {
"class_type": "AnimaLLLiteApply_sdscripts",
"inputs": {
"model": [
"$sampler.model",
"*"
],
"lllite_name": "$lllite",
"image": [
"pose",
0
],
"strength": 1.0,
"start_percent": 0.0,
"end_percent": 1.0,
"preserve_wrapper": true
},
"wire_to": "$sampler"
},
"look": {
"class_type": "PreviewImage",
"inputs": {
"images": [
"pose",
0
]
}
}
},
"after": "On this Anima model an Anima ControlNet-LLLite steers it, more gently than an SDXL ControlNet: say in the prompt what the picture shows (how many people, what they do), or the prompt wins. Strength 1.0 for all the steps; up to 1.5 holds it closer. run_workflow. Its pose skeleton is among the run's previews: if it is empty or wrong (a crowded or very stylised picture), try control_depth."
}
},
"models": [
"noob_openpose",
"thibauld_openpose_sdxl",
"union_promax_sdxl",
"anima_lllite_pose"
],
"tested": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"date": "2026-10-27",
"runs": [
{
"model": "SDXL (Illustrious)",
"size": "832×1216",
"seconds": 64
}
]
}
}
+125
View File
@@ -0,0 +1,125 @@
{
"job": "detail",
"rank": 1,
"short": "after",
"title": {
"zh": "部位细化(脸和手)",
"en": "Face and hand detailing"
},
"about": {
"zh": "出图后找出脸和手,各自放大重画再贴回去。",
"en": "After a picture is made, faces and hands are found, redrawn larger and pasted back."
},
"source": {
"zh": "参考工作流的「部位细化」组",
"en": "The reference workflow's detailing group"
},
"packs": {
"FaceDetailer": "ComfyUI-Impact-Pack",
"UltralyticsDetectorProvider": "ComfyUI-Impact-Subpack"
},
"detectors": {
"face": "face_yolov8",
"hand": "hand_yolov8"
},
"nodes": {
"face_finder": {
"class_type": "UltralyticsDetectorProvider",
"inputs": {
"model_name": "$face"
}
},
"hand_finder": {
"class_type": "UltralyticsDetectorProvider",
"inputs": {
"model_name": "$hand"
}
},
"face": {
"class_type": "FaceDetailer",
"inputs": {
"image": [
"$decode",
0
],
"model": [
"$sampler.model",
"*"
],
"clip": [
"$sampler.positive.clip",
"*"
],
"vae": [
"$decode.vae",
"*"
],
"positive": [
"$sampler.positive",
"*"
],
"negative": [
"$sampler.negative",
"*"
],
"bbox_detector": [
"face_finder",
0
],
"denoise": 0.4,
"guide_size": 512
}
},
"hands": {
"class_type": "FaceDetailer",
"inputs": {
"image": [
"face",
"image"
],
"model": [
"$sampler.model",
"*"
],
"clip": [
"$sampler.positive.clip",
"*"
],
"vae": [
"$decode.vae",
"*"
],
"positive": [
"$sampler.positive",
"*"
],
"negative": [
"$sampler.negative",
"*"
],
"bbox_detector": [
"hand_finder",
0
],
"denoise": 0.45,
"guide_size": 512
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": [
"hands",
"image"
],
"filename_prefix": "detailed"
}
}
},
"after": "run_workflow saves the picture as before and the detailed one beside it (prefix detailed).",
"models": [
"face_yolov8m",
"hand_yolov8s"
],
"tested": {}
}
+489
View File
@@ -0,0 +1,489 @@
{
"job": "detail",
"want": "hands",
"rank": 2,
"short": "enlarge, then hands (fallback)",
"enlarge": {
"megapixels": 4.2,
"most": 2.0,
"unknown": 1.5
},
"title": {
"zh": "修手·先放大再修(备选)",
"en": "Fix hands: enlarge, then fix (fallback)"
},
"about": {
"zh": "整张图放大到约 400 万像素(小图 2 倍)轻轻重画,让手变大;再把每只手放大重画后贴回去:有 SAM3 模型时先按文字找出贴着手形的轮廓重画一遍,再用手部检测框补修一遍。",
"en": "The whole picture is enlarged to about 4 megapixels (2× for a small one) and redrawn lightly, so the hands grow; then each hand is redrawn larger and pasted back: with the SAM3 model here, first inside the outline it finds by the word, then once more in the box the hand detector finds."
},
"source": {
"zh": "参考作者的「先放大重画」和「手部精修」两步合在一起,2026-10-29 在 RTX 4050 上试出这组数;SAM3 按文字找手那一轮照他的连法,2026-11-03 加入",
"en": "The reference author's light redraw after enlarging and his hand detailing, put together; these numbers tried on an RTX 4050 on 2026-10-29; the SAM3 pass finding hands by the word follows his wiring, added on 2026-11-03"
},
"packs": {
"FaceDetailer": "ComfyUI-Impact-Pack",
"UltralyticsDetectorProvider": "ComfyUI-Impact-Subpack"
},
"takes": "picture",
"detectors": {
"hand": "hand_yolov8"
},
"checkpoints": {
"sam3": "sam3"
},
"roles": {
"sampler": {
"denoise": 0.4
}
},
"nodes": {
"picture": {
"class_type": "LoadImage",
"inputs": {
"image": "$image"
}
},
"bigger": {
"class_type": "ImageScaleBy",
"inputs": {
"image": [
"picture",
0
],
"upscale_method": "lanczos",
"scale_by": "$enlarge"
}
},
"encode": {
"class_type": "VAEEncode",
"inputs": {
"pixels": [
"bigger",
0
],
"vae": [
"$decode.vae",
"*"
]
},
"wire_to": "$sampler"
},
"hand_seeker": {
"class_type": "CheckpointLoaderSimple",
"optional": true,
"_meta": {
"title": "SAM3"
},
"inputs": {
"ckpt_name": "$sam3"
}
},
"hand_words": {
"class_type": "CLIPTextEncode",
"optional": true,
"_meta": {
"title": "SAM3: what to find"
},
"inputs": {
"text": "hand:8",
"clip": [
"hand_seeker",
1
]
}
},
"hand_shapes": {
"class_type": "SAM3_Detect",
"optional": true,
"inputs": {
"model": [
"hand_seeker",
0
],
"image": [
"$decode",
0
],
"conditioning": [
"hand_words",
0
],
"threshold": 0.5,
"refine_iterations": 2,
"individual_masks": false
}
},
"hand_areas": {
"class_type": "MaskToSEGS",
"optional": true,
"inputs": {
"mask": [
"hand_shapes",
0
],
"combined": false,
"crop_factor": 2.0,
"bbox_fill": false,
"drop_size": 10,
"contour_fill": false
}
},
"shaped_hands": {
"class_type": "DetailerForEach",
"like": "$sampler",
"optional": true,
"inputs": {
"image": [
"$decode",
0
],
"segs": [
"hand_areas",
0
],
"model": [
"$sampler.model",
"*"
],
"clip": [
"$sampler.positive.clip",
"*"
],
"vae": [
"$decode.vae",
"*"
],
"positive": [
"$sampler.positive",
"*"
],
"negative": [
"$sampler.negative",
"*"
],
"guide_size": 768,
"max_size": 1024,
"denoise": 0.45,
"feather": 5,
"noise_mask": true,
"force_inpaint": true,
"noise_mask_feather": 10,
"wildcard": "detailed hand"
}
},
"hand_finder": {
"class_type": "UltralyticsDetectorProvider",
"inputs": {
"model_name": "$hand"
}
},
"hands": {
"class_type": "FaceDetailer",
"like": "$sampler",
"inputs": {
"image": [
"shaped_hands|$decode",
0
],
"model": [
"$sampler.model",
"*"
],
"clip": [
"$sampler.positive.clip",
"*"
],
"vae": [
"$decode.vae",
"*"
],
"positive": [
"$sampler.positive",
"*"
],
"negative": [
"$sampler.negative",
"*"
],
"bbox_detector": [
"hand_finder",
0
],
"guide_size": 768,
"max_size": 1024,
"denoise": 0.45,
"bbox_threshold": 0.3,
"bbox_crop_factor": 2.0,
"feather": 5,
"drop_size": 10,
"wildcard": "detailed hand"
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": [
"hands",
"image"
],
"filename_prefix": "hands_fixed"
}
}
},
"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.",
"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",
"sam3_1",
"anima_turbo_lora"
],
"tested": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"date": "2026-11-03",
"runs": [
{
"model": "Anima (Kirazuri v4, Turbo LoRA)",
"size": "1280×832 → 2560×1664",
"seconds": 105
},
{
"model": "Anima (MiaoMiao Harem 1.5, Turbo LoRA, SAM3 pass)",
"size": "832×1216 → 1664×2432",
"seconds": 112
}
]
},
"variants": {
"anima": {
"loras": {
"turbo": "anima-turbo-lora"
},
"without_loras": "On an Anima model the Anima Turbo LoRA (anima-turbo-lora-v0.2, the user can get it in the ways panel) makes this about three times faster: 10 steps at CFG 1.",
"roles": {
"sampler": {
"denoise": 0.4,
"steps": 10,
"cfg": 1.0,
"sampler_name": "euler_ancestral",
"scheduler": "normal"
}
},
"nodes": {
"picture": {
"class_type": "LoadImage",
"inputs": {
"image": "$image"
}
},
"turbo": {
"class_type": "LoraLoaderModelOnly",
"inputs": {
"model": [
"$sampler.model",
"*"
],
"lora_name": "$turbo",
"strength_model": 0.7
},
"wire_to": "$sampler"
},
"bigger": {
"class_type": "ImageScaleBy",
"inputs": {
"image": [
"picture",
0
],
"upscale_method": "lanczos",
"scale_by": "$enlarge"
}
},
"encode": {
"class_type": "VAEEncode",
"inputs": {
"pixels": [
"bigger",
0
],
"vae": [
"$decode.vae",
"*"
]
},
"wire_to": "$sampler"
},
"hand_seeker": {
"class_type": "CheckpointLoaderSimple",
"optional": true,
"_meta": {
"title": "SAM3"
},
"inputs": {
"ckpt_name": "$sam3"
}
},
"hand_words": {
"class_type": "CLIPTextEncode",
"optional": true,
"_meta": {
"title": "SAM3: what to find"
},
"inputs": {
"text": "hand:8",
"clip": [
"hand_seeker",
1
]
}
},
"hand_shapes": {
"class_type": "SAM3_Detect",
"optional": true,
"inputs": {
"model": [
"hand_seeker",
0
],
"image": [
"$decode",
0
],
"conditioning": [
"hand_words",
0
],
"threshold": 0.5,
"refine_iterations": 2,
"individual_masks": false
}
},
"hand_areas": {
"class_type": "MaskToSEGS",
"optional": true,
"inputs": {
"mask": [
"hand_shapes",
0
],
"combined": false,
"crop_factor": 2.0,
"bbox_fill": false,
"drop_size": 10,
"contour_fill": false
}
},
"shaped_hands": {
"class_type": "DetailerForEach",
"like": "$sampler",
"optional": true,
"inputs": {
"image": [
"$decode",
0
],
"segs": [
"hand_areas",
0
],
"model": [
"turbo",
0
],
"clip": [
"$sampler.positive.clip",
"*"
],
"vae": [
"$decode.vae",
"*"
],
"positive": [
"$sampler.positive",
"*"
],
"negative": [
"$sampler.negative",
"*"
],
"guide_size": 768,
"max_size": 1024,
"steps": 10,
"cfg": 1.0,
"sampler_name": "euler_ancestral",
"scheduler": "normal",
"denoise": 0.45,
"feather": 5,
"noise_mask": true,
"force_inpaint": true,
"noise_mask_feather": 10,
"wildcard": "detailed hand"
}
},
"hand_finder": {
"class_type": "UltralyticsDetectorProvider",
"inputs": {
"model_name": "$hand"
}
},
"hands": {
"class_type": "FaceDetailer",
"like": "$sampler",
"inputs": {
"image": [
"shaped_hands|$decode",
0
],
"model": [
"turbo",
0
],
"clip": [
"$sampler.positive.clip",
"*"
],
"vae": [
"$decode.vae",
"*"
],
"positive": [
"$sampler.positive",
"*"
],
"negative": [
"$sampler.negative",
"*"
],
"bbox_detector": [
"hand_finder",
0
],
"guide_size": 768,
"max_size": 1024,
"steps": 10,
"cfg": 1.0,
"sampler_name": "euler_ancestral",
"scheduler": "normal",
"denoise": 0.45,
"bbox_threshold": 0.3,
"bbox_crop_factor": 2.0,
"feather": 5,
"drop_size": 10,
"wildcard": "detailed hand"
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": [
"hands",
"image"
],
"filename_prefix": "hands_fixed"
}
}
},
"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. On this Anima model the Anima Turbo LoRA (0.7) makes both passes 10 steps at CFG 1, about 105 s for 1280×832 (2×) on a 6 GB card. 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."
}
}
}
+72
View File
@@ -0,0 +1,72 @@
{
"job": "repaint",
"rank": 3,
"short": "built-in nodes",
"title": {
"zh": "局部重绘·官方节点",
"en": "Repaint: built-in nodes"
},
"about": {
"zh": "只用 ComfyUI 自带的节点重画圈出的地方,没装别的包时用。",
"en": "ComfyUI's own nodes only: for when the packs above are missing."
},
"source": {
"zh": "ComfyUI 官方的局部重绘做法",
"en": "ComfyUI's own way to inpaint"
},
"packs": {},
"takes": "inpaint",
"nodes": {
"picture": {
"class_type": "LoadImage",
"inputs": {
"image": "$image"
}
},
"soft": {
"class_type": "DifferentialDiffusion",
"inputs": {
"model": [
"$sampler.model",
"*"
]
},
"wire_to": "$sampler"
},
"encode": {
"class_type": "VAEEncode",
"inputs": {
"pixels": [
"picture",
0
],
"vae": [
"$decode.vae",
"*"
]
}
},
"area": {
"class_type": "SetLatentNoiseMask",
"inputs": {
"samples": [
"encode",
0
],
"mask": [
"picture",
1
]
},
"wire_to": "$sampler"
}
},
"roles": {
"sampler": {
"denoise": 0.5
}
},
"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.",
"models": [],
"tested": {}
}
+223
View File
@@ -0,0 +1,223 @@
{
"job": "repaint",
"rank": 1,
"short": "small fixes",
"title": {
"zh": "局部重绘·裁切缝合",
"en": "Repaint: crop and stitch"
},
"about": {
"zh": "把圈出的地方连同周围一圈裁出来放大重画,再贴回去,适合小地方。",
"en": "Crops the marked area with a margin, redraws it larger and pastes it back: for small spots."
},
"source": {
"zh": "参考工作流的「局部重绘」组,和节点包自带的示例一致",
"en": "The reference workflow's repaint group, as the pack's own example"
},
"packs": {
"InpaintCropImproved": "ComfyUI-Inpaint-CropAndStitch",
"InpaintStitchImproved": "ComfyUI-Inpaint-CropAndStitch"
},
"instead": "inpaint_basic",
"takes": "inpaint",
"nodes": {
"picture": {
"class_type": "LoadImage",
"inputs": {
"image": "$image"
}
},
"soft": {
"class_type": "DifferentialDiffusion",
"inputs": {
"model": [
"$sampler.model",
"*"
]
},
"wire_to": "$sampler"
},
"crop": {
"class_type": "InpaintCropImproved",
"inputs": {
"image": [
"picture",
0
],
"mask": [
"picture",
1
],
"mask_blend_pixels": 32,
"context_from_mask_extend_factor": 2.0,
"output_resize_to_target_size": true,
"output_target_width": 1024,
"output_target_height": 1024,
"downscale_algorithm": "lanczos",
"upscale_algorithm": "lanczos"
}
},
"condition": {
"class_type": "InpaintModelConditioning",
"inputs": {
"vae": [
"$decode.vae",
"*"
],
"pixels": [
"crop",
"cropped_image"
],
"mask": [
"crop",
"cropped_mask"
]
},
"wire_to": "$sampler"
},
"stitch": {
"class_type": "InpaintStitchImproved",
"inputs": {
"stitcher": [
"crop",
"stitcher"
],
"inpainted_image": [
"$decode",
0
]
},
"wire_to": "$save"
}
},
"roles": {
"sampler": {
"denoise": 0.45
}
},
"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.",
"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": {
"packs": {
"InpaintCropImproved": "ComfyUI-Inpaint-CropAndStitch",
"InpaintStitchImproved": "ComfyUI-Inpaint-CropAndStitch",
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
},
"lllite": "inpaint",
"nodes": {
"picture": {
"class_type": "LoadImage",
"inputs": {
"image": "$image"
}
},
"soft": {
"class_type": "DifferentialDiffusion",
"inputs": {
"model": [
"$sampler.model",
"*"
]
}
},
"patch": {
"class_type": "AnimaLLLiteApply_sdscripts",
"inputs": {
"model": [
"soft",
0
],
"lllite_name": "$lllite",
"image": [
"crop",
"cropped_image"
],
"strength": 1.0,
"start_percent": 0.0,
"end_percent": 1.0,
"preserve_wrapper": true,
"mask": [
"crop",
"cropped_mask"
]
},
"wire_to": "$sampler"
},
"crop": {
"class_type": "InpaintCropImproved",
"inputs": {
"image": [
"picture",
0
],
"mask": [
"picture",
1
],
"mask_blend_pixels": 32,
"context_from_mask_extend_factor": 2.0,
"output_resize_to_target_size": true,
"output_target_width": 1024,
"output_target_height": 1024,
"downscale_algorithm": "lanczos",
"upscale_algorithm": "lanczos"
}
},
"condition": {
"class_type": "InpaintModelConditioning",
"inputs": {
"vae": [
"$decode.vae",
"*"
],
"pixels": [
"crop",
"cropped_image"
],
"mask": [
"crop",
"cropped_mask"
]
},
"wire_to": "$sampler"
},
"stitch": {
"class_type": "InpaintStitchImproved",
"inputs": {
"stitcher": [
"crop",
"stitcher"
],
"inpainted_image": [
"$decode",
0
]
},
"wire_to": "$save"
}
},
"after": "On this Anima model its repaint LLLite guides it too; 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."
}
},
"models": [
"anima_lllite_inpainting"
],
"tested": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"date": "2026-10-27",
"runs": [
{
"model": "SDXL (Illustrious)",
"size": "832×1216",
"seconds": null
},
{
"model": "Anima",
"size": "1216×832",
"seconds": 130
}
]
}
}
+93
View File
@@ -0,0 +1,93 @@
{
"job": "repaint",
"want": "best",
"rank": 2,
"short": "best blend, slow",
"title": {
"zh": "局部重绘·LanPaint",
"en": "Repaint: LanPaint"
},
"about": {
"zh": "换成 LanPaint 的采样器,边缘融合最好,但慢。",
"en": "LanPaint's sampler: the best blend at the edges, but slow."
},
"source": {
"zh": "节点包 LanPaint 自带的 SDXL 示例",
"en": "LanPaint's own SDXL example"
},
"packs": {
"LanPaint_KSampler": "LanPaint",
"LanPaint_ImageEncode": "LanPaint",
"LanPaint_ImageDecode": "LanPaint"
},
"instead": "inpaint_crop_stitch",
"takes": "inpaint",
"nodes": {
"picture": {
"class_type": "LoadImage",
"inputs": {
"image": "$image"
}
},
"encode": {
"class_type": "LanPaint_ImageEncode",
"inputs": {
"image": [
"picture",
0
],
"vae": [
"$decode.vae",
"*"
],
"mask": [
"picture",
1
]
}
},
"sampler": {
"class_type": "LanPaint_KSampler",
"replaces": "$sampler",
"inputs": {
"latent_image": [
"encode",
0
],
"denoise": 1.0
}
},
"decode": {
"class_type": "LanPaint_ImageDecode",
"replaces": "$decode",
"inputs": {
"samples": [
"sampler",
0
],
"image": [
"picture",
0
],
"mask": [
"picture",
1
]
}
}
},
"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.",
"models": [],
"tested": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"date": "2026-10-27",
"runs": [
{
"model": "SDXL (Illustrious)",
"size": "832×1216",
"seconds": 82
}
]
}
}
+246
View File
@@ -0,0 +1,246 @@
{
"job": "picture",
"rank": 1,
"short": "a picture from words",
"standalone": true,
"title": {
"zh": "出图",
"en": "Make a picture"
},
"about": {
"zh": "在空画布上按模型的家族搭好出图的节点:SDXL 一类是一个 checkpoint,Anima 是模型、文本编码器、VAE 三个文件;设置用验证过的那一套。",
"en": "Builds the nodes that make a picture on an empty canvas, by the model's family: one checkpoint for SDXL and its kin, the model, text encoder and VAE files for Anima; with the tested settings."
},
"source": {
"zh": "ComfyUI 的默认出图流程;Anima 的设置来自你验证过的两段放大卡的底图那一段",
"en": "ComfyUI's default text-to-image graph; Anima's settings from the base pass of the user's tested two-pass card"
},
"packs": {},
"nodes": {
"model": {
"class_type": "CheckpointLoaderSimple",
"_meta": {
"title": "Model"
},
"inputs": {
"ckpt_name": "$model"
}
},
"positive": {
"class_type": "CLIPTextEncode",
"inputs": {
"text": "",
"clip": [
"model",
1
]
}
},
"negative": {
"class_type": "CLIPTextEncode",
"inputs": {
"text": "",
"clip": [
"model",
1
]
}
},
"size": {
"class_type": "EmptyLatentImage",
"inputs": {
"width": 832,
"height": 1216,
"batch_size": 1
}
},
"sampler": {
"class_type": "KSampler",
"inputs": {
"model": [
"model",
0
],
"positive": [
"positive",
0
],
"negative": [
"negative",
0
],
"latent_image": [
"size",
0
],
"steps": 28,
"cfg": 5.5,
"sampler_name": "euler_ancestral",
"scheduler": "normal",
"denoise": 1.0
}
},
"decode": {
"class_type": "VAEDecode",
"inputs": {
"samples": [
"sampler",
0
],
"vae": [
"model",
2
]
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": [
"decode",
0
],
"filename_prefix": "picture"
}
}
},
"variants": {
"anima": {
"files": {
"$text_encoder": [
"CLIPLoader",
"clip_name",
[
"qwen_3_06b",
"qwen3_06b"
]
],
"$vae": [
"VAELoader",
"vae_name",
[
"qwenimage",
"qwen_image"
]
]
},
"nodes": {
"model": {
"class_type": "UNETLoader",
"_meta": {
"title": "Model"
},
"inputs": {
"unet_name": "$model",
"weight_dtype": "default"
}
},
"text_encoder": {
"class_type": "CLIPLoader",
"inputs": {
"clip_name": "$text_encoder",
"type": "stable_diffusion"
}
},
"vae": {
"class_type": "VAELoader",
"inputs": {
"vae_name": "$vae"
}
},
"positive": {
"class_type": "CLIPTextEncode",
"inputs": {
"text": "",
"clip": [
"text_encoder",
0
]
}
},
"negative": {
"class_type": "CLIPTextEncode",
"inputs": {
"text": "",
"clip": [
"text_encoder",
0
]
}
},
"size": {
"class_type": "EmptyLatentImage",
"inputs": {
"width": 832,
"height": 1216,
"batch_size": 1
}
},
"sampler": {
"class_type": "KSampler",
"inputs": {
"model": [
"model",
0
],
"positive": [
"positive",
0
],
"negative": [
"negative",
0
],
"latent_image": [
"size",
0
],
"steps": 35,
"cfg": 4.4,
"sampler_name": "dpmpp_2m_sde_gpu",
"scheduler": "beta",
"denoise": 1.0
}
},
"decode": {
"class_type": "VAEDecode",
"inputs": {
"samples": [
"sampler",
0
],
"vae": [
"vae",
0
]
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": [
"decode",
0
],
"filename_prefix": "picture"
}
}
},
"after": "The picture's nodes are on the canvas, on the Anima model picked (the loader titled Model), with its text encoder and VAE and the tested sampling (35 steps, dpmpp_2m_sde_gpu, beta, cfg 4.4, 832x1216). 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."
}
},
"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.",
"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",
"vram_gb": 6,
"date": "2026-11-03",
"runs": [
{
"model": "Anima (MiaoMiao Harem 1.5)",
"size": "832×1216",
"seconds": 78
}
]
}
}
+181
View File
@@ -0,0 +1,181 @@
{
"job": "new_background",
"want": "given",
"rank": 5,
"short": "given place",
"title": {
"zh": "放进另一张图",
"en": "Put a character into a picture"
},
"about": {
"zh": "把人物抠出来贴进你给的背景图,再轻轻重画让它融进去。",
"en": "Pastes the cut-out character into a given picture, then lightly redraws so it fits."
},
"source": {
"zh": "常见的“抠图合成再重绘融合”做法:inspyrenet-rembg 抠人物,ComfyUI 自带的节点缩放、贴图和局部重绘",
"en": "The common cut-out, composite and blend-by-redrawing way: inspyrenet-rembg finds the character, ComfyUI's own nodes scale, paste and redraw"
},
"packs": {
"InspyrenetRembg": "comfyui-inspyrenet-rembg"
},
"takes": "picture",
"scene": true,
"nodes": {
"picture": {
"class_type": "LoadImage",
"inputs": {
"image": "$image"
}
},
"place": {
"class_type": "LoadImage",
"inputs": {
"image": "$scene"
}
},
"cut": {
"class_type": "InspyrenetRembg",
"inputs": {
"image": [
"picture",
0
],
"torchscript_jit": "default"
}
},
"size": {
"class_type": "GetImageSize",
"inputs": {
"image": [
"picture",
0
]
}
},
"fit": {
"class_type": "ImageScale",
"inputs": {
"image": [
"place",
0
],
"upscale_method": "lanczos",
"width": [
"size",
0
],
"height": [
"size",
1
],
"crop": "center"
}
},
"paste": {
"class_type": "ImageCompositeMasked",
"inputs": {
"destination": [
"fit",
0
],
"source": [
"picture",
0
],
"x": 0,
"y": 0,
"resize_source": false,
"mask": [
"cut",
1
]
}
},
"background": {
"class_type": "InvertMask",
"inputs": {
"mask": [
"cut",
1
]
}
},
"edge": {
"class_type": "GrowMask",
"inputs": {
"mask": [
"background",
0
],
"expand": 6,
"tapered_corners": true
}
},
"soft": {
"class_type": "DifferentialDiffusion",
"inputs": {
"model": [
"$sampler.model",
"*"
]
},
"wire_to": "$sampler"
},
"encode": {
"class_type": "VAEEncode",
"inputs": {
"pixels": [
"paste",
0
],
"vae": [
"$decode.vae",
"*"
]
}
},
"area": {
"class_type": "SetLatentNoiseMask",
"inputs": {
"samples": [
"encode",
0
],
"mask": [
"edge",
0
]
},
"wire_to": "$sampler"
},
"look": {
"class_type": "PreviewImage",
"inputs": {
"images": [
"paste",
0
]
}
}
},
"roles": {
"sampler": {
"denoise": 0.5
}
},
"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.",
"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": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"date": "2026-10-28",
"runs": [
{
"model": "SDXL (Illustrious)",
"size": "832×1216",
"seconds": 40
}
]
}
}
+195
View File
@@ -0,0 +1,195 @@
{
"job": "refine",
"rank": 6,
"short": "other model",
"title": {
"zh": "换模型精修",
"en": "Refine with another model"
},
"about": {
"zh": "换另一个模型把整张图轻轻重画一遍,另存一张(单文件模型和 Anima 都行)。",
"en": "Another model lightly redraws the whole picture, saved beside it (a single-file checkpoint or an Anima model)."
},
"source": {
"zh": "参考工作流的「Refiner 管线」:精修时可以换一个底模加载器,配一套独立的 LoRA 堆",
"en": "The reference workflow's refiner: a base model loader of its own for refining, with a separate LoRA stack"
},
"packs": {},
"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
]
}
},
"encode": {
"class_type": "VAEEncode",
"inputs": {
"pixels": [
"$decode",
0
],
"vae": [
"second_model",
2
]
}
},
"redraw": {
"class_type": "KSampler",
"like": "$sampler",
"inputs": {
"model": [
"second_model",
0
],
"positive": [
"second_positive",
0
],
"negative": [
"second_negative",
0
],
"latent_image": [
"encode",
0
],
"denoise": 0.4
}
},
"decode": {
"class_type": "VAEDecode",
"inputs": {
"samples": [
"redraw",
0
],
"vae": [
"second_model",
2
]
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": [
"decode",
0
],
"filename_prefix": "refined"
}
}
},
"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.",
"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": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"date": "2026-11-02",
"runs": [
{
"model": "SDXL (Illustrious) → NoobAI",
"size": "832×1216",
"seconds": 66
},
{
"model": "Anima (Kirazuri → Realskin 1.3, denoise 0.4, with the base)",
"size": "1216×832",
"seconds": 148
}
]
},
"variants": {
"anima": {
"nodes": {
"second_model": {
"class_type": "UNETLoader",
"like": "$loader",
"inputs": {}
},
"encode": {
"class_type": "VAEEncode",
"inputs": {
"pixels": [
"$decode",
0
],
"vae": [
"$decode.vae",
"*"
]
}
},
"redraw": {
"class_type": "KSampler",
"like": "$sampler",
"inputs": {
"model": [
"second_model",
0
],
"positive": [
"$sampler.positive",
"*"
],
"negative": [
"$sampler.negative",
"*"
],
"latent_image": [
"encode",
0
],
"denoise": 0.4
}
},
"decode": {
"class_type": "VAEDecode",
"inputs": {
"samples": [
"redraw",
0
],
"vae": [
"$decode.vae",
"*"
]
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": [
"decode",
0
],
"filename_prefix": "refined"
}
}
},
"after": "On this Anima model the refine'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 (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 refined one is saved beside it (prefix refined). Denoise 0.4: lower keeps more of the first, higher takes more of the second."
}
}
}
+64
View File
@@ -0,0 +1,64 @@
{
"job": "process",
"want": "cut",
"rank": 1,
"short": "cut-out",
"title": {
"zh": "背景移除",
"en": "Remove the background"
},
"about": {
"zh": "抠掉背景,存成透明背景的 PNG。",
"en": "Cuts the background away into a transparent PNG."
},
"source": {
"zh": "参考工作流的「背景移除」组,换成你装的 inspyrenet-rembg",
"en": "The reference workflow's background removal, with your inspyrenet-rembg"
},
"packs": {
"InspyrenetRembg": "comfyui-inspyrenet-rembg"
},
"takes": "picture",
"nodes": {
"picture": {
"class_type": "LoadImage",
"inputs": {
"image": "$image"
}
},
"cut": {
"class_type": "InspyrenetRembg",
"inputs": {
"image": [
"picture",
0
],
"torchscript_jit": "default"
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": [
"cut",
0
],
"filename_prefix": "nobg"
}
}
},
"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).",
"models": [],
"tested": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"date": "2026-10-27",
"runs": [
{
"model": "—",
"size": "1280×832",
"seconds": 8
}
]
}
}
+120
View File
@@ -0,0 +1,120 @@
{
"job": "follow",
"want": "style",
"rank": 5,
"short": "new style",
"title": {
"zh": "换画风·构图不变",
"en": "New style, same layout"
},
"about": {
"zh": "用轮廓线钉住构图,按新画风整张重画。",
"en": "Outlines hold the layout while the whole picture is redrawn in a new style."
},
"source": {
"zh": "常见的“控制网 + 以图生图”换风格做法,轮廓用 Union 控制网的 canny 模式",
"en": "The common ControlNet-plus-img2img restyle, the outlines through the union ControlNet's canny mode"
},
"packs": {},
"takes": "reference",
"control": "lineart",
"nodes": {
"reference": {
"class_type": "LoadImage",
"inputs": {
"image": "$image"
}
},
"edges": {
"class_type": "Canny",
"inputs": {
"image": [
"reference",
0
],
"low_threshold": 0.4,
"high_threshold": 0.8
}
},
"controlnet": {
"class_type": "ControlNetLoader",
"inputs": {
"control_net_name": "$controlnet"
}
},
"steer": {
"class_type": "ControlNetApplyAdvanced",
"inputs": {
"positive": [
"$sampler.positive",
"*"
],
"negative": [
"$sampler.negative",
"*"
],
"control_net": [
"controlnet",
0
],
"image": [
"edges",
0
],
"vae": [
"$decode.vae",
"*"
],
"strength": 0.7,
"start_percent": 0.0,
"end_percent": 0.8
},
"wire_to": "$sampler"
},
"encode": {
"class_type": "VAEEncode",
"inputs": {
"pixels": [
"reference",
0
],
"vae": [
"$decode.vae",
"*"
]
},
"wire_to": "$sampler"
},
"look": {
"class_type": "PreviewImage",
"inputs": {
"images": [
"edges",
0
]
}
}
},
"roles": {
"sampler": {
"denoise": 1.0
}
},
"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).",
"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"
],
"tested": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"date": "2026-10-28",
"runs": [
{
"model": "SDXL (Illustrious)",
"size": "832×1216",
"seconds": 41
}
]
}
}
+157
View File
@@ -0,0 +1,157 @@
{
"job": "refine",
"want": "midway",
"rank": 7,
"short": "switch mid-way",
"title": {
"zh": "半途换模型接着画",
"en": "Switch models mid-way"
},
"about": {
"zh": "前面的步数用原模型定下构图,后面的步数换另一个模型接着画完,画风和细节跟着它走。",
"en": "The first steps keep the model's composition, then another model draws the rest, with its look and detail."
},
"source": {
"zh": "复现 Aaalice 双模型路线时用的接力(两个 KSampler Advanced 分段),本机三组对比验证过",
"en": "The relay used to reproduce the Aaalice two-model route (two KSampler Advanced parts), checked in three local comparisons"
},
"packs": {},
"nodes": {
"second_model": {
"class_type": "CheckpointLoaderSimple",
"like": "$loader",
"inputs": {}
},
"first": {
"class_type": "KSamplerAdvanced",
"like": "$sampler",
"inputs": {
"model": [
"$sampler.model",
"*"
],
"positive": [
"$sampler.positive",
"*"
],
"negative": [
"$sampler.negative",
"*"
],
"latent_image": [
"$sampler.latent_image",
"*"
],
"add_noise": "enable",
"start_at_step": 0,
"end_at_step": 21,
"return_with_leftover_noise": "enable"
}
},
"second": {
"class_type": "KSamplerAdvanced",
"replaces": "$sampler",
"inputs": {
"model": [
"second_model",
0
],
"latent_image": [
"first",
0
],
"add_noise": "disable",
"start_at_step": 21,
"end_at_step": 10000,
"return_with_leftover_noise": "disable"
}
}
},
"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.",
"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": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"runs": [
{
"model": "Anima (Kirazuri → Realskin 1.3 at step 21)",
"size": "1216×832",
"seconds": 79
},
{
"model": "SDXL (Nova Anime XL → MiaoMiao Harem v2.0 at step 18 of 30)",
"size": "832×1216",
"seconds": 41
},
{
"model": "Anima (MiaoMiao Harem 1.5 both parts, enlarged 1.5× at step 21)",
"size": "832×1216 → 1248×1824",
"seconds": 106
}
],
"date": "2026-11-03"
},
"variants": {
"anima": {
"nodes": {
"second_model": {
"class_type": "UNETLoader",
"like": "$loader",
"inputs": {}
},
"first": {
"class_type": "KSamplerAdvanced",
"like": "$sampler",
"inputs": {
"model": [
"$sampler.model",
"*"
],
"positive": [
"$sampler.positive",
"*"
],
"negative": [
"$sampler.negative",
"*"
],
"latent_image": [
"$sampler.latent_image",
"*"
],
"add_noise": "enable",
"start_at_step": 0,
"end_at_step": 21,
"return_with_leftover_noise": "enable",
"steps": 35,
"sampler_name": "dpmpp_2m_sde_gpu",
"scheduler": "beta"
}
},
"second": {
"class_type": "KSamplerAdvanced",
"replaces": "$sampler",
"inputs": {
"model": [
"second_model",
0
],
"latent_image": [
"first",
0
],
"add_noise": "disable",
"start_at_step": 21,
"end_at_step": 10000,
"return_with_leftover_noise": "disable",
"steps": 35,
"sampler_name": "dpmpp_2m_sde_gpu",
"scheduler": "beta"
}
}
},
"after": "The canvas's sampler became two, with the tested Anima sampling (35 steps, dpmpp_2m_sde_gpu, beta): the first runs steps 0 to 21 on the canvas's model, the second (second_model, a new UNET 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 Anima model the user wants (it reuses the text encoder and 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 (the tested route did). Move the switch with the first sampler's end_at_step and the second's start_at_step together (earlier: more of the second model, later: more of the first). Keep dpmpp_2m_sde_gpu: er_sde came out grainy split on two Anima models. 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."
}
}
}
+60
View File
@@ -0,0 +1,60 @@
{
"job": "process",
"want": "tags",
"rank": 3,
"short": "its tags",
"title": {
"zh": "读图出标签",
"en": "Tags from a picture"
},
"about": {
"zh": "读出一张图的标签,交给 AI 当文字用。",
"en": "Reads a picture's tags and hands them to the AI as words."
},
"source": {
"zh": "参考工作流的「图像反推」组",
"en": "The reference workflow's interrogation group"
},
"packs": {
"WD14Tagger|pysssss": "ComfyUI-WD14-Tagger"
},
"takes": "picture",
"nodes": {
"picture": {
"class_type": "LoadImage",
"inputs": {
"image": "$image"
}
},
"tags": {
"class_type": "WD14Tagger|pysssss",
"inputs": {
"image": [
"picture",
0
],
"model": "wd-eva02-large-tagger-v3",
"threshold": 0.35,
"character_threshold": 0.85,
"replace_underscore": false,
"trailing_comma": false,
"exclude_tags": ""
}
}
},
"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.",
"notes": "The source's model and thresholds (wd-eva02-large-tagger-v3, 0.35, 0.85).",
"models": [],
"tested": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"date": "2026-10-27",
"runs": [
{
"model": "—",
"size": "1280×832",
"seconds": 166
}
]
}
}
+150
View File
@@ -0,0 +1,150 @@
{
"job": "upscale",
"rank": 1,
"short": "1.25×",
"title": {
"zh": "放大",
"en": "Upscale"
},
"about": {
"zh": "用放大模型放到 1.25 倍,再轻轻重画补细节。",
"en": "An upscale model to 1.25×, then a light redraw for detail."
},
"source": {
"zh": "参考工作流的「通用放大」组",
"en": "The reference workflow's general upscale group"
},
"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.",
"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": {
"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"
}
},
"encode": {
"class_type": "VAEEncode",
"inputs": {
"pixels": [
"fit",
0
],
"vae": [
"$decode.vae",
"*"
]
}
},
"redraw": {
"class_type": "KSampler",
"like": "$sampler",
"inputs": {
"model": [
"$sampler.model",
"*"
],
"positive": [
"$sampler.positive",
"*"
],
"negative": [
"$sampler.negative",
"*"
],
"latent_image": [
"encode",
0
],
"denoise": 0.35
}
},
"decode": {
"class_type": "VAEDecode",
"inputs": {
"samples": [
"redraw",
0
],
"vae": [
"$decode.vae",
"*"
]
}
},
"match": {
"class_type": "ColorMatch",
"optional": true,
"inputs": {
"image_ref": [
"fit",
0
],
"image_target": [
"decode",
0
],
"method": "mkl",
"strength": 1.0,
"multithread": true
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": [
"match|decode",
0
],
"filename_prefix": "upscaled"
}
}
},
"extras": {
"ColorMatch": "ComfyUI-KJNodes"
},
"models": [
"2x_animesharp_v4",
"4x_ultrasharp",
"4x_animesharp"
],
"tested": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"date": "2026-10-27",
"runs": [
{
"model": "SDXL (Illustrious)",
"size": "832×1216 → 1.25×",
"seconds": 43
}
]
}
}
+339
View File
@@ -0,0 +1,339 @@
{
"job": "upscale",
"want": "detail",
"rank": 2,
"short": "held by Tile, own model",
"title": {
"zh": "Tile 锁图放大",
"en": "Upscale held by Tile"
},
"about": {
"zh": "放大时用 Tile 钉住画面,重画得更狠、细节更多。放大这一段有自己的一份模型(默认和底图同一个,LoRA 一起带上),想换第二个模型只要换这一格;Anima 上用 Anima 的 Tile LLLite。",
"en": "A Tile holds the picture while it is enlarged and redrawn, so the redraw can go stronger: more detail. The redraw has its own copy of the model (the base's, its LoRAs too); a second model is one swap of that loader. On Anima, Anima's Tile LLLite holds it."
},
"source": {
"zh": "参考工作流的「通用放大」组(平铺控制网 0.6,到 80% 的步数)和它的放大加载器;Anima 是你验证过的两段放大卡(潜空间 1.22 倍 + Tile LLLite,降噪 0.85)",
"en": "The reference workflow's general upscale (tile ControlNet 0.6, to 80% of the steps) and its upscale loader; on Anima the user's tested two-pass card (latent 1.22x, Tile LLLite, denoise 0.85)"
},
"packs": {},
"upscaler": true,
"upscale_by": 1.25,
"control": "tile",
"extras": {
"ColorMatch": "ComfyUI-KJNodes"
},
"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"
}
},
"controlnet": {
"class_type": "ControlNetLoader",
"inputs": {
"control_net_name": "$controlnet"
}
},
"hold": {
"class_type": "ControlNetApplyAdvanced",
"inputs": {
"positive": [
"$sampler.positive",
"*"
],
"negative": [
"$sampler.negative",
"*"
],
"control_net": [
"controlnet",
0
],
"image": [
"fit",
0
],
"vae": [
"$decode.vae",
"*"
],
"strength": 0.6,
"start_percent": 0.0,
"end_percent": 0.8
}
},
"encode": {
"class_type": "VAEEncode",
"inputs": {
"pixels": [
"fit",
0
],
"vae": [
"$decode.vae",
"*"
]
}
},
"redraw": {
"class_type": "KSampler",
"like": "$sampler",
"inputs": {
"model": [
"own",
0
],
"positive": [
"hold",
0
],
"negative": [
"hold",
1
],
"latent_image": [
"encode",
0
],
"denoise": 0.5
}
},
"decode": {
"class_type": "VAEDecode",
"inputs": {
"samples": [
"redraw",
0
],
"vae": [
"$decode.vae",
"*"
]
}
},
"match": {
"class_type": "ColorMatch",
"optional": true,
"inputs": {
"image_ref": [
"fit",
0
],
"image_target": [
"decode",
0
],
"method": "mkl",
"strength": 1.0,
"multithread": true
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": [
"match|decode",
0
],
"filename_prefix": "upscaled"
}
}
},
"variants": {
"anima": {
"packs": {
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
},
"upscaler": false,
"control": null,
"lllite": "tile",
"nodes": {
"own": {
"class_type": "UNETLoader",
"copy_of": "$sampler.model",
"_meta": {
"title": "Upscale model"
},
"inputs": {}
},
"encode": {
"class_type": "VAEEncodeTiled",
"inputs": {
"pixels": [
"$save.images",
"*"
],
"vae": [
"$decode.vae",
"*"
],
"tile_size": 512,
"overlap": 64,
"temporal_size": 64,
"temporal_overlap": 8
}
},
"enlarge": {
"class_type": "LatentUpscaleBy",
"inputs": {
"samples": [
"encode",
0
],
"upscale_method": "bislerp",
"scale_by": 1.22
}
},
"tile": {
"class_type": "AnimaLLLiteApply_sdscripts",
"inputs": {
"model": [
"own",
0
],
"lllite_name": "$lllite",
"image": [
"$save.images",
"*"
],
"strength": 0.7,
"start_percent": 0.0,
"end_percent": 0.8,
"preserve_wrapper": true
}
},
"redraw": {
"class_type": "KSampler",
"like": "$sampler",
"inputs": {
"model": [
"tile",
0
],
"positive": [
"$sampler.positive",
"*"
],
"negative": [
"$sampler.negative",
"*"
],
"latent_image": [
"enlarge",
0
],
"steps": 30,
"cfg": 4.4,
"sampler_name": "dpmpp_2m_sde_gpu",
"scheduler": "sgm_uniform",
"denoise": 0.85
}
},
"decode": {
"class_type": "VAEDecodeTiled",
"inputs": {
"samples": [
"redraw",
0
],
"vae": [
"$decode.vae",
"*"
],
"tile_size": 512,
"overlap": 64,
"temporal_size": 64,
"temporal_overlap": 8
}
},
"match": {
"class_type": "ColorMatch",
"optional": true,
"inputs": {
"image_ref": [
"$save.images",
"*"
],
"image_target": [
"decode",
0
],
"method": "mkl",
"strength": 1.0,
"multithread": true
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": [
"match|decode",
0
],
"filename_prefix": "upscaled"
}
}
},
"after": "On this Anima model the picture is enlarged 1.22x in latent space and redrawn at denoise 0.85, held by Anima's Tile LLLite (0.7, to 80% of the steps), with its own copy of the canvas's model and LoRAs (the loader titled Upscale model). For a second model (steadier, other skin or texture), set_model that loader to another Anima model; the LoRAs stay. Then run_workflow; the larger one is saved beside it (prefix upscaled). Compare face, hands and clothes with the base: Tile guides, it does not guarantee."
}
},
"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.",
"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",
"noob_tile",
"anima_tile_repair",
"2x_animesharp_v4",
"4x_ultrasharp"
],
"tested": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"date": "2026-11-02",
"runs": [
{
"model": "SDXL (Illustrious)",
"size": "832×1216 → 1.25×",
"seconds": 77
},
{
"model": "Anima (Kirazuri → Realskin 1.3, Tile)",
"size": "1216×832 → 1.22×",
"seconds": 127
}
]
}
}
+114
View File
@@ -0,0 +1,114 @@
{
"job": "upscale",
"want": "2x",
"rank": 4,
"short": "2×, in tiles",
"title": {
"zh": "分块放大",
"en": "Tiled upscale"
},
"about": {
"zh": "放大 2 倍,按小块逐块重画,多大的图都放得下。",
"en": "2×, redrawn tile by tile, for any size."
},
"source": {
"zh": "节点包 UltimateSDUpscale 自带的示例",
"en": "UltimateSDUpscale's own example"
},
"packs": {
"UltimateSDUpscale": "ComfyUI_UltimateSDUpscale"
},
"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).",
"extras": {
"ColorMatch": "ComfyUI-KJNodes"
},
"notes": "ColorMatch mkl after the redraw is the source's, optional (KJNodes).",
"upscale_by": 2.0,
"nodes": {
"upscaler": {
"class_type": "UpscaleModelLoader",
"inputs": {
"model_name": "$upscaler"
}
},
"tiles": {
"class_type": "UltimateSDUpscale",
"like": "$sampler",
"inputs": {
"image": [
"$save.images",
"*"
],
"model": [
"$sampler.model",
"*"
],
"positive": [
"$sampler.positive",
"*"
],
"negative": [
"$sampler.negative",
"*"
],
"vae": [
"$decode.vae",
"*"
],
"upscale_model": [
"upscaler",
0
],
"upscale_by": 2.0,
"denoise": 0.3,
"tile_width": 1024,
"tile_height": 1024
}
},
"match": {
"class_type": "ColorMatch",
"optional": true,
"inputs": {
"image_ref": [
"$save.images",
"*"
],
"image_target": [
"tiles",
0
],
"method": "mkl",
"strength": 1.0,
"multithread": true
}
},
"save": {
"class_type": "SaveImage",
"inputs": {
"images": [
"match|tiles",
0
],
"filename_prefix": "upscaled"
}
}
},
"models": [
"2x_animesharp_v4",
"4x_ultrasharp"
],
"tested": {
"gpu": "RTX 4050 Laptop",
"vram_gb": 6,
"date": "2026-10-27",
"runs": [
{
"model": "SDXL (Illustrious)",
"size": "832×1216 → 2×",
"seconds": 167
}
]
}
}
+57 -28
View File
@@ -9,6 +9,13 @@ from aiohttp import web
import folder_paths
import struct
try:
from ..net_proxy import CONFIG_KEY as PROXY_KEY, configured as configured_proxy, normalize as normalize_proxy
from ..model_sidecars import layout as _side_file_layout
except ImportError: # loaded outside the package (tests)
from net_proxy import CONFIG_KEY as PROXY_KEY, configured as configured_proxy, normalize as normalize_proxy
from model_sidecars import layout as _side_file_layout
async def api_get_config(request):
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
@@ -20,20 +27,27 @@ async def api_get_config(request):
cfg = json.load(f)
has_key = bool(cfg.get("CIVITAI_API_KEY", "").strip())
folder_types = cfg.get("folder_types_config", [])
except:
pass
if not has_key:
legacy_config_path = os.path.join(os.path.dirname(script_dir), "config.json")
if os.path.exists(legacy_config_path):
try:
with open(legacy_config_path, 'r', encoding='utf-8') as f:
legacy_cfg = json.load(f)
has_key = bool(legacy_cfg.get("CIVITAI_API_KEY", "").strip())
except Exception:
pass
return web.json_response({"has_api_key": has_key, "folder_types_config": folder_types})
except:
pass
if not has_key:
legacy_config_path = os.path.join(os.path.dirname(script_dir), "config.json")
if os.path.exists(legacy_config_path):
try:
with open(legacy_config_path, 'r', encoding='utf-8') as f:
legacy_cfg = json.load(f)
has_key = bool(legacy_cfg.get("CIVITAI_API_KEY", "").strip())
except Exception:
pass
deepl_key = ""
try:
with open(config_path, 'r', encoding='utf-8') as f:
deepl_key = json.load(f).get("DEEPL_API_KEY", "")
except (OSError, ValueError, AttributeError):
pass
return web.json_response({"has_api_key": has_key, "has_deepl_key": bool(isinstance(deepl_key, str) and deepl_key.strip()),
"proxy": configured_proxy(), "sidecar_layout": _side_file_layout(), "folder_types_config": folder_types})
async def api_save_config(request):
async def api_save_config(request):
try:
data = await request.json()
api_key = data.get("api_key")
@@ -50,17 +64,32 @@ async def api_save_config(request):
except:
pass
if api_key is not None and not isinstance(api_key, str):
return web.json_response({"status": "error", "message": "Invalid API key"}, status=400)
if folder_types_config is not None and not isinstance(folder_types_config, list):
return web.json_response({"status": "error", "message": "Invalid folder configuration"}, status=400)
if "physical_folders_config" in data and not isinstance(data.get("physical_folders_config"), list):
return web.json_response({"status": "error", "message": "Invalid physical folder configuration"}, status=400)
if "folder_view_mode" in data and data.get("folder_view_mode") not in {"abstract", "physical"}:
return web.json_response({"status": "error", "message": "Invalid folder view mode"}, status=400)
if api_key is not None:
cfg["CIVITAI_API_KEY"] = api_key.strip()
deepl_key = data.get("deepl_key")
if api_key is not None and not isinstance(api_key, str):
return web.json_response({"status": "error", "message": "Invalid API key"}, status=400)
if deepl_key is not None and not isinstance(deepl_key, str):
return web.json_response({"status": "error", "message": "Invalid DeepL key"}, status=400)
proxy = None
if "proxy" in data: # Settings -> Network (net_proxy.py); "" = the system's proxy
try:
proxy = normalize_proxy(data.get("proxy"))
except ValueError as error:
return web.json_response({"status": "error", "message": "Invalid proxy", "code": str(error)}, status=400)
if folder_types_config is not None and not isinstance(folder_types_config, list):
return web.json_response({"status": "error", "message": "Invalid folder configuration"}, status=400)
if "physical_folders_config" in data and not isinstance(data.get("physical_folders_config"), list):
return web.json_response({"status": "error", "message": "Invalid physical folder configuration"}, status=400)
if "folder_view_mode" in data and data.get("folder_view_mode") not in {"abstract", "physical"}:
return web.json_response({"status": "error", "message": "Invalid folder view mode"}, status=400)
if api_key is not None:
cfg["CIVITAI_API_KEY"] = api_key.strip()
if deepl_key is not None: # Settings -> Translation (translation_routes.py)
cfg["DEEPL_API_KEY"] = deepl_key.strip()
if proxy is not None:
cfg[PROXY_KEY] = proxy
if folder_types_config is not None:
cfg["folder_types_config"] = folder_types_config
@@ -71,10 +100,10 @@ async def api_save_config(request):
if "folder_view_mode" in data:
cfg["folder_view_mode"] = data.get("folder_view_mode")
temp_path = config_path + ".tmp"
with open(temp_path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4)
os.replace(temp_path, config_path)
temp_path = config_path + ".tmp"
with open(temp_path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4)
os.replace(temp_path, config_path)
return web.json_response({"status": "ok"})
except Exception as e:
+395
View File
@@ -0,0 +1,395 @@
"""Where a model the open workflow is missing can be downloaded from, for Model Check's
"Download" (api/model_download.py fetches it).
Sources that name one exact file: Civitai by the file's hash (the workflow's fingerprint), a
Civitai model version or file link, or a direct file link on Hugging Face (or its mirror) or
GitHub, as a shared workflow carries them (AMB's model links, ComfyUI's own `properties.models`).
Then ComfyUI-Manager's model list by file name (manager_catalog.py): with the fingerprint, only
the entry whose file has it; without, the one entry of that name, marked `by_name` because a
name is no proof (the page asks before it downloads one). Each answer says the file's size and
SHA-256 when the site gives them, so the download can be checked.
"""
import json
import re
import urllib.error
import urllib.parse
import urllib.request
try:
from ..civitai_client import USER_AGENT, _load_api_key, answered, api_hosts
from ..net_proxy import urlopen
except ImportError: # loaded outside the package (tests)
from civitai_client import USER_AGENT, _load_api_key, answered, api_hosts
from net_proxy import urlopen
from . import manager_catalog
CIVITAI_HOSTS = ("civitai.com", "civitai.red")
HF_HOSTS = ("huggingface.co", "hf-mirror.com")
HF_MIRROR = "hf-mirror.com" # Hugging Face's mirror for mainland China (it sends other networks back)
OTHER_HOSTS = ("github.com",)
TIMEOUT = 15
SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
class SourceUnreachable(Exception):
"""The site did not answer (network, timeout, server error)."""
def host_of(url):
try:
parsed = urllib.parse.urlsplit(str(url).strip())
except ValueError:
return ""
return (parsed.hostname or "").lower() if parsed.scheme == "https" else ""
def _site(host, sites):
return any(host == site or host.endswith("." + site) for site in sites)
def allowed_download_url(url):
"""Whether `url` is an https link on a site downloads may start from."""
host = host_of(url)
return bool(host) and _site(host, CIVITAI_HOSTS + HF_HOSTS + OTHER_HOSTS)
def is_civitai(url):
return _site(host_of(url), CIVITAI_HOSTS)
def civitai_headers():
headers = {"User-Agent": USER_AGENT}
key = _load_api_key()
if key:
headers["Authorization"] = f"Bearer {key}"
return headers
def _get_json(url, headers):
"""The JSON at `url`; None on 404; SourceUnreachable when it could not be asked."""
try:
with urlopen(urllib.request.Request(url, headers=headers), timeout=TIMEOUT) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
if error.code in (400, 404):
return None
raise SourceUnreachable(f"HTTP {error.code}") from error
except (urllib.error.URLError, OSError, ValueError) as error:
raise SourceUnreachable(str(error)) from error
def _civitai_json(path):
"""Civitai's API answer at `path` (/api/v1/…), from whichever of its addresses answers
(civitai_client.api_hosts); None on 404."""
unreachable = None
for host in api_hosts():
try:
data = _get_json(f"https://{host}{path}", civitai_headers())
except SourceUnreachable as error:
if isinstance(error.__cause__, urllib.error.HTTPError):
raise # Civitai answered with an error: the other address is the same Civitai
unreachable = error
continue
answered(host)
return data
raise unreachable
def _civitai_page(version):
domain = "civitai.red" if (version.get("model") or {}).get("nsfw") or (version.get("nsfwLevel") or 1) > 1 else "civitai.com"
model_id = version.get("modelId")
return f"https://{domain}/models/{model_id}?modelVersionId={version.get('id')}" if model_id else ""
def _pick_civitai_file(files, file_hash="", name=""):
"""The version's file with this hash (SHA-256 or a prefix such as AutoV2; None if it has
none such). Without a hash: the file of this name, else the version's primary model file —
the link names that version, and people rename their files."""
files = [item for item in files or [] if isinstance(item, dict) and item.get("downloadUrl")]
wanted = str(file_hash or "").upper()
if wanted:
for item in files:
hashes = {str(value).upper() for value in (item.get("hashes") or {}).values()}
sha = str((item.get("hashes") or {}).get("SHA256") or "").upper()
if wanted in hashes or (len(wanted) >= 10 and sha.startswith(wanted)):
return item
return None
if name:
for item in files:
if str(item.get("name", "")).lower() == name.lower():
return item
models = [item for item in files if item.get("type", "Model") == "Model"]
return next((item for item in models if item.get("primary")), models[0] if models else None)
def _from_civitai_version(version, file_hash="", name=""):
picked = _pick_civitai_file(version.get("files"), file_hash, name)
if not picked:
return None
sha = str((picked.get("hashes") or {}).get("SHA256") or "")
return {
"found": True,
"source": "civitai",
"download_url": picked["downloadUrl"],
"file_name": picked.get("name") or "",
"size": int(float(picked.get("sizeKB") or 0) * 1024),
"sha256": sha.lower() if SHA256.match(sha) else "",
"format": ((picked.get("metadata") or {}).get("format") or ""),
"base_model": version.get("baseModel") or "",
"model_name": (version.get("model") or {}).get("name") or "",
"version_name": version.get("name") or "",
"page": _civitai_page(version),
}
def civitai_by_hash(file_hash):
version = _civitai_json(f"/api/v1/model-versions/by-hash/{urllib.parse.quote(file_hash)}")
return _from_civitai_version(version, file_hash) if version else None
def _civitai_version_id(url):
parsed = urllib.parse.urlsplit(url)
query = urllib.parse.parse_qs(parsed.query)
if query.get("modelVersionId"):
return query["modelVersionId"][0]
match = re.search(r"/api/download/models/(\d+)", parsed.path) or re.search(r"/model-versions/(\d+)", parsed.path)
return match.group(1) if match else ""
def civitai_by_link(url, name, file_hash=""):
"""A Civitai link that names a model version: its file of this hash or name."""
version_id = _civitai_version_id(url)
if not version_id.isdigit():
return None
version = _civitai_json(f"/api/v1/model-versions/{version_id}")
return _from_civitai_version(version, file_hash, name) if version else None
MAX_VERSIONS = 60 # a model page's versions listed, newest first
def civitai_versions(model_id):
"""Every version on Civitai model page `model_id`, newest first, with its primary file to download:
{name, versions: [{id, name, base_model, published, file_name, size, sha256, page}]}; None on 404.
Raises SourceUnreachable when Civitai could not be asked."""
data = _civitai_json(f"/api/v1/models/{int(model_id)}")
if not isinstance(data, dict):
return None
versions = []
for version in (data.get("modelVersions") or [])[:MAX_VERSIONS]:
if not isinstance(version, dict) or not str(version.get("id", "")).isdigit():
continue
found = _from_civitai_version({**version, "modelId": data.get("id"), "model": {"nsfw": data.get("nsfw")}}) or {}
versions.append({"id": int(version["id"]), "name": version.get("name") or "", "base_model": version.get("baseModel") or "",
"published": str(version.get("publishedAt") or version.get("createdAt") or "")[:10],
**{key: found.get(key, "") for key in ("file_name", "size", "sha256", "page")}})
versions.sort(key=lambda item: item["id"], reverse=True)
return {"name": data.get("name") or "", "versions": versions}
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
def _hf_file_url(url, host=""):
"""https://<hf>/<repo>/resolve/<rev>/<path> for a blob or resolve link to a file, else '';
on `host` (Hugging Face or its mirror) when given."""
parsed = urllib.parse.urlsplit(url)
parts = [part for part in parsed.path.split("/") if part]
for marker in ("resolve", "blob"):
if marker in parts:
at = parts.index(marker)
if at >= 2 and len(parts) > at + 2:
parts[at] = "resolve"
return urllib.parse.urlunsplit(("https", host or parsed.netloc, "/" + "/".join(parts), "", ""))
return ""
def _head(url):
"""(headers, redirected) of a HEAD request that does not follow redirects; None on 404,
"gated" on 401/403."""
opener = urllib.request.build_opener(_NoRedirect)
request = urllib.request.Request(url, method="HEAD", headers={"User-Agent": USER_AGENT})
try:
return opener.open(request, timeout=TIMEOUT).headers, False
except urllib.error.HTTPError as error:
if error.code in (401, 403):
return "gated"
if error.code == 404:
return None
if error.code not in (301, 302, 303, 307, 308):
raise SourceUnreachable(f"HTTP {error.code}") from error
return error.headers, True
except (urllib.error.URLError, OSError) as error:
raise SourceUnreachable(str(error)) from error
def hugging_face_file(url, host=""):
"""A Hugging Face file link, fetched from `host` (huggingface.co or the mirror; the link's
own when empty): its size and SHA-256 from the site's headers."""
file_url = _hf_file_url(url, host)
if not file_url:
return None
answer = _head(file_url)
if isinstance(answer, tuple) and answer[1] and not answer[0].get("x-linked-size"):
# Sent on to the other Hugging Face host (the mirror does that outside China): ask there.
location = urllib.parse.urljoin(file_url, answer[0].get("Location") or "")
if _site(host_of(location), HF_HOSTS) and host_of(location) != host_of(file_url):
answer = _head(location)
if answer is None:
return None
if answer == "gated":
# The repository's page, where the user logs in and accepts its terms.
parts = urllib.parse.urlsplit(file_url).path.strip("/").split("/")
return {"found": False, "reason": "gated", "page": f"https://huggingface.co/{'/'.join(parts[:2])}"}
headers, redirected = answer
etag = str(headers.get("x-linked-etag") or headers.get("etag") or "").strip('"').replace("W/", "").strip('"')
# A redirect's own length is not the file's: without x-linked-size (small files kept
# outside large-file storage) the size is unknown.
size = headers.get("x-linked-size") or (0 if redirected else headers.get("content-length")) or 0
return {
"found": True,
"source": "huggingface",
"mirror": host_of(file_url) == HF_MIRROR,
"download_url": file_url,
"file_name": urllib.parse.unquote(file_url.rsplit("/", 1)[-1]),
"size": int(size) if str(size).isdigit() else 0,
"sha256": etag.lower() if SHA256.match(etag) else "",
"format": "",
"base_model": "",
"model_name": "",
"version_name": "",
"page": url,
}
def plain_link(url):
"""A direct file link on GitHub (a release asset, or a file in a repository, fetched raw): no
size or hash to check against."""
parsed = urllib.parse.urlsplit(url)
path = parsed.path
if "/blob/" in path: # the page about the file; its raw form is the file
path = path.replace("/blob/", "/raw/", 1)
if "/releases/download/" not in path and "/raw/" not in path:
return None
return {
"found": True, "source": "link", "download_url": urllib.parse.urlunsplit(("https", parsed.netloc, path, "", "")),
"file_name": urllib.parse.unquote(path.rsplit("/", 1)[-1]), "size": 0, "sha256": "",
"format": "", "base_model": "", "model_name": "", "version_name": "", "page": url,
}
def same_file(expected, sha256):
"""Whether a fingerprint (full SHA-256 or a prefix of 10+, AutoV2) names this SHA-256."""
expected, sha256 = str(expected or "").lower(), str(sha256 or "").lower()
return len(expected) >= 10 and sha256.startswith(expected)
def _is_sha_prefix(value):
return len(value) >= 10 and bool(re.fullmatch(r"[0-9a-fA-F]+", value))
class _Sites:
"""Which sites stopped answering during one lookup, so the next models skip only those
(offline in mainland China, Civitai fails while Hugging Face through its mirror works)."""
def __init__(self, down=None):
self.down = down if down is not None else set()
self.missed = False
def ask(self, site, call, *args):
if site in self.down:
self.missed = True
return None
try:
return call(*args)
except SourceUnreachable:
self.down.add(site)
self.missed = True
return None
def _by_link(sites, url, name, file_hash, hf_host):
host = host_of(url)
if _site(host, CIVITAI_HOSTS):
return sites.ask("civitai", civitai_by_link, url, name, file_hash)
if _site(host, HF_HOSTS):
return sites.ask("huggingface", hugging_face_file, url, hf_host)
if _site(host, OTHER_HOSTS):
return plain_link(url)
return None
def _from_manager_list(sites, name, folder_type, file_hash, hf_host):
"""A file of this name in ComfyUI-Manager's model list. With the workflow's fingerprint,
only an entry whose file has that SHA-256 (several of the same name are each asked); without,
the one entry of that name, marked `by_name` (nothing can tell it is the same file)."""
entries = manager_catalog.candidates(name, folder_type)
if not entries:
return None
if _is_sha_prefix(file_hash):
for entry in entries[:6]:
found = _by_link(sites, entry["url"], name, "", hf_host)
if found and found.get("found") and same_file(file_hash, found.get("sha256")):
return {**found, "via": "manager_list", "list_name": entry["name"], "by_name": False,
"list_base": entry["base"], "list_site": manager_catalog.site_of(entry["url"])}
return None
if len(entries) != 1:
return None # several files of that name: which one is unknowable
found = _by_link(sites, entries[0]["url"], name, "", hf_host)
if found and found.get("found"):
return {**found, "via": "manager_list", "list_name": entries[0]["name"], "by_name": True,
"base_model": found.get("base_model") or entries[0]["base"],
"list_base": entries[0]["base"], "list_site": manager_catalog.site_of(entries[0]["url"])}
return found
def _same_file_on_hugging_face(sites, civitai, url, name, folder_type, hf_host):
"""With the mirror on (mainland China, where Civitai is slow or out of reach and some of its
files need a login), the same file (same SHA-256) on Hugging Face, from the workflow's link
or ComfyUI-Manager's list, is fetched there instead. Civitai's name, base model and page stay."""
sha = civitai.get("sha256") or ""
if not SHA256.match(sha):
return civitai
other = None
if url and _site(host_of(url), HF_HOSTS):
other = _by_link(sites, url, name, "", hf_host)
if not (other and other.get("found") and same_file(sha, other.get("sha256"))):
other = None
if other is None and name:
other = _from_manager_list(sites, name, folder_type, sha, hf_host)
if not other:
return civitai
keep = {key: civitai[key] for key in ("base_model", "model_name", "version_name", "page") if civitai.get(key)}
return {**other, **keep, "via": other.get("via", ""), "also_on": "civitai"}
def find_source(file_hash="", url="", name="", hf_mirror=False, folder_type="", down=None):
"""Where one missing model can come from: {found, source, download_url, file_name, size,
sha256, base_model, model_name, version_name, page}, or {found: False, reason}. Asked in
turn: Civitai by the fingerprint, the workflow's link, ComfyUI-Manager's model list by file
name. With `hf_mirror`, Hugging Face files come from its mirror (and without, never from it).
`down` (a set kept across one lookup) names the sites that stopped answering."""
sites = _Sites(down)
hf_host = HF_MIRROR if hf_mirror else "huggingface.co"
looked = False
found = None
if file_hash:
looked = True
found = sites.ask("civitai", civitai_by_hash, file_hash)
if not found and url and host_of(url):
looked = True
found = _by_link(sites, url, name, file_hash, hf_host)
if not (found and found.get("found")) and name:
listed = _from_manager_list(sites, name, folder_type, file_hash, hf_host)
looked = looked or listed is not None or bool(manager_catalog.candidates(name, folder_type))
found = listed or found
if found and found.get("found") and found.get("source") == "civitai" and hf_mirror:
found = _same_file_on_hugging_face(sites, found, url, name, folder_type, hf_host)
if found:
return found
if sites.missed:
return {"found": False, "reason": "network"}
return {"found": False, "reason": "not_found" if looked else "no_source"}
+145
View File
@@ -0,0 +1,145 @@
"""The example workflows Anomalous ships (api/examples/<id>.json), because a new install has no cards of
its own: plans (title), each in a version per model family (version) with a note (about). Most use only
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."""
import asyncio
import json
import os
import folder_paths
from aiohttp import web
from . import 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")
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"}
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
PICTURES = (".png", ".jpg", ".jpeg", ".webp")
def _load():
examples = {}
for key in ORDER:
with open(os.path.join(FOLDER, f"{key}.json"), encoding="utf-8") as file:
examples[key] = json.load(file)
return examples
EXAMPLES = _load()
def _here():
"""The loaders' drop-downs, in the form reference_blocks reads /object_info; 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}
def _model(info, kind, field, family):
"""The first file loader `kind` offers of `family` (those named for it looked at first), 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]), "")
def _picture():
"""A picture in ComfyUI's input folder, its own example if that is there, else ""."""
try:
names = sorted(name for name in os.listdir(folder_paths.get_input_directory()) if name.lower().endswith(PICTURES))
except OSError:
return ""
return "example.png" if "example.png" in names else names[0] if names else ""
def _fills(info):
"""{node type: the file it takes here} for the examples' loaders, "" for what is not here."""
from . import reference_blocks
checkpoints = reference_blocks._choices(info, "CheckpointLoaderSimple", "ckpt_name")
fills = {"CheckpointLoaderSimple": _model(info, "CheckpointLoaderSimple", "ckpt_name", "sd") or next(iter(checkpoints), ""),
"UNETLoader": _model(info, "UNETLoader", "unet_name", "anima"), "LoadImage": _picture()}
for kind, field, marks in reference_blocks.BLOCKS["picture"]["variants"]["anima"]["files"].values():
fills[kind] = reference_blocks._file_like(info, kind, field, marks) or ""
try:
fills["AnimaLLLiteApply_sdscripts"] = reference_blocks._lllite(info, "tile")
except reference_blocks.MissingModel:
fills["AnimaLLLiteApply_sdscripts"] = ""
return fills
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]
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)]
def read(example_id):
"""Example `example_id` on files that are here (those missing stay blank), or None when there is no such example."""
example = EXAMPLES.get(example_id)
if not example:
return None
fills = _fills(_here())
workflow = json.loads(json.dumps(example["workflow"]))
for node in workflow["nodes"]:
if node["type"] in fills:
node["widgets_values"][0] = fills[node["type"]]
return {"name": example["name"], "workflow": workflow}
def listing():
"""What the page's window shows of each example, and what it lacks here."""
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()]
def for_ai():
"""The examples that can run here, as list_workflows gives saved workflows."""
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"]}
for item in EXAMPLES.values() if not _lacks(item, info, fills)]
def _short(card):
"""A saved workflow in a longer list: what tells it apart."""
shown = {key: value for key, value in card.items() if value and key not in ("settings", "saved")}
return {**shown, "notes": shown["notes"][:SHORT_NOTES]} if "notes" in shown else shown
def answer(found, fits, limit):
"""What list_workflows gives: the saved workflows `found`, cut short when they are many, or else the examples
that `fits`. The other examples go by name, so an AI with no card for the job knows of them."""
examples = for_ai()
fitting = [] if found else [item for item in examples if fits(item)]
if len(found) > MAX_WITH_SETTINGS:
found = [_short(card) for card in found]
listed = found or fitting
others = [item["name"] for item in examples if item not in fitting]
return {"total_matches": len(listed), "workflows": listed[:limit], **({"examples": others} if others else {})}
def file_of(name):
"""The file name open_workflow and run_workflow take for the example called `name`, or None."""
return next((PREFIX + key for key, item in EXAMPLES.items() if item["name"] == name), None)
async def api_examples(request):
"""GET /anomalous/example_workflows: the list; ?id=<id>: one, in `data` as /anomalous/recipe_full gives a card."""
if "id" not in request.query:
return web.json_response({"examples": await asyncio.to_thread(listing)})
data = await asyncio.to_thread(read, request.query["id"])
return web.json_response({"data": data}) if data else web.json_response({"error": "not_found"}, status=404)
+578
View File
@@ -0,0 +1,578 @@
{
"name": "Example: picture to picture",
"title": {
"zh": "图生图",
"en": "Picture to picture"
},
"about": {
"zh": "从一张图出发,按提示词改。在 Load Image 节点选图;denoise 越高,改得越多。",
"en": "Starts from a picture and changes it by the prompt. Pick it in the Load Image node; a higher denoise changes more."
},
"ai": "Redraws a picture (img2img) at denoise 0.6. open_workflow, set_model on its loader, then run_workflow with the prompt and image.",
"version": {
"zh": "SDXL / SD 1.5",
"en": "SDXL / SD 1.5"
},
"family": "sd",
"workflow": {
"id": "00000000-0000-0000-0000-000000000000",
"revision": 0,
"last_node_id": 8,
"last_link_id": 11,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [
40,
120
],
"size": [
273.4576171875,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "Checkpoint名称",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": null
}
],
"outputs": [
{
"localized_name": "模型",
"name": "MODEL",
"type": "MODEL",
"links": [
5
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"links": [
1,
2
]
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [
4,
10
]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
""
]
},
{
"id": 2,
"type": "CLIPTextEncode",
"pos": [
440,
60
],
"size": [
400,
200
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 1
},
{
"localized_name": "文本",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "条件",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
6
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"a quiet lake at sunrise, mountains in the distance, soft morning light, detailed"
]
},
{
"id": 3,
"type": "CLIPTextEncode",
"pos": [
440,
300
],
"size": [
400,
200
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 2
},
{
"localized_name": "文本",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "条件",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
7
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"low quality, blurry, watermark, text"
]
},
{
"id": 4,
"type": "LoadImage",
"pos": [
40,
420
],
"size": [
270,
102
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "图像",
"name": "image",
"type": "COMBO",
"widget": {
"name": "image"
},
"link": null
},
{
"localized_name": "选择文件上传",
"name": "upload",
"type": "IMAGEUPLOAD",
"widget": {
"name": "upload"
},
"link": null
}
],
"outputs": [
{
"localized_name": "图像",
"name": "IMAGE",
"type": "IMAGE",
"links": [
3
]
},
{
"localized_name": "遮罩",
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"",
"image"
]
},
{
"id": 5,
"type": "VAEEncode",
"pos": [
440,
560
],
"size": [
140,
46
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "像素",
"name": "pixels",
"type": "IMAGE",
"link": 3
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 4
}
],
"outputs": [
{
"localized_name": "Latent",
"name": "LATENT",
"type": "LATENT",
"links": [
8
]
}
],
"properties": {
"Node name for S&R": "VAEEncode"
}
},
{
"id": 6,
"type": "KSampler",
"pos": [
860,
120
],
"size": [
270,
262
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "模型",
"name": "model",
"type": "MODEL",
"link": 5
},
{
"localized_name": "正面条件",
"name": "positive",
"type": "CONDITIONING",
"link": 6
},
{
"localized_name": "负面条件",
"name": "negative",
"type": "CONDITIONING",
"link": 7
},
{
"localized_name": "Latent图像",
"name": "latent_image",
"type": "LATENT",
"link": 8
},
{
"localized_name": "种子",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": null
},
{
"localized_name": "步数",
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": null
},
{
"localized_name": "cfg",
"name": "cfg",
"type": "FLOAT",
"widget": {
"name": "cfg"
},
"link": null
},
{
"localized_name": "采样器名称",
"name": "sampler_name",
"type": "COMBO",
"widget": {
"name": "sampler_name"
},
"link": null
},
{
"localized_name": "调度器",
"name": "scheduler",
"type": "COMBO",
"widget": {
"name": "scheduler"
},
"link": null
},
{
"localized_name": "降噪",
"name": "denoise",
"type": "FLOAT",
"widget": {
"name": "denoise"
},
"link": null
}
],
"outputs": [
{
"localized_name": "Latent",
"name": "LATENT",
"type": "LATENT",
"links": [
9
]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
28,
5.5,
"euler_ancestral",
"normal",
0.6
]
},
{
"id": 7,
"type": "VAEDecode",
"pos": [
1240,
120
],
"size": [
140,
46
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "Latent",
"name": "samples",
"type": "LATENT",
"link": 9
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 10
}
],
"outputs": [
{
"localized_name": "图像",
"name": "IMAGE",
"type": "IMAGE",
"links": [
11
]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"id": 8,
"type": "SaveImage",
"pos": [
1500,
120
],
"size": [
270,
58
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "图片",
"name": "images",
"type": "IMAGE",
"link": 11
},
{
"localized_name": "文件名前缀",
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"links": null
}
],
"properties": {},
"widgets_values": [
"example"
]
}
],
"links": [
[
1,
1,
1,
2,
0,
"CLIP"
],
[
2,
1,
1,
3,
0,
"CLIP"
],
[
3,
4,
0,
5,
0,
"IMAGE"
],
[
4,
1,
2,
5,
1,
"VAE"
],
[
5,
1,
0,
6,
0,
"MODEL"
],
[
6,
2,
0,
6,
1,
"CONDITIONING"
],
[
7,
3,
0,
6,
2,
"CONDITIONING"
],
[
8,
5,
0,
6,
3,
"LATENT"
],
[
9,
6,
0,
7,
0,
"LATENT"
],
[
10,
1,
2,
7,
1,
"VAE"
],
[
11,
7,
0,
8,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"version": 0.4,
"extra": {}
}
}
+638
View File
@@ -0,0 +1,638 @@
{
"name": "Example: repaint an area",
"title": {
"zh": "局部重绘",
"en": "Repaint an area"
},
"about": {
"zh": "只重画图上的一块。在 Load Image 节点选图,右键打开遮罩编辑器,涂出要改的地方。",
"en": "Redraws one part of a picture. Pick it in the Load Image node, right-click to open the mask editor and paint the part to change."
},
"ai": "Redraws the area masked on the Load Image node (inpaint). The user paints that mask; to repaint for them yourself, add_nodes(use='repaint') is better.",
"version": {
"zh": "SDXL / SD 1.5",
"en": "SDXL / SD 1.5"
},
"family": "sd",
"workflow": {
"id": "00000000-0000-0000-0000-000000000000",
"revision": 0,
"last_node_id": 9,
"last_link_id": 13,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [
40,
120
],
"size": [
273.4576171875,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "Checkpoint名称",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": null
}
],
"outputs": [
{
"localized_name": "模型",
"name": "MODEL",
"type": "MODEL",
"links": [
7
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"links": [
1,
2
]
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [
4,
12
]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
""
]
},
{
"id": 2,
"type": "CLIPTextEncode",
"pos": [
440,
60
],
"size": [
400,
200
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 1
},
{
"localized_name": "文本",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "条件",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
8
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"a quiet lake at sunrise, mountains in the distance, soft morning light, detailed"
]
},
{
"id": 3,
"type": "CLIPTextEncode",
"pos": [
440,
300
],
"size": [
400,
200
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 2
},
{
"localized_name": "文本",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "条件",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
9
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"low quality, blurry, watermark, text"
]
},
{
"id": 4,
"type": "LoadImage",
"pos": [
40,
420
],
"size": [
270,
102
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "图像",
"name": "image",
"type": "COMBO",
"widget": {
"name": "image"
},
"link": null
},
{
"localized_name": "选择文件上传",
"name": "upload",
"type": "IMAGEUPLOAD",
"widget": {
"name": "upload"
},
"link": null
}
],
"outputs": [
{
"localized_name": "图像",
"name": "IMAGE",
"type": "IMAGE",
"links": [
3
]
},
{
"localized_name": "遮罩",
"name": "MASK",
"type": "MASK",
"links": [
6
]
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"",
"image"
]
},
{
"id": 5,
"type": "VAEEncode",
"pos": [
440,
560
],
"size": [
140,
46
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "像素",
"name": "pixels",
"type": "IMAGE",
"link": 3
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 4
}
],
"outputs": [
{
"localized_name": "Latent",
"name": "LATENT",
"type": "LATENT",
"links": [
5
]
}
],
"properties": {
"Node name for S&R": "VAEEncode"
}
},
{
"id": 6,
"type": "SetLatentNoiseMask",
"pos": [
660,
560
],
"size": [
165.134375,
46
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "Latent",
"name": "samples",
"type": "LATENT",
"link": 5
},
{
"localized_name": "遮罩",
"name": "mask",
"type": "MASK",
"link": 6
}
],
"outputs": [
{
"localized_name": "Latent",
"name": "LATENT",
"type": "LATENT",
"links": [
10
]
}
],
"properties": {
"Node name for S&R": "SetLatentNoiseMask"
}
},
{
"id": 7,
"type": "KSampler",
"pos": [
960,
120
],
"size": [
270,
262
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"localized_name": "模型",
"name": "model",
"type": "MODEL",
"link": 7
},
{
"localized_name": "正面条件",
"name": "positive",
"type": "CONDITIONING",
"link": 8
},
{
"localized_name": "负面条件",
"name": "negative",
"type": "CONDITIONING",
"link": 9
},
{
"localized_name": "Latent图像",
"name": "latent_image",
"type": "LATENT",
"link": 10
},
{
"localized_name": "种子",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": null
},
{
"localized_name": "步数",
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": null
},
{
"localized_name": "cfg",
"name": "cfg",
"type": "FLOAT",
"widget": {
"name": "cfg"
},
"link": null
},
{
"localized_name": "采样器名称",
"name": "sampler_name",
"type": "COMBO",
"widget": {
"name": "sampler_name"
},
"link": null
},
{
"localized_name": "调度器",
"name": "scheduler",
"type": "COMBO",
"widget": {
"name": "scheduler"
},
"link": null
},
{
"localized_name": "降噪",
"name": "denoise",
"type": "FLOAT",
"widget": {
"name": "denoise"
},
"link": null
}
],
"outputs": [
{
"localized_name": "Latent",
"name": "LATENT",
"type": "LATENT",
"links": [
11
]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
28,
5.5,
"euler_ancestral",
"normal",
0.6
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [
1340,
120
],
"size": [
140,
46
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "Latent",
"name": "samples",
"type": "LATENT",
"link": 11
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 12
}
],
"outputs": [
{
"localized_name": "图像",
"name": "IMAGE",
"type": "IMAGE",
"links": [
13
]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"id": 9,
"type": "SaveImage",
"pos": [
1600,
120
],
"size": [
270,
58
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "图片",
"name": "images",
"type": "IMAGE",
"link": 13
},
{
"localized_name": "文件名前缀",
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"links": null
}
],
"properties": {},
"widgets_values": [
"example"
]
}
],
"links": [
[
1,
1,
1,
2,
0,
"CLIP"
],
[
2,
1,
1,
3,
0,
"CLIP"
],
[
3,
4,
0,
5,
0,
"IMAGE"
],
[
4,
1,
2,
5,
1,
"VAE"
],
[
5,
5,
0,
6,
0,
"LATENT"
],
[
6,
4,
1,
6,
1,
"MASK"
],
[
7,
1,
0,
7,
0,
"MODEL"
],
[
8,
2,
0,
7,
1,
"CONDITIONING"
],
[
9,
3,
0,
7,
2,
"CONDITIONING"
],
[
10,
6,
0,
7,
3,
"LATENT"
],
[
11,
7,
0,
8,
0,
"LATENT"
],
[
12,
1,
2,
8,
1,
"VAE"
],
[
13,
8,
0,
9,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"version": 0.4,
"extra": {}
}
}
+523
View File
@@ -0,0 +1,523 @@
{
"name": "Example: text to picture",
"title": {
"zh": "文生图",
"en": "Text to picture"
},
"about": {
"zh": "最基础的一条路:写提示词,出一张图。",
"en": "The plainest route: write a prompt, get a picture."
},
"ai": "One pass from words (txt2img). open_workflow, set_model on its loader, then run_workflow with the prompt.",
"version": {
"zh": "SDXL / SD 1.5",
"en": "SDXL / SD 1.5"
},
"family": "sd",
"workflow": {
"id": "00000000-0000-0000-0000-000000000000",
"revision": 0,
"last_node_id": 7,
"last_link_id": 9,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [
40,
120
],
"size": [
273.4576171875,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "Checkpoint名称",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": null
}
],
"outputs": [
{
"localized_name": "模型",
"name": "MODEL",
"type": "MODEL",
"links": [
3
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"links": [
1,
2
]
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [
8
]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
""
]
},
{
"id": 2,
"type": "CLIPTextEncode",
"pos": [
440,
60
],
"size": [
400,
200
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 1
},
{
"localized_name": "文本",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "条件",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
4
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"a quiet lake at sunrise, mountains in the distance, soft morning light, detailed"
]
},
{
"id": 3,
"type": "CLIPTextEncode",
"pos": [
440,
300
],
"size": [
400,
200
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 2
},
{
"localized_name": "文本",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "条件",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
5
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"low quality, blurry, watermark, text"
]
},
{
"id": 4,
"type": "EmptyLatentImage",
"pos": [
440,
540
],
"size": [
270,
106
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "宽度",
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": null
},
{
"localized_name": "高度",
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": null
},
{
"localized_name": "批量大小",
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": null
}
],
"outputs": [
{
"localized_name": "Latent",
"name": "LATENT",
"type": "LATENT",
"links": [
6
]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [
832,
1216,
1
]
},
{
"id": 5,
"type": "KSampler",
"pos": [
860,
120
],
"size": [
270,
262
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "模型",
"name": "model",
"type": "MODEL",
"link": 3
},
{
"localized_name": "正面条件",
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"localized_name": "负面条件",
"name": "negative",
"type": "CONDITIONING",
"link": 5
},
{
"localized_name": "Latent图像",
"name": "latent_image",
"type": "LATENT",
"link": 6
},
{
"localized_name": "种子",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": null
},
{
"localized_name": "步数",
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": null
},
{
"localized_name": "cfg",
"name": "cfg",
"type": "FLOAT",
"widget": {
"name": "cfg"
},
"link": null
},
{
"localized_name": "采样器名称",
"name": "sampler_name",
"type": "COMBO",
"widget": {
"name": "sampler_name"
},
"link": null
},
{
"localized_name": "调度器",
"name": "scheduler",
"type": "COMBO",
"widget": {
"name": "scheduler"
},
"link": null
},
{
"localized_name": "降噪",
"name": "denoise",
"type": "FLOAT",
"widget": {
"name": "denoise"
},
"link": null
}
],
"outputs": [
{
"localized_name": "Latent",
"name": "LATENT",
"type": "LATENT",
"links": [
7
]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
28,
5.5,
"euler_ancestral",
"normal",
1
]
},
{
"id": 6,
"type": "VAEDecode",
"pos": [
1240,
120
],
"size": [
140,
46
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "Latent",
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"localized_name": "图像",
"name": "IMAGE",
"type": "IMAGE",
"links": [
9
]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"id": 7,
"type": "SaveImage",
"pos": [
1500,
120
],
"size": [
270,
58
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "图片",
"name": "images",
"type": "IMAGE",
"link": 9
},
{
"localized_name": "文件名前缀",
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"links": null
}
],
"properties": {},
"widgets_values": [
"example"
]
}
],
"links": [
[
1,
1,
1,
2,
0,
"CLIP"
],
[
2,
1,
1,
3,
0,
"CLIP"
],
[
3,
1,
0,
5,
0,
"MODEL"
],
[
4,
2,
0,
5,
1,
"CONDITIONING"
],
[
5,
3,
0,
5,
2,
"CONDITIONING"
],
[
6,
4,
0,
5,
3,
"LATENT"
],
[
7,
5,
0,
6,
0,
"LATENT"
],
[
8,
1,
2,
6,
1,
"VAE"
],
[
9,
6,
0,
7,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"version": 0.4,
"extra": {}
}
}
+741
View File
@@ -0,0 +1,741 @@
{
"name": "Example: two passes",
"title": {
"zh": "两段放大",
"en": "Two passes"
},
"about": {
"zh": "只用官方节点:先出一张图,放大 1.25 倍再重绘一遍。这是最简单的两段;想用放大模型或 Tile 锁图,用“做法”里的放大。",
"en": "ComfyUI's own nodes only: a picture, then enlarged 1.25x and redrawn. The plainest two passes; for an upscale model or a Tile lock, use the upscale ways in Ways."
},
"ai": "A pass, then the latent enlarged 1.25x and redrawn at denoise 0.5. open_workflow, set_model on its loader, then run_workflow with the prompt. For an upscale model or a Tile lock, add_nodes(use='upscale') on a one-pass canvas does better.",
"version": {
"zh": "SDXL / SD 1.5",
"en": "SDXL / SD 1.5"
},
"family": "sd",
"workflow": {
"id": "00000000-0000-0000-0000-000000000000",
"revision": 0,
"last_node_id": 9,
"last_link_id": 14,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [
40,
120
],
"size": [
273.4576171875,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "Checkpoint名称",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": null
}
],
"outputs": [
{
"localized_name": "模型",
"name": "MODEL",
"type": "MODEL",
"links": [
3,
8
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"links": [
1,
2
]
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"links": [
13
]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
""
]
},
{
"id": 2,
"type": "CLIPTextEncode",
"pos": [
440,
60
],
"size": [
400,
200
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 1
},
{
"localized_name": "文本",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "条件",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
4,
9
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"a quiet lake at sunrise, mountains in the distance, soft morning light, detailed"
]
},
{
"id": 3,
"type": "CLIPTextEncode",
"pos": [
440,
300
],
"size": [
400,
200
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 2
},
{
"localized_name": "文本",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "条件",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
5,
10
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"low quality, blurry, watermark, text"
]
},
{
"id": 4,
"type": "EmptyLatentImage",
"pos": [
440,
540
],
"size": [
270,
106
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "宽度",
"name": "width",
"type": "INT",
"widget": {
"name": "width"
},
"link": null
},
{
"localized_name": "高度",
"name": "height",
"type": "INT",
"widget": {
"name": "height"
},
"link": null
},
{
"localized_name": "批量大小",
"name": "batch_size",
"type": "INT",
"widget": {
"name": "batch_size"
},
"link": null
}
],
"outputs": [
{
"localized_name": "Latent",
"name": "LATENT",
"type": "LATENT",
"links": [
6
]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [
832,
1216,
1
]
},
{
"id": 5,
"type": "KSampler",
"pos": [
860,
120
],
"size": [
270,
262
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "模型",
"name": "model",
"type": "MODEL",
"link": 3
},
{
"localized_name": "正面条件",
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"localized_name": "负面条件",
"name": "negative",
"type": "CONDITIONING",
"link": 5
},
{
"localized_name": "Latent图像",
"name": "latent_image",
"type": "LATENT",
"link": 6
},
{
"localized_name": "种子",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": null
},
{
"localized_name": "步数",
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": null
},
{
"localized_name": "cfg",
"name": "cfg",
"type": "FLOAT",
"widget": {
"name": "cfg"
},
"link": null
},
{
"localized_name": "采样器名称",
"name": "sampler_name",
"type": "COMBO",
"widget": {
"name": "sampler_name"
},
"link": null
},
{
"localized_name": "调度器",
"name": "scheduler",
"type": "COMBO",
"widget": {
"name": "scheduler"
},
"link": null
},
{
"localized_name": "降噪",
"name": "denoise",
"type": "FLOAT",
"widget": {
"name": "denoise"
},
"link": null
}
],
"outputs": [
{
"localized_name": "Latent",
"name": "LATENT",
"type": "LATENT",
"links": [
7
]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
28,
5.5,
"euler_ancestral",
"normal",
1
]
},
{
"id": 6,
"type": "LatentUpscaleBy",
"pos": [
1240,
120
],
"size": [
270,
82
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "Latent",
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"localized_name": "缩放算法",
"name": "upscale_method",
"type": "COMBO",
"widget": {
"name": "upscale_method"
},
"link": null
},
{
"localized_name": "缩放比例",
"name": "scale_by",
"type": "FLOAT",
"widget": {
"name": "scale_by"
},
"link": null
}
],
"outputs": [
{
"localized_name": "Latent",
"name": "LATENT",
"type": "LATENT",
"links": [
11
]
}
],
"properties": {
"Node name for S&R": "LatentUpscaleBy"
},
"widgets_values": [
"nearest-exact",
1.25
]
},
{
"id": 7,
"type": "KSampler",
"pos": [
1560,
120
],
"size": [
270,
262
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"localized_name": "模型",
"name": "model",
"type": "MODEL",
"link": 8
},
{
"localized_name": "正面条件",
"name": "positive",
"type": "CONDITIONING",
"link": 9
},
{
"localized_name": "负面条件",
"name": "negative",
"type": "CONDITIONING",
"link": 10
},
{
"localized_name": "Latent图像",
"name": "latent_image",
"type": "LATENT",
"link": 11
},
{
"localized_name": "种子",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": null
},
{
"localized_name": "步数",
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": null
},
{
"localized_name": "cfg",
"name": "cfg",
"type": "FLOAT",
"widget": {
"name": "cfg"
},
"link": null
},
{
"localized_name": "采样器名称",
"name": "sampler_name",
"type": "COMBO",
"widget": {
"name": "sampler_name"
},
"link": null
},
{
"localized_name": "调度器",
"name": "scheduler",
"type": "COMBO",
"widget": {
"name": "scheduler"
},
"link": null
},
{
"localized_name": "降噪",
"name": "denoise",
"type": "FLOAT",
"widget": {
"name": "denoise"
},
"link": null
}
],
"outputs": [
{
"localized_name": "Latent",
"name": "LATENT",
"type": "LATENT",
"links": [
12
]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
28,
5.5,
"euler_ancestral",
"normal",
0.5
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [
1940,
120
],
"size": [
140,
46
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "Latent",
"name": "samples",
"type": "LATENT",
"link": 12
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 13
}
],
"outputs": [
{
"localized_name": "图像",
"name": "IMAGE",
"type": "IMAGE",
"links": [
14
]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"id": 9,
"type": "SaveImage",
"pos": [
2200,
120
],
"size": [
270,
58
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "图片",
"name": "images",
"type": "IMAGE",
"link": 14
},
{
"localized_name": "文件名前缀",
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"links": null
}
],
"properties": {},
"widgets_values": [
"example"
]
}
],
"links": [
[
1,
1,
1,
2,
0,
"CLIP"
],
[
2,
1,
1,
3,
0,
"CLIP"
],
[
3,
1,
0,
5,
0,
"MODEL"
],
[
4,
2,
0,
5,
1,
"CONDITIONING"
],
[
5,
3,
0,
5,
2,
"CONDITIONING"
],
[
6,
4,
0,
5,
3,
"LATENT"
],
[
7,
5,
0,
6,
0,
"LATENT"
],
[
8,
1,
0,
7,
0,
"MODEL"
],
[
9,
2,
0,
7,
1,
"CONDITIONING"
],
[
10,
3,
0,
7,
2,
"CONDITIONING"
],
[
11,
6,
0,
7,
3,
"LATENT"
],
[
12,
7,
0,
8,
0,
"LATENT"
],
[
13,
1,
2,
8,
1,
"VAE"
],
[
14,
8,
0,
9,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"version": 0.4,
"extra": {}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+41 -4
View File
@@ -1,4 +1,9 @@
"""Configured model-folder visibility and scan-scope helpers."""
"""Configured model-folder visibility and scan-scope helpers.
Shown folders are also the ones scans read, so a workflow can carry each model's fingerprint
only for them. Text encoders, CLIP vision and upscalers are shown by default since
2026-10 (FOLDER_DEFAULTS_VERSION 2); an older config.json gets them turned on once.
"""
import json
import os
@@ -6,12 +11,44 @@ import os
from aiohttp import web
import folder_paths
from .path_utils import atomic_write_json
DEFAULT_TYPES = ['checkpoints', 'loras', 'diffusion_models', 'unet', 'controlnet', 'vae',
'text_encoders', 'clip', 'clip_vision', 'upscale_models']
# Added to the defaults in version 2 (the components a workflow's fingerprints need most).
ADDED_IN_2 = {'text_encoders', 'clip', 'clip_vision', 'upscale_models'}
FOLDER_DEFAULTS_VERSION = 2
DEFAULT_PHYSICAL_FOLDER_NAMES = {
'checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae'
'checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae',
'text_encoders', 'clip', 'clip_vision', 'upscale_models',
}
def config_path():
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
def upgrade_folder_defaults(path=None):
"""Once per config.json: the folders added to the defaults are shown (and scanned) even
where an earlier version saved them as hidden. True when the file changed."""
path = path or config_path()
try:
with open(path, encoding='utf-8') as source:
cfg = json.load(source)
except (OSError, ValueError):
return False # no config yet: the defaults apply as they are
if not isinstance(cfg, dict) or int(cfg.get("folder_defaults_version") or 1) >= FOLDER_DEFAULTS_VERSION:
return False
for key in ("folder_types_config", "physical_folders_config"):
for item in cfg.get(key) or []:
if isinstance(item, dict) and item.get("type") in ADDED_IN_2:
item["visible"] = True
cfg["folder_defaults_version"] = FOLDER_DEFAULTS_VERSION
atomic_write_json(path, cfg)
return True
def get_folder_view_mode():
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "config.json")
@@ -149,7 +186,7 @@ def get_active_folder_types():
config_path = os.path.join(script_dir, "config.json")
all_types = list(folder_paths.folder_names_and_paths.keys())
default_types = ['checkpoints', 'loras', 'diffusion_models', 'unet', 'controlnet', 'vae']
default_types = DEFAULT_TYPES
try:
if os.path.exists(config_path):
@@ -215,7 +252,7 @@ async def api_get_all_folder_types(request):
else:
# Abstract mode
all_types = list(folder_paths.folder_names_and_paths.keys())
default_types = ['checkpoints', 'loras', 'diffusion_models', 'controlnet', 'vae']
default_types = DEFAULT_TYPES
configured_types = set()
try:
if os.path.exists(config_path):
+104 -13
View File
@@ -9,13 +9,29 @@ from aiohttp import web
import folder_paths
from .image_search import filter_images
from .media_routes import CARD_THUMBNAIL_STATIC_EXTENSIONS, _build_card_thumbnail
from .media_routes import (
CARD_POSTER_VIDEO_EXTENSIONS,
CARD_THUMBNAIL_STATIC_EXTENSIONS,
_build_card_thumbnail,
_build_video_poster,
picture_placeholder,
picture_size,
shrunk_in_child,
)
from .path_utils import require_filename, resolve_within
from .trash import move_to_trash, trash_failure
GALLERY_SNAPSHOT_SECONDS = 10
MAX_CACHED_GALLERY_IMAGES = 50_000
GALLERY_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif'}
# Videos a browser plays in the viewer; not .avi (Chrome and Edge cannot).
GALLERY_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov', '.mkv'}
GALLERY_MEDIA = ("all", "image", "video")
BIG_FILE_BYTES = 32 * 1024 * 1024 # from this size the listing reads a picture's pixel size
PREVIEW_EDGE = 4096
PREVIEW_PIXELS = 36_000_000 # past this (or PREVIEW_BYTES) the viewers show a PREVIEW_EDGE copy
PREVIEW_BYTES = 64 * 1024 * 1024
_gallery_snapshot_lock = threading.Lock()
_gallery_snapshot = {"root": None, "expires": 0, "images": []}
@@ -24,18 +40,28 @@ def _collect_gallery_images(output_dir):
images = []
for root, _, files in os.walk(output_dir):
for filename in files:
if os.path.splitext(filename)[1].lower() not in {'.png', '.jpg', '.jpeg', '.webp', '.gif'}:
ext = os.path.splitext(filename)[1].lower()
if ext not in GALLERY_IMAGE_EXTENSIONS and ext not in GALLERY_VIDEO_EXTENSIONS:
continue
path = os.path.join(root, filename)
try:
mtime = os.path.getmtime(os.path.join(root, filename))
stat = os.stat(path)
except OSError:
continue
subfolder = os.path.relpath(root, output_dir)
images.append({
item = {
"filename": filename,
"subfolder": "" if subfolder == "." else subfolder.replace(os.sep, '/'),
"type": "output", "mtime": mtime,
})
"type": "output",
"mtime": stat.st_mtime,
"bytes": stat.st_size,
"media_type": "video" if ext in GALLERY_VIDEO_EXTENSIONS else "image",
}
# A big file's pixel size from its header: the card says it is a very large picture.
size = picture_size(path) if stat.st_size >= BIG_FILE_BYTES and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS else None
if size:
item["width"], item["height"] = size
images.append(item)
images.sort(key=lambda item: (item['mtime'], item['subfolder'], item['filename']), reverse=True)
return images
@@ -58,33 +84,66 @@ def _invalidate_gallery_snapshot():
_gallery_snapshot.update(root=None, expires=0, images=[])
def _gallery_listing(output_dir, refresh, query):
def _format(filename):
"""The format name the gallery's settings use: the extension, with .jpeg counted as jpg."""
ext = os.path.splitext(filename)[1].lower().lstrip('.')
return 'jpg' if ext == 'jpeg' else ext
def _gallery_listing(output_dir, refresh, query, media="image", hidden=frozenset()):
"""Outputs, newest first. `media`: "image" (the default, for callers that show pictures),
"video" or "all"; `hidden`: format names left out (see _format)."""
images = _gallery_images(output_dir, refresh)
if media != "all" or hidden:
images = [item for item in images
if media in ("all", item["media_type"]) and _format(item["filename"]) not in hidden]
return filter_images(os.path.realpath(output_dir), images, query) if query else images
def _gallery_page_source(output_dir, refresh, query, media, hidden, kept_only=False):
"""The listing (only the images kept as a recipe, combo or prompt when `kept_only`), and
whether the output folder has any video (the gallery offers its Pictures / Videos switch
only then)."""
images = _gallery_listing(output_dir, refresh, query, media, hidden)
if kept_only:
from .kept_images import kept_keys # here: kept_images reaches back to this module through the stores
kept = kept_keys()
images = [item for item in images if (item.get("subfolder") or "", item["filename"]) in kept]
has_videos = any(item["media_type"] == "video" for item in _gallery_images(output_dir))
return images, has_videos
async def api_get_gallery_images(request):
"""GET /anomalous/gallery_images?page&limit&refresh&term=..&term=.. (or q) - Output images, optionally searched by embedded generation info."""
"""GET /anomalous/gallery_images?page&limit&refresh&term=..&term=.. (or q)&media=all|image|video&hide=mkv,gif&kept=1
- Outputs (pictures only unless `media` says otherwise), optionally searched by embedded
generation info, without the `hide` formats; with `kept=1` only those kept as something."""
try:
page = max(1, int(request.query.get('page', 1)))
limit = min(200, max(1, int(request.query.get('limit', 50))))
# Repeated `term` params keep phrases whole; `q` is split on whitespace.
terms = [term for term in request.query.getall('term', []) if term.strip()]
query = terms or request.query.get('q', '').strip()
images = await asyncio.to_thread(_gallery_listing, folder_paths.get_output_directory(), request.query.get('refresh') == '1', query)
media = request.query.get('media', 'image')
if media not in GALLERY_MEDIA:
raise ValueError(media)
hidden = frozenset(part.strip().lower().lstrip('.') for part in request.query.get('hide', '').split(','))
images, has_videos = await asyncio.to_thread(
_gallery_page_source, folder_paths.get_output_directory(), request.query.get('refresh') == '1',
query, media, hidden - {''}, request.query.get('kept') == '1')
total = len(images)
start = (page - 1) * limit
return web.json_response({"images": images[start:start + limit], "total": total,
"page": page, "pages": (total + limit - 1) // limit})
"page": page, "pages": (total + limit - 1) // limit,
"has_videos": has_videos})
except (TypeError, ValueError):
return web.json_response({"error": "Invalid pagination"}, status=400)
return web.json_response({"error": "Invalid page or filter"}, status=400)
except OSError:
return web.json_response({"error": "Could not list output images"}, status=500)
async def api_output_thumbnail(request):
"""GET /anomalous/output_thumbnail?filename&subfolder - the gallery grid's 512 px WebP of an
output image (cached like model cards); the original stays at ComfyUI's /view."""
output image/video (cached like model cards); the original stays at ComfyUI's /view."""
output_dir = folder_paths.get_output_directory()
try:
filename = require_filename(request.query.get('filename'))
@@ -94,12 +153,44 @@ async def api_output_thumbnail(request):
if not os.path.isfile(file_path):
return web.json_response({"status": "error", "message": "File not found"}, status=404)
served = file_path
if os.path.splitext(file_path)[1].lower() in CARD_THUMBNAIL_STATIC_EXTENSIONS:
ext = os.path.splitext(file_path)[1].lower()
if ext in CARD_THUMBNAIL_STATIC_EXTENSIONS:
served = await asyncio.to_thread(_build_card_thumbnail, file_path)
if served is None:
return picture_placeholder(file_path)
elif ext in CARD_POSTER_VIDEO_EXTENSIONS:
served = await asyncio.to_thread(_build_video_poster, file_path)
if not served: # no first frame (a broken file, no decoder): never the video itself as a picture
return picture_placeholder(file_path)
# Revalidate (cheap 304s): an output file can be replaced under the same name.
return web.FileResponse(served, headers={'Cache-Control': 'no-cache'})
async def api_output_preview(request):
"""GET /anomalous/output_preview?filename&subfolder[&info=1] - an output picture as the viewers
show it: itself, or for a very large one (past PREVIEW_PIXELS or PREVIEW_BYTES) a cached
PREVIEW_EDGE px copy, so the browser never decodes hundreds of megapixels. `info=1`: JSON
{width, height, bytes, preview} instead, for the viewer's note and its "Open the original"."""
output_dir = folder_paths.get_output_directory()
try:
filename = require_filename(request.query.get('filename'))
file_path = resolve_within(resolve_within(output_dir, request.query.get('subfolder', '')), filename)
except ValueError:
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
if not os.path.isfile(file_path) or os.path.splitext(file_path)[1].lower() not in GALLERY_IMAGE_EXTENSIONS:
return web.json_response({"status": "error", "message": "File not found"}, status=404)
size, file_bytes = picture_size(file_path), os.path.getsize(file_path)
large = file_bytes > PREVIEW_BYTES or bool(size) and size[0] * size[1] > PREVIEW_PIXELS
if request.query.get('info') == '1':
return web.json_response({"width": size and size[0], "height": size and size[1], "bytes": file_bytes, "preview": large})
if not large:
return web.FileResponse(file_path, headers={'Cache-Control': 'no-cache'})
preview = await asyncio.to_thread(shrunk_in_child, file_path, PREVIEW_EDGE, 'preview')
if not preview:
return picture_placeholder(file_path)
return web.FileResponse(preview, headers={'Cache-Control': 'no-cache', 'Content-Type': 'image/webp'})
async def api_delete_gallery_image(request):
try:
data = await request.json()
+146
View File
@@ -0,0 +1,146 @@
"""What a Hugging Face repository says about a model downloaded from it (model_download.py):
the model card's first example image becomes its cover, and its trigger words, example prompts
and base model go into its notes, with the repository as its link. Hugging Face cannot be asked
by fingerprint, so this works only for files downloaded from it (the repository is known then).
Nothing the user or a scan already has is replaced: a cover only when the model has none, notes
and link only when empty (in the user's layer, <model>.anomalous.json).
"""
import json
import os
import urllib.parse
import urllib.request
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES
from .path_utils import atomic_write_json
try:
from ..model_identity import USER_INFO_SUFFIX, read_json
from ..model_sidecars import target
from ..civitai_client import USER_AGENT
from ..net_proxy import urlopen
except ImportError: # loaded outside the package (tests)
from model_identity import USER_INFO_SUFFIX, read_json
from model_sidecars import target
from civitai_client import USER_AGENT
from net_proxy import urlopen
TIMEOUT = 15
MAX_COVER_BYTES = 20 * 1024 * 1024
IMAGE_TYPES = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif"}
IMAGE_FILE = (".png", ".jpg", ".jpeg", ".webp")
HF_HOSTS = ("huggingface.co", "hf-mirror.com")
MAX_PROMPTS = 3
def repo_of(url):
"""("org/name", host) of a Hugging Face file link, or None."""
parsed = urllib.parse.urlsplit(url)
parts = [part for part in parsed.path.split("/") if part]
if (parsed.hostname or "") not in HF_HOSTS or "resolve" not in parts or parts.index("resolve") < 2:
return None
return "/".join(parts[:2]), parsed.hostname
def _get(url, limit=None):
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urlopen(request, timeout=TIMEOUT) as response:
data = response.read(limit + 1 if limit else -1)
return data, str(response.headers.get("Content-Type", "")).split(";")[0].strip().lower()
def _file_url(repo, host, path):
"""A link to a file of the repository, on `host` (the mirror when the download used it)."""
if path.startswith(("https://", "http://")):
parsed = urllib.parse.urlsplit(path)
if parsed.hostname not in HF_HOSTS:
return ""
return urllib.parse.urlunsplit(("https", host, parsed.path, parsed.query, ""))
return f"https://{host}/{repo}/resolve/main/{urllib.parse.quote(path.lstrip('/'))}"
def _as_list(value):
if isinstance(value, str):
return [value] if value.strip() else []
return [str(item) for item in value if str(item).strip()] if isinstance(value, list) else []
def read_card(repo, host):
"""{images, prompts, trigger, base} from the repository's model card (empty when it has none)."""
data, _type = _get(f"https://{host}/api/models/{repo}?expand[]=cardData&expand[]=siblings")
info = json.loads(data.decode("utf-8"))
card = info.get("cardData") or {}
widgets = [item for item in card.get("widget") or [] if isinstance(item, dict)]
images = [(item.get("output") or {}).get("url") for item in widgets]
images = [url for url in (_file_url(repo, host, str(path)) for path in images if path) if url]
if not images: # no examples in the card: the repository's own pictures
files = [item.get("rfilename", "") for item in info.get("siblings") or [] if isinstance(item, dict)]
images = [_file_url(repo, host, name) for name in files if name.lower().endswith(IMAGE_FILE)][:1]
prompts = [str(item.get("text")).strip() for item in widgets if isinstance(item.get("text"), str) and item["text"].strip()]
return {"images": images, "prompts": prompts[:MAX_PROMPTS],
"trigger": _as_list(card.get("instance_prompt")), "base": _as_list(card.get("base_model"))}
def save_cover(image_url, base_path):
"""The image as the model's cover (<model>.preview.<ext>) when it has none yet; True if saved."""
if any(os.path.exists(base_path + suffix) for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS):
return False
data, content_type = _get(image_url, MAX_COVER_BYTES)
extension = IMAGE_TYPES.get(content_type)
if not extension or len(data) > MAX_COVER_BYTES:
return False
target = base_path + ".preview" + extension
temporary = target + ".part"
with open(temporary, "wb") as output:
output.write(data)
os.replace(temporary, target)
return True
def notes_text(repo, card):
lines = [f"Hugging Face: {repo}"]
if card["base"]:
lines.append("Base model: " + ", ".join(card["base"]))
if card["trigger"]:
lines.append("Trigger words: " + ", ".join(card["trigger"]))
if card["prompts"]:
lines.append("Example prompts:")
lines.extend("- " + prompt[:400] for prompt in card["prompts"])
return "\n".join(lines)
def save_notes(base_path, repo, card):
"""The card's words as the model's notes and the repository as its link, where both are empty."""
path = target(base_path, USER_INFO_SUFFIX)
if os.path.exists(path) and read_json(path) is None:
return False # unreadable: leave it to the user
user = read_json(path) or {"format": 1}
changed = False
if not str(user.get("custom_notes") or "").strip():
user["custom_notes"] = notes_text(repo, card)
changed = True
if not str(user.get("source_url") or "").strip():
user["source_url"] = f"https://huggingface.co/{repo}"
changed = True
if changed:
atomic_write_json(path, user)
return changed
def enrich(model_path, download_url):
"""Cover and notes for a model just downloaded from Hugging Face: {cover, notes}."""
located = repo_of(download_url)
if not located:
return {"cover": False, "notes": False}
repo, host = located
base_path = os.path.splitext(model_path)[0]
card = read_card(repo, host)
cover = False
for image in card["images"][:2]:
try:
cover = save_cover(image, base_path)
break
except OSError:
continue
return {"cover": cover, "notes": save_notes(base_path, repo, card)}
+21
View File
@@ -0,0 +1,21 @@
"""Run by media_routes.py as a child process: a smaller WebP of a picture too large to decode
inside ComfyUI (past Pillow's pixel limit), so the memory it takes goes back when it ends.
Arguments: the picture, the WebP to write, the longest edge, the most pixels it may decode."""
import sys
from PIL import Image, ImageOps
def shrink(source, target, edge, most_pixels):
Image.MAX_IMAGE_PIXELS = most_pixels
with Image.open(source) as image:
image.thumbnail((edge, edge), Image.Resampling.LANCZOS) # a JPEG decodes at a fraction; others whole, once
image = ImageOps.exif_transpose(image) # on the small one: turning the whole picture would copy it
if image.mode not in ('RGB', 'RGBA'):
image = image.convert('RGBA' if 'transparency' in image.info else 'RGB')
image.save(target, format='WEBP', quality=84, method=4)
if __name__ == '__main__':
shrink(sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4]))
+7 -2
View File
@@ -76,11 +76,12 @@ def _normalise_model_ref(value):
def build_record(path):
"""Searchable text, recorded model hashes and referenced model files for one image."""
"""Searchable text, recorded model hashes, referenced model files and node types for one image."""
name = os.path.basename(path)
texts = [name]
hashes = set()
models = set()
types = set()
if path.lower().endswith(".png"):
try:
chunks = read_png_text(path)
@@ -92,7 +93,11 @@ def build_record(path):
if not isinstance(node, dict):
continue
texts.append(str(node.get("class_type", "")))
if isinstance(node.get("class_type"), str):
types.add(node["class_type"])
for key, value in (node.get("inputs") or {}).items():
if isinstance(value, dict) and isinstance(value.get("lora"), str):
value = value["lora"] # a row of rgthree's Power Lora Loader
if isinstance(value, str):
texts.append(value)
if MODEL_FILE.search(value):
@@ -111,7 +116,7 @@ def build_record(path):
models |= _normalise_model_ref(model_name)
if chunks.get("parameters"):
texts.append(chunks["parameters"])
return {"text": "\n".join(texts).lower(), "hashes": hashes, "models": models}
return {"text": "\n".join(texts).lower(), "hashes": hashes, "models": models, "types": types}
def _load_json(raw):
+5
View File
@@ -35,6 +35,11 @@ def _kept_images():
return [{"subfolder": subfolder, "filename": filename, **entry} for (subfolder, filename), entry in kept.items()]
def kept_keys():
"""{(subfolder, filename)} of every output image kept as anything (the gallery's ★ view)."""
return {(item["subfolder"], item["filename"]) for item in _kept_images()}
async def api_kept_images(request):
try:
images = await asyncio.to_thread(_kept_images)
+68 -10
View File
@@ -6,9 +6,18 @@ version switches, saved settings) answers only requests from the computer ComfyU
GET is local-only unless it is a read sent as POST (OPEN_POSTS); a GET is open unless it is not
a plain read (LOCAL_GETS). A request is local when it comes from a loopback address, through no
proxy, naming a loopback host; checking the Host also keeps out a page that reached this server
under another name (DNS rebinding)."""
under another name (DNS rebinding). A browser on this computer that opens ComfyUI at one of its own
addresses (ComfyUI listening on its LAN address only) is local too: the request then comes from
the very address it reached, which no other device can have, and names it.
ComfyUI on a rented cloud server is only ever opened from another device. An empty file named
`allow_remote_changes` in the plugin's folder, made on that server, lets every device that can
open the page change things too (may_change); it is read once, when ComfyUI starts, and the
page cannot make or remove it. MCP keeps to this computer either way."""
import ipaddress
import os
import shlex
import urllib.parse
from aiohttp import web
@@ -26,13 +35,23 @@ OPEN_POSTS = frozenset({
LOCAL_GETS = frozenset({
"/anomalous/backup/file",
"/anomalous/version/releases",
"/anomalous/tts_version/releases",
"/anomalous/import/folders",
"/anomalous/civitai_check",
"/anomalous/download/versions", # asks Civitai
"/anomalous/usage/check", # reads every output and saved workflow
})
# Routes that answer other devices themselves (MCP replies in JSON-RPC).
OWN_CHECK = frozenset({"/anomalous/mcp"})
READS = ("GET", "HEAD", "OPTIONS")
MESSAGE = ("只能在运行 ComfyUI 的电脑上操作,别的设备只能浏览。"
" Only the computer ComfyUI runs on can do this; other devices can only look.")
REMOTE_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "allow_remote_changes")
REMOTE_CHANGES = os.path.isfile(REMOTE_FILE)
if REMOTE_CHANGES:
print("[Anomalous Browser] allow_remote_changes 文件存在:任何能打开页面的设备都能扫描、移动、删除模型和改设置。"
" The allow_remote_changes file is there: every device that can open the page may change things.", flush=True)
MESSAGE = ("只能在运行 ComfyUI 的电脑上操作,别的设备只能浏览;窗口顶上的「仅浏览」里有远程也能用的办法。"
" Only the computer ComfyUI runs on can do this; other devices can only look."
" The View only chip at the top of the window says how to use it remotely too.")
def loopback_host(name):
@@ -47,13 +66,37 @@ def loopback_host(name):
return False
def _address(name):
"""An IP address from a peer, socket or Host name (IPv4 inside IPv6 as IPv4), or None."""
try:
address = ipaddress.ip_address(str(name or "").strip("[]").split("%")[0])
except ValueError:
return None
return getattr(address, "ipv4_mapped", None) or address
def _own_address(request):
"""This server's end of the request's connection: the address the browser reached."""
transport = request.transport
sockname = transport.get_extra_info("sockname") if transport else None
return _address(sockname[0]) if isinstance(sockname, (tuple, list)) and sockname else None
def is_local(request):
"""The request comes from this computer, directly, and names a loopback host."""
if not loopback_host(request.remote or ""):
return False
"""The request comes from this computer, directly, and names a loopback host, or else this
computer's own address that it came from and reached."""
if any(header in request.headers for header in PROXY_HEADERS):
return False
return loopback_host(urllib.parse.urlsplit("//" + request.headers.get("Host", "")).hostname)
host = urllib.parse.urlsplit("//" + request.headers.get("Host", "")).hostname
if loopback_host(request.remote or ""):
return loopback_host(host)
own = _own_address(request)
return own is not None and _address(request.remote) == own and _address(host) == own
def may_change(request):
"""The request may change things: it comes from this computer, or remote changes are allowed."""
return REMOTE_CHANGES or is_local(request)
def needs_local(method, path):
@@ -72,11 +115,26 @@ def refusal():
@web.middleware
async def local_only_middleware(request, handler):
if needs_local(request.method, request.path) and not is_local(request):
if needs_local(request.method, request.path) and not may_change(request):
return refusal()
return await handler(request)
def switch_command():
"""The line to paste into the server's terminal that makes the allow_remote_changes file, or
removes it when it is there. On Windows `echo off >` makes the file in cmd and PowerShell
alike (only that it exists counts)."""
if os.name == "nt":
return f'del "{REMOTE_FILE}"' if REMOTE_CHANGES else f'echo off > "{REMOTE_FILE}"'
return ("rm -f " if REMOTE_CHANGES else "touch ") + shlex.quote(REMOTE_FILE)
async def api_access(request):
"""GET /anomalous/access - whether this page may change things: {"local": bool}."""
return web.json_response({"local": is_local(request)})
"""GET /anomalous/access - whether this page is on this computer and whether it may change
things: {"local": bool, "changes": bool}; for another device also "command", the line that
turns remote changes on or off (it only runs where someone types it, on the server)."""
local = is_local(request)
answer = {"local": local, "changes": may_change(request)}
if not local:
answer["command"] = switch_command()
return web.json_response(answer)
+40
View File
@@ -0,0 +1,40 @@
"""What a LoRA file trains, read from its safetensors header: whether it also has text-encoder
weights, so inserting it wires the CLIP line too (graph_splice.js planLoraInsertion).
GET /anomalous/lora_info?name=<the loader's lora_name> -> {text_encoder}.
"""
import asyncio
import json
import struct
from aiohttp import web
import folder_paths
# Key prefixes of text-encoder weights: kohya lora_te / lora_te1 / lora_te2, te1 / te2, diffusers text_encoder.
TEXT_ENCODER_KEYS = ("lora_te", "te_", "te1", "te2", "text_encoder")
MAX_HEADER_BYTES = 100 * 1024 * 1024
def trains_text_encoder(path):
"""Whether the LoRA at `path` has text-encoder weights. Other formats, or a header that
cannot be read, count as yes: the CLIP line then goes through it, which costs nothing."""
if not str(path).lower().endswith(".safetensors"):
return True
try:
with open(path, "rb") as source:
size = struct.unpack("<Q", source.read(8))[0]
if size > MAX_HEADER_BYTES:
return True
keys = json.loads(source.read(size))
except (OSError, ValueError, struct.error):
return True
return any(key.startswith(TEXT_ENCODER_KEYS) for key in keys if key != "__metadata__")
async def api_lora_info(request):
name = request.query.get("name", "")
path = folder_paths.get_full_path("loras", name) if name else None # only names ComfyUI lists
if not path:
return web.json_response({"error": "Unknown LoRA"}, status=404)
return web.json_response({"text_encoder": await asyncio.to_thread(trains_text_encoder, path)})
+108
View File
@@ -0,0 +1,108 @@
"""Where ComfyUI-Manager's model list says a model file comes from, for Model Check's downloads
(download_sources.find_source). The list (model-list.json, a few hundred common models: base
models, VAEs, text encoders, upscalers, ControlNets… mostly on Hugging Face) is read from the
Manager installed here; without it there is nothing to find. A list entry is only a file of the
same name: download_sources checks it against the workflow's fingerprint, or offers it as such.
"""
import importlib.util
import json
import os
import folder_paths
# The list's model types and save paths -> ComfyUI folder types.
TYPE_FOLDERS = {
"checkpoint": "checkpoints", "checkpoints": "checkpoints", "lora": "loras", "loras": "loras",
"vae": "vae", "taesd": "vae_approx", "vae_approx": "vae_approx", "clip": "text_encoders",
"text_encoders": "text_encoders", "clip_vision": "clip_vision", "unet": "diffusion_models",
"diffusion_model": "diffusion_models", "diffusion_models": "diffusion_models", "controlnet": "controlnet",
"upscale": "upscale_models", "upscale_models": "upscale_models", "embeddings": "embeddings",
"embedding": "embeddings", "gligen": "gligen", "style_models": "style_models",
}
# ComfyUI's older names for the same folders.
SAME_FOLDER = {"unet": "diffusion_models", "clip": "text_encoders"}
_cache = {"path": "", "mtime": 0.0, "index": {}}
def list_path():
"""The Manager's model-list.json on this computer, or ''."""
roots = []
try:
roots = list(folder_paths.get_folder_paths("custom_nodes") or [])
except Exception:
pass
for root in roots:
try:
names = sorted(os.listdir(root))
except OSError:
continue
for name in names:
path = os.path.join(root, name, "model-list.json")
if "manager" in name.lower() and os.path.isfile(path):
return path
try: # the Manager installed as a Python package
spec = importlib.util.find_spec("comfyui_manager")
for location in (spec.submodule_search_locations or []) if spec else []:
path = os.path.join(location, "model-list.json")
if os.path.isfile(path):
return path
except (ImportError, ValueError):
pass
return ""
def site_of(url):
"""Where a list entry's file is: the host and, on Hugging Face or GitHub, the repository."""
parts = str(url or "").split("/")
if len(parts) < 3:
return ""
host = parts[2].lower()
return "/".join([host, *parts[3:5]]) if host in ("huggingface.co", "hf-mirror.com", "github.com") and len(parts) >= 5 else host
def _folder(entry):
path = str(entry.get("save_path") or "").replace("\\", "/").strip("/")
first = path.split("/")[0].lower() if path and path != "default" else ""
return TYPE_FOLDERS.get(first) or TYPE_FOLDERS.get(str(entry.get("type") or "").lower(), "")
def _index():
path = list_path()
if not path:
return {}
try:
mtime = os.path.getmtime(path)
except OSError:
return {}
if path != _cache["path"] or mtime != _cache["mtime"]:
index = {}
try:
with open(path, encoding="utf-8") as source:
models = json.load(source).get("models") or []
except (OSError, ValueError, AttributeError):
models = []
for entry in models:
if not isinstance(entry, dict) or not entry.get("filename") or not str(entry.get("url", "")).startswith("https://"):
continue
index.setdefault(str(entry["filename"]).lower(), []).append({
"name": str(entry.get("name") or entry["filename"]), "url": entry["url"],
"folder": _folder(entry), "base": str(entry.get("base") or ""),
})
_cache.update(path=path, mtime=mtime, index=index)
return _cache["index"]
def candidates(file_name, folder_type=""):
"""The list's entries for a file of this name (any case) that fit `folder_type`, one per URL.
When the node's folder type is known, an entry whose type the list does not say does not fit."""
wanted = SAME_FOLDER.get(folder_type, folder_type)
seen, found = set(), []
for entry in _index().get(os.path.basename(str(file_name)).lower(), []):
if wanted and SAME_FOLDER.get(entry["folder"], entry["folder"]) != wanted:
continue
if entry["url"] not in seen:
seen.add(entry["url"])
found.append(entry)
return found
+12
View File
@@ -399,6 +399,14 @@ async def api_get_materials_by_node_type(request):
continue
blocks = [block for block in _material_node_blocks(material, include_values=True) if block["type"] == node_type]
if blocks:
# A prompt's role, by what it is wired to in the workflow it came from.
roles = _prompt_roles_for_workflow(material["workflow"], material.get("promptRoleOverrides"))
for block in blocks:
block["role"] = (roles.get(str(block["node_id"])) or {}).get("role") or ""
# Where the values came from, for Current node: a kept picture, or a node saved there.
source = material.get("source") if isinstance(material.get("source"), dict) else {}
image = source.get("image") if isinstance(source.get("image"), dict) else {}
preview = str((material.get("image") if isinstance(material.get("image"), dict) else {}).get("preview_asset_id") or "")
matches.append({
"filename": summary["filename"],
"name": summary["name"],
@@ -406,6 +414,10 @@ async def api_get_materials_by_node_type(request):
"timestamp": summary["timestamp"],
"blocks": blocks,
"workflow_hashes": _workflow_hashes_for_blocks(material["workflow"], blocks),
"origin": str(source.get("type") or ""),
"source_image": {"filename": str(image["filename"]), "subfolder": str(image.get("subfolder") or "")}
if isinstance(image.get("filename"), str) and image["filename"] else None,
"preview": preview if preview.lower().endswith((".png", ".webp")) else "",
})
return matches
+424
View File
@@ -0,0 +1,424 @@
"""The MCP tools that act: on the open canvas, through the page (mcp_bridge.py and
web/modules/mcp_bridge.js, which use Anomalous's own canvas code, so every change is one
Ctrl+Z step and lands in the activity log marked as the AI's), and on this ComfyUI server
through its own routes (scans, Anomalous TTS speech), so they behave exactly as from the UI.
Nothing here deletes or renames files. Tools that overwrite something on the canvas say so
(destructiveHint); AI apps ask the user before calling tools that are not read-only.
"""
import json
import os
import re
import ssl
import time
import urllib.error
import urllib.request
from . import ai_bases, ai_gates, 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
SPEAK_WAIT_SECONDS = 120
HISTORY_POLL_SECONDS = 1.5
def _page(action, args=None, timeout=30):
try:
return ask_page(action, args, timeout)
except (PageUnavailable, PageRefused) as error:
raise ToolError(str(error))
def _rel(base_dir, file_path):
return os.path.relpath(file_path, base_dir).replace(os.sep, "/")
def _local(ctx, method, path, body=None, timeout=20):
"""(status, JSON) of one request to this ComfyUI server's own routes."""
data = json.dumps(body).encode("utf-8") if body is not None else None
request = urllib.request.Request(ctx["base_url"] + path, data=data, method=method,
headers={"Content-Type": "application/json"})
# Loopback only (mcp_server checked the host); a self-signed certificate is the user's own.
context = ssl._create_unverified_context() if ctx["base_url"].startswith("https:") else None
try:
with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
return response.status, json.loads(response.read() or b"null")
except urllib.error.HTTPError as error:
try:
return error.code, json.loads(error.read() or b"null")
except ValueError:
return error.code, None
except (urllib.error.URLError, OSError) as error:
raise ToolError(f"Could not reach ComfyUI's own server: {error}")
# ---------------------------------------------------------------- canvas (through the page)
def describe_canvas(ctx):
return _page("describe_canvas")
def set_prompt(ctx, prompt, role="positive", node_id=None, mode="replace"):
if role not in ("positive", "negative") or mode not in ("replace", "append"):
raise ToolError("role is positive or negative; mode is replace or append.")
return _page("set_prompt", {"text": str(prompt), "role": role, "node_id": node_id, "mode": mode})
def _base_note(result, folder_type, file_path):
"""The page's answer without the model line it reports, and base_model_note when a LoRA on that
line does not suit its main model (model_advice.py)."""
if not isinstance(result, dict):
return result
line = result.pop("line", None) or {}
if folder_type == "loras":
note = model_advice.lora_note(file_path, line)
else:
notes = (model_advice.lora_note(path, {"model": line.get("model")}) for path in model_advice.lora_paths(line.get("loras")))
note = next((item for item in notes if item), None)
if note:
result["base_model_note"] = note
return result
def set_model(ctx, node_id, model_id):
folder_type, _idx, base_dir, file_path = _resolve_model(model_id)
result = _page("set_model", {"node_id": node_id, "model": {"type": folder_type, "path": _rel(base_dir, file_path)}})
return _base_note(result, folder_type, file_path)
def add_lora(ctx, model_id, after_node_id=None, strength_model=1.0, strength_clip=1.0):
folder_type, _idx, base_dir, file_path = _resolve_model(model_id)
if folder_type != "loras":
raise ToolError("add_lora takes a model from the loras folder.")
result = _page("add_lora", {"model": {"type": folder_type, "path": _rel(base_dir, file_path)},
"text_encoder": trains_text_encoder(file_path), "after_node_id": after_node_id,
"strength_model": float(strength_model), "strength_clip": float(strength_clip)})
return _base_note(result, folder_type, file_path)
def place_combo(ctx, name):
return _page("place_combo", {"name": str(name)})
def check_workflow_models(ctx):
return _page("check_models", timeout=90)
def fix_workflow_models(ctx):
return _page("fix_models", timeout=90)
def download_missing_models(ctx, names=None):
return _page("download_missing", {"names": [str(name) for name in names or []]}, timeout=120)
def download_model(ctx, type, url="", hash="", folder=None, name=""):
if not (url or hash):
raise ToolError("Give url (a Civitai, Hugging Face or GitHub link to the file or model version) or hash.")
return _page("download_model", {"url": str(url), "hash": str(hash), "type": str(type),
"folder": None if folder is None else str(folder), "name": str(name or "")}, timeout=90)
def download_status(ctx):
status, data = _local(ctx, "GET", "/anomalous/download/status")
if status != 200:
raise ToolError(f"Download status unavailable ({status}).")
jobs = []
for job in (data or {}).get("jobs", []):
total = job.get("total") or 0
jobs.append({"file": job.get("rel"), "type": job.get("type"), "state": job.get("state"), "error": job.get("error") or None,
"percent": round(job["received"] * 100 / total) if total else None, "size": total or None,
"checked": job.get("verified"), "for_model": job.get("value") or None})
return {"downloads": jobs, "meaning": "state: queued, running, verifying, done (checked: its fingerprint matched), "
"failed (error says why; starting it again continues it), cancelled."}
MAX_PROMPTS = 8
MAX_TRIES = 16 # prompts x batch in one call
def _submit(batch, base_only=False, hold=None):
"""Queues what the canvas holds `batch` times (its first pass alone with `base_only`; `hold`: the base the
user picked, ai_bases.py): (receipt IDs, the page's answer); IDs are None when no valid receipt came."""
try:
answer = ask_page("run_workflow", {"batch": batch, **({"base_only": True} if base_only else {}),
**({"hold": hold} if hold else {})}, required_capability="prompt_ids")
except (PageUnavailable, PageRefused) as error:
raise ToolError(str(error))
page = answer if isinstance(answer, dict) else {}
ids = page.get("prompt_ids")
if (not isinstance(ids, list) or not ids or len(ids) > batch
or any(not isinstance(pid, str) or not pid or len(pid) > 128 for pid in ids) or len(set(ids)) != len(ids)):
return None, page
return ids, page
def _refused(page):
return (run_refusal.say(page.get("refusal")) or page.get("submission_error")
or "No valid submission receipts. Check ComfyUI's queue before retrying; do not repeat automatically.")
def _base_words(answer, page, base_only, hold):
"""What run_workflow says of the base: the bases to pick from coming, or the picked one held (ai_bases.py)."""
if base_only and page.get("base"):
answer["base_only"] = page["base"] # the run's step keeps it: its previews become the user's picks
answer["note"] += (" Only the first pass runs, as previews: when it ends Anomalous shows the user these bases to "
"pick one; the picked one is kept and its seed held for your next run_workflow.")
held = page.get("held") or {}
if hold and held.get("kept"):
answer["held"] = f"The rest grows from the base the user picked ({hold['kept']}); it is not drawn again."
elif hold and held.get("held"):
answer["held"] = f"The base the user picked: seed {hold['seed']} on node {hold['node']}, held for every try."
if hold.get("show"):
answer["base_check"] = {"show": hold["show"], "kept": hold["kept"]} # compared when the run ends
elif hold:
answer["held"] = (f"The base the user picked was not held: {held.get('reason') or 'the page did not hold it'}; "
"this run draws its own base.")
def run_workflow(ctx, batch=1, wait_seconds=55, workflow=None, prompts=None, base_only=False, **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 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.")
if len(prompts) * batch > MAX_TRIES:
raise ToolError(f"{len(prompts)} prompts x batch {batch} is over {MAX_TRIES} tries in one call.")
ids, groups, filled, page, stopped = [], [], None, {}, None
hold = None if base_only else ai_bases.hold_of(ctx.get("task"))
if base_only:
ai_bases.forget(ctx.get("task")) # new bases: the earlier pick no longer holds
for index, text in enumerate(prompts or [None]):
try:
done = mcp_fill.fill(ctx, workflow, **(slots if text is None else {**slots, "prompt": text}))
except ToolError as error:
if not ids:
raise
stopped = f"Prompt {index + 1} was not filled in: {error}"
break
filled = filled or done
got, page = _submit(batch, base_only, hold)
if got is None:
if not ids:
raise ToolError(_refused(page))
stopped = f"Prompt {index + 1} was not queued: {_refused(page)}"
break
ids += got
groups.append({"prompt": index + 1, "prompt_ids": got})
if len(got) < batch:
stopped = f"Only {len(got)} of {batch} tries of prompt {index + 1} were accepted. {run_refusal.say(page.get('refusal')) or ''}".strip()
break
requested = batch * len(prompts or [None])
answer = {"prompt_ids": ids, "queued": len(ids), "requested": requested,
"note": ("If this answer does not say it is done, the user tells you when it is: say it is running and end "
"your turn; run_status then brings its pictures." if ai_gates.run_wait() == "call" else
"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 prompts is not None:
answer["by_prompt"] = groups # which receipts are which prompt; the canvas keeps the last prompt
if filled:
answer["filled"] = filled
# Some prompts may have been accepted before validation or a transport failure stopped the batch.
if stopped:
answer["submission_error"] = stopped
return answer
def open_in_anomalous(ctx, model_id):
folder_type, path_idx, base_dir, file_path = _resolve_model(model_id)
return _page("open_model", {"type": folder_type, "path_idx": path_idx, "rel": _rel(base_dir, file_path)})
# ---------------------------------------------------------------- this server
def scan_models(ctx, what="new", model_ids=None):
body = {"offline_only": False, "skip_rename": False, "virtual_rename": True, "physical_rename": False,
"force_overwrite": False, "retry_unmatched": what == "retry_unmatched"}
if what == "models":
folders = {}
for model_id in model_ids or []:
folder_type, path_idx, base_dir, file_path = _resolve_model(model_id)
subfolder, _, filename = _rel(base_dir, file_path).rpartition("/")
folders.setdefault((folder_type, path_idx, "/" + subfolder), []).append(filename)
if not folders:
raise ToolError("what=models needs model_ids from search_models.")
body["targets"] = [{"type": t, "path_idx": i, "subfolder": s, "files": files} for (t, i, s), files in folders.items()]
elif what not in ("new", "retry_unmatched"):
raise ToolError("what is new, retry_unmatched or models.")
status, data = _local(ctx, "POST", "/anomalous/scan_all", body)
if status == 409:
raise ToolError("A scan is already running; call scan_report later to see it.")
if status != 200 or (data or {}).get("status") != "ok":
raise ToolError(f"The scan did not start: {(data or {}).get('message') or status}")
return {"started": True, "note": "Scans run in the background and only read .safetensors files. "
"Call scan_report in a minute or two for what it found."}
def _tts_characters(ctx):
status, data = _local(ctx, "GET", "/anomalous_tts/characters")
if status == 404:
raise ToolError("Anomalous TTS is not installed, so there are no voices.")
if status != 200 or not isinstance(data, dict):
raise ToolError(f"Could not read the voices ({status}).")
return data.get("characters") or []
def list_voices(ctx):
return {"voices": [{"character": item.get("name"), "language": item.get("language") or "",
"emotions": sorted((item.get("emotions") or {}).keys()),
**({"problem": item.get("error") or item.get("settings_error")}
if item.get("error") or item.get("settings_error") else {})}
for item in _tts_characters(ctx)]}
def _output_prefix(character):
"""Where the audio page saves a character's lines (audio_script.js ttsOutputPrefix)."""
parts = [re.sub(r'[<>:"\\|?*\x00-\x1f]', "_", part).strip() or "_" for part in str(character).split("/")]
parts = ["_" if re.fullmatch(r"\.+", part) else part for part in parts]
return f"audio/{'/'.join(parts)}/{parts[-1]}"
def speak(ctx, character, text, seed=None, language="auto"):
names = {item.get("name") for item in _tts_characters(ctx)}
if character not in names:
raise ToolError(f"No voice called {character!r}; list_voices names them.")
if not str(text).strip():
raise ToolError("text is empty.")
seed = int(seed) if seed is not None else int(time.time() * 1000) % 2**31
prompt = {"1": {"class_type": "AnomalousTTS_CharacterSpeech",
"inputs": {"character": character, "text": str(text), "seed": seed, "language": language}},
"2": {"class_type": "SaveAudio", "inputs": {"audio": ["1", 0], "filename_prefix": _output_prefix(character)}}}
status, data = _local(ctx, "POST", "/prompt", {"prompt": prompt, "client_id": "anomalous-mcp"})
prompt_id = (data or {}).get("prompt_id")
if status != 200 or not prompt_id:
errors = (data or {}).get("node_errors") or (data or {}).get("error") or status
raise ToolError(f"ComfyUI refused the speech job: {json.dumps(errors, ensure_ascii=False)[:500]}")
deadline = time.monotonic() + SPEAK_WAIT_SECONDS
while time.monotonic() < deadline:
time.sleep(HISTORY_POLL_SECONDS)
_status, history = _local(ctx, "GET", f"/history/{prompt_id}")
record = (history or {}).get(prompt_id) or {}
state = record.get("status") or {}
if state.get("status_str") == "error":
messages = [m[1].get("exception_message") for m in state.get("messages") or [] if m and m[0] == "execution_error"]
raise ToolError(f"Speech failed: {messages[0] if messages else 'see the ComfyUI console'}")
if state.get("completed"):
files = [f"{item.get('subfolder')}/{item.get('filename')}".replace("\\", "/").strip("/")
for output in (record.get("outputs") or {}).values() for item in output.get("audio") or []]
return {"done": True, "seed": seed, "files": files,
"note": "Saved under ComfyUI's output folder; it shows in Anomalous's audio gallery."}
return {"done": False, "prompt_id": prompt_id, "seed": seed,
"note": "Still generating (ComfyUI may be busy with other jobs); search_audio will list it when done."}
# ---------------------------------------------------------------- registry
_NODE = {"type": "integer", "description": "A node id."}
_MODEL = {"type": "string", "description": "model_id from search_models."}
_PROMPT = {"type": "string", "description": "Your own? First get_toolbox(skill=\"prompt_pose\")."}
def _action(name, title, description, run, properties=None, required=(), destructive=False, open_world=False,
read_only=False):
return name, {"run": run, "context": True, "spec": {
"name": name, "title": title, "description": description,
"inputSchema": _schema(properties, required),
"annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": destructive,
"idempotentHint": read_only, "openWorldHint": open_world},
}}
ACTIONS = dict([
_action("describe_canvas", "Read the canvas",
"The workflow open in ComfyUI now: each node's id, type, title, settings, which prompt box is "
"positive or negative, and where its inputs come from. Call it before changing settings; "
"get_situation gives the canvas in brief (a line a node, what is still unwired and what fits it).",
describe_canvas, read_only=True),
_action("set_prompt", "Write a prompt",
"Writes text into the workflow's positive or negative prompt box (or the one on node_id). "
"mode=append adds to what is there. One Ctrl+Z step.",
set_prompt, {"prompt": _PROMPT, "role": {"type": "string", "enum": ["positive", "negative"], "default": "positive"},
"node_id": _NODE, "mode": {"type": "string", "enum": ["replace", "append"], "default": "replace"}},
["prompt"], destructive=True),
_action("set_model", "Swap a node's model",
"Puts a model into a loader node (checkpoint, LoRA, VAE…) on the canvas, replacing the one it had. "
"base_model_note in the answer: a LoRA and the main model are for different base models.",
set_model, {"node_id": _NODE, "model_id": _MODEL}, ["node_id", "model_id"], destructive=True),
_action("add_lora", "Add a LoRA",
"Inserts a LoRA loader after after_node_id (default: the main model loader, past its LoRAs) and moves "
"what that node fed behind it; the CLIP line too when the LoRA trains the text encoder. base_model_note in "
"the answer: the LoRA is for another base model than the main model.",
add_lora, {"model_id": _MODEL, "after_node_id": _NODE,
"strength_model": {"type": "number", "default": 1.0}, "strength_clip": {"type": "number", "default": 1.0}},
["model_id"]),
_action("place_combo", "Place a combo",
"Adds one of the user's combos (list_combos) to the canvas as a new wired group: model loader, LoRAs, "
"positive and negative prompt, or a node-structure combo's own nodes and links. Nothing already there "
"changes; ports to nodes outside the combo (sampler…) stay open.",
place_combo, {"name": {"type": "string", "description": "Combo name from list_combos."}}, ["name"]),
_action("check_workflow_models", "Check missing models",
"Models the open workflow names that this computer lacks, and which Anomalous can put back (the same "
"file by hash, or a likely one).",
check_workflow_models, read_only=True),
_action("fix_workflow_models", "Fix missing models",
"Puts back every missing model found by hash; likely ones are left to the user. One Ctrl+Z step.",
fix_workflow_models, destructive=True),
_action("download_missing_models", "Download missing models",
"Downloads the models the open workflow is missing whose source is certain: Civitai found by the "
"workflow's fingerprint, or a file link the workflow carries. A file name alone is never used. Files go "
"where the user's download settings say and are checked against the fingerprint before use; finished "
"ones are put into their nodes. names limits it to those files. Large files take a while.",
download_missing_models, {"names": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_LIMIT,
"description": "Only these model file names (default: all missing)."}},
open_world=True),
_action("download_model", "Download a model",
"Downloads one model into this computer's models folder of `type` (loras, checkpoints, vae, "
"diffusion_models, text_encoders, upscale_models…): from url (a Civitai model-version link, or a Hugging "
"Face / GitHub link to the file) or by its Civitai hash. folder is a subfolder inside that type's folder "
"(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",
"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.",
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},
"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,
"description": "Instead of prompt: several, queued in order as one run."},
"seed": {"type": "integer", "minimum": 0},
"width": {"type": "integer", "minimum": 16, "maximum": 16384},
"height": {"type": "integer", "minimum": 16, "maximum": 16384},
"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."}}),
_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"]),
_action("scan_models", "Scan models",
"Starts a scan that fetches covers, trigger words and base models from Civitai (by file hash) for "
"models not scanned yet (new), looks unmatched ones up again (retry_unmatched), or scans model_ids.",
scan_models, {"what": {"type": "string", "enum": ["new", "retry_unmatched", "models"], "default": "new"},
"model_ids": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_LIMIT}},
open_world=True),
_action("list_voices", "Voices",
"The Anomalous TTS characters (GPT-SoVITS voices) with their language and emotions.", list_voices,
read_only=True),
_action("speak", "Speak a line",
"Has an Anomalous TTS character read text and saves the audio. Emotion tags like {happy} switch "
"emotion mid-text; waits up to two minutes.",
speak, {"character": {"type": "string", "description": "From list_voices."}, "text": {"type": "string"},
"seed": {"type": "integer"}, "language": {"type": "string", "default": "auto"}},
["character", "text"]),
])
+215
View File
@@ -0,0 +1,215 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anomalous</title>
<!-- Anomalous's card in the AI app's conversation (api/mcp_apps.py). Everything the AI or the task
says is drawn with textContent; pictures are inline thumbnails; nothing is loaded from elsewhere. -->
<style>
:root { --bg: #ffffff; --soft: #f4f4f6; --line: #e2e2e8; --text: #1d1d22; --dim: #6b6b75; --accent: #7c5cdb; --ok: #2f9e6e; --warn: #c98a04; --bad: #d64545; }
:root.dark { --bg: #1d1d22; --soft: #26262d; --line: #36363f; --text: #ececf1; --dim: #9a9aa6; --accent: #b39eef; --ok: #52b98d; --warn: #fbbf24; --bad: #f87171; }
* { box-sizing: border-box; }
body { margin: 0; padding: 14px 16px; background: var(--bg); color: var(--text); font: 13.5px/1.55 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; }
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.brand { color: var(--accent); font-weight: 600; font-size: 12px; }
.title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
.state { padding: 1px 8px; border-radius: 999px; background: var(--soft); color: var(--dim); font-size: 11.5px; }
.state.live { color: var(--ok); }
.steps { display: flex; flex-direction: column; gap: 4px; margin: 0 0 10px; padding: 0; list-style: none; }
.steps li { display: flex; gap: 8px; color: var(--dim); font-size: 12.5px; }
.steps li::before { content: "•"; color: var(--ok); }
.steps li.running::before { color: var(--accent); }
.steps li.asking::before, .steps li.allowed::before { color: var(--warn); }
.steps li.bad::before { color: var(--bad); }
.steps li span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pictures { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 6px; margin-bottom: 10px; }
.pictures img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 8px; background: var(--soft); }
.box { margin-bottom: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--soft); }
.box.warn { border-color: var(--warn); }
.question { margin-bottom: 8px; font-weight: 600; }
.options { display: flex; flex-wrap: wrap; gap: 6px; }
button { padding: 6px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--text); font: inherit; font-size: 12.5px; cursor: pointer; }
button:hover:not(:disabled) { border-color: var(--accent); }
button:disabled { opacity: 0.5; cursor: default; }
button.primary { border-color: var(--accent); background: var(--accent); color: #fff; }
input { flex: 1; min-width: 0; padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--text); font: inherit; }
.row { display: flex; gap: 6px; }
.foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--dim); font-size: 12px; }
.link { padding: 0; border: 0; background: none; color: var(--accent); }
.note { margin-bottom: 8px; color: var(--dim); font-size: 12px; }
</style>
</head>
<body>
<div id="card"><div class="note" id="loading"></div></div>
<script>
(() => {
let zh = /^zh/i.test(navigator.language || '');
const words = {
loading: ['Reading the task from Anomalous…', '正在从 AMB 读取任务…'],
live: ['Working', '进行中'], done: ['Done', '已完成'],
asking: ['The AI waits for your OK in Anomalous', 'AI 在等你在 AMB 里点头'],
askingHow: ['Allowing is only done in Anomalous\'s AI workbench, so the AI can never allow itself.', '为了安全,允许只能在 AMB 的 AI 工作台里点,AI 自己没法替你点。'],
openAmb: ['Open in Anomalous', '在 AMB 里看'], send: ['Send', '发送'], refresh: ['Refresh', '刷新'],
picked: ['I picked in the card: ', '我在卡片里选了:'], typed: ['My answer in the card: ', '我在卡片里回答:'],
youAnswered: ['You answered: ', '你回答了:'], none: ['No task to show yet.', '还没有任务可看。'],
private: ['Privacy mode: the pictures are only shown in Anomalous.', '隐私模式:图只在 AMB 里看。'],
};
const t = key => words[key][zh ? 1 : 0];
const card = document.getElementById('card');
document.getElementById('loading').textContent = t('loading');
// JSON-RPC with the AI app (MCP Apps): requests wait for their answer by id.
let nextId = 1;
const waiting = new Map();
const send = (message) => window.parent.postMessage({ jsonrpc: '2.0', ...message }, '*');
const request = (method, params) => new Promise((resolve, reject) => {
const id = nextId++;
waiting.set(id, { resolve, reject });
send({ id, method, params });
});
const notify = (method, params) => send({ method, params });
const resize = () => notify('ui/notifications/size-changed', { width: document.body.scrollWidth, height: document.body.scrollHeight });
let target = {}; // { panel } or { task }: what the card shows
let openUrl = '';
let polls = 0;
let timer = null;
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = String(text);
return node;
}
async function callTool(name, args) {
const result = await request('tools/call', { name, arguments: args });
if (result?.isError) throw new Error(result.content?.[0]?.text || 'error');
return result?.structuredContent || {};
}
async function answer(question, choice, text, label) {
card.querySelectorAll('button, input').forEach((node) => { node.disabled = true; });
try {
await callTool('app_answer', { panel: question.panel, block: question.block, ...(choice ? { choice: [choice] } : { text }) });
// Also said in the conversation, so the AI goes on even when it is not waiting.
await request('ui/message', { role: 'user', content: [{ type: 'text', text: (choice ? t('picked') : t('typed')) + label }] }).catch(() => {});
} catch (error) {
card.append(el('div', 'note', error.message));
}
await load();
}
function questionBox(question) {
const box = el('div', 'box');
box.append(el('div', 'question', question.question));
if (question.type === 'input') {
const row = el('div', 'row');
const field = el('input');
const go = el('button', 'primary', t('send'));
go.onclick = () => field.value.trim() && answer(question, null, field.value.trim(), field.value.trim());
field.onkeydown = (event) => { if (event.key === 'Enter') go.click(); };
row.append(field, go);
box.append(row);
} else {
const options = el('div', 'options');
for (const option of question.options) {
const pick = el('button', '', option.label);
pick.onclick = () => answer(question, option.id, null, option.label);
options.append(pick);
}
box.append(options);
}
return box;
}
function draw(view) {
openUrl = view.open_url || openUrl;
const head = el('div', 'head');
head.append(el('span', 'brand', 'Anomalous'), el('span', 'title', view.title || ''),
el('span', `state${view.ended ? '' : ' live'}`, t(view.ended ? 'done' : 'live')));
const steps = el('ul', 'steps');
for (const step of view.steps || []) {
const line = el('li', `${step.state || ''}${step.ok === false ? ' bad' : ''}`);
line.append(el('span', '', step.text || step.tool));
steps.append(line);
}
const parts = [head, steps];
if (view.pictures?.length) {
const pictures = el('div', 'pictures');
for (const picture of view.pictures) {
const image = el('img');
image.alt = picture.name;
image.title = picture.name;
image.src = picture.src; // a data: thumbnail from Anomalous
image.onload = resize;
pictures.append(image);
}
parts.push(pictures);
}
if (view.private) parts.push(el('div', 'note', t('private')));
for (const item of view.answered || []) parts.push(el('div', 'note', `${t('youAnswered')}${item.question ? `${item.question} ` : ''}${item.answer}`));
for (const question of view.questions || []) parts.push(questionBox(question));
if (view.asking?.length) {
const box = el('div', 'box warn');
box.append(el('div', 'question', t('asking')), el('div', 'note', t('askingHow')));
parts.push(box);
}
const foot = el('div', 'foot');
const open = el('button', 'link', t('openAmb'));
open.onclick = () => openUrl && request('ui/open-link', { url: openUrl }).catch(() => {});
const again = el('button', 'link', t('refresh'));
again.onclick = () => { polls = 0; load(); };
foot.append(open, again);
parts.push(foot);
card.replaceChildren(...parts);
resize();
// Follows the task for a few minutes while it goes on; Refresh starts again.
clearTimeout(timer);
if (!view.ended && polls++ < 36) timer = setTimeout(load, 5000);
}
async function load() {
try {
draw(await callTool('app_task_view', target));
} catch (_) {
card.replaceChildren(el('div', 'note', t('none')));
resize();
}
}
window.addEventListener('message', (event) => {
if (event.source !== window.parent) return;
const message = event.data;
if (!message || message.jsonrpc !== '2.0') return;
if (message.id !== undefined && waiting.has(message.id)) {
const { resolve, reject } = waiting.get(message.id);
waiting.delete(message.id);
if (message.error) reject(new Error(message.error.message));
else resolve(message.result);
return;
}
if (message.method === 'ui/notifications/tool-result') {
const data = message.params?.structuredContent || {};
target = data.panel ? { panel: String(data.panel) } : data.task ? { task: String(data.task) } : {};
polls = 0;
load();
} else if (message.method === 'ui/notifications/host-context-changed') {
document.documentElement.classList.toggle('dark', message.params?.theme === 'dark');
}
});
request('ui/initialize', { appInfo: { name: 'anomalous-task-card', version: '1.0.0' }, appCapabilities: {}, protocolVersion: '2026-01-26' })
.then((result) => {
const context = result?.hostContext || {};
if (context.locale) zh = /^zh/i.test(context.locale);
document.documentElement.classList.toggle('dark', context.theme === 'dark');
notify('ui/notifications/initialized', {});
resize();
})
.catch(() => {});
})();
</script>
</body>
</html>
+148
View File
@@ -0,0 +1,148 @@
"""Anomalous's card inside the AI app's conversation (the MCP Apps extension,
io.modelcontextprotocol/ui): AI apps that support it (Claude, ChatGPT, VS Code…) show the page
mcp_app/task_card.html under the results of the tools in CARD_TOOLS, in a sandboxed frame of
theirs. It is Anomalous's own page, never anything an AI wrote; the AI's data reaches it only as
text. The card reads the task through app_task_view and answers the AI's questions through
app_answer, two tools for the card only (visibility "app": apps that follow the extension keep
them from the AI; an AI that calls them anyway only reads its own task or answers its own
question). Their results go to the card, not the AI. The card never answers a gate:
an OK for what an AI may do is only given in Anomalous (ai_gates.py), so a tool the AI could
reach can never grant it. The pictures it shows are the gallery's thumbnails, inline (the frame
loads nothing from this server); a private task's are not shown (ai_privacy.py).
"""
import os
from . import ai_panels, ai_privacy, ai_tasks
from .mcp_tools import ToolError, _image_content, _resolve_image, _schema
EXTENSION = "io.modelcontextprotocol/ui"
MIME = "text/html;profile=mcp-app"
CARD_URI = "ui://anomalous/task-card.html"
CARD_TOOLS = ("show_panel", "ask_user", "report", "run_workflow", "begin_task")
MAX_PICTURES = 6
MAX_STEPS = 8
HTML = os.path.join(os.path.dirname(__file__), "mcp_app", "task_card.html")
def resources():
return [{"uri": CARD_URI, "name": "task-card", "title": "Anomalous task card", "mimeType": MIME,
"description": "What the AI is doing in Anomalous: its steps, pictures and questions."}]
def read(uri):
"""resources/read for the card, or None for another address."""
if uri != CARD_URI:
return None
with open(HTML, encoding="utf-8") as source:
html = source.read()
return {"contents": [{"uri": CARD_URI, "mimeType": MIME, "text": html, "_meta": {"ui": {"prefersBorder": True}}}]}
def with_card(spec):
"""A tool's spec naming the card that shows its results (and the older flat key some apps read)."""
if spec["name"] not in CARD_TOOLS:
return spec
return {**spec, "_meta": {**spec.get("_meta", {}), "ui": {"resourceUri": CARD_URI}, "ui/resourceUri": CARD_URI}}
def _picture(result):
rel = "/".join(part for part in (result.get("subfolder"), result.get("filename")) if part)
try:
_, path = _resolve_image(rel)
except ToolError:
return None
item = _image_content(path)
return {"name": result["filename"], "src": f"data:{item['mimeType']};base64,{item['data']}"} if item else None
def _words(step):
"""A line for a step: its note or panel title, else what it was about (its summary without the tool)."""
summary = step.get("summary", "")
about = summary.split(": ", 1)[1] if summary.startswith(f"{step['tool']}: ") else summary
return ai_tasks._short(step.get("text") or (step.get("panel") or {}).get("title") or about or step["tool"], 120)
def _answered(step):
"""What the user already answered on a panel step: [{question, answer}]."""
blocks = {block["id"]: block for block in step["panel"]["blocks"]}
return [{"question": blocks.get(block, {}).get("question", ""), "answer": "(left to the AI)" if given.get("skipped") else "、".join(given.get("labels") or [given.get("text", "")])}
for block, given in step.get("answers", {}).items()]
def _questions(step):
"""The open questions of a panel step, as the card draws them (choices and picks as buttons, input as a line)."""
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
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", [])]
out.append({"panel": panel["id"], "block": block["id"], "type": block["type"], "question": block.get("question", ""),
"options": [{"id": option["id"], "label": option.get("label") or option["id"]} for option in options]})
return out
def task_view(ctx, panel=None, task=None):
"""The card's view of a task: the panel's task, task `task`, or the calling app's active one."""
with ai_tasks._lock:
found = ai_panels._find(str(panel))[0] if panel else None
task_id = found["id"] if found else task or ai_tasks.active_task_id(ctx.get("client") or "AI", ctx.get("session") or "")
record = ai_tasks.get_task(task_id) if task_id else None
if not record:
raise ToolError("No task to show.")
steps = [step for step in record["steps"] if step.get("kind") != "look"]
private = ai_privacy.is_private(record) # its pictures stay in Anomalous
pictures = [] if private else [result for step in record["steps"] for result in step.get("results") or [] if result.get("type") == "image"]
seen, shown = set(), []
for result in reversed(pictures):
key = (result.get("subfolder"), result.get("filename"))
if key in seen or len(shown) >= MAX_PICTURES:
continue
seen.add(key)
picture = _picture(result)
if picture:
shown.append(picture)
return {
"task": record["id"], "title": record.get("title") or "", "ended": bool(record.get("ended")),
"steps": [{"tool": step["tool"], "kind": step["kind"], "state": step.get("state", "done"), "ok": step.get("ok", True),
"stage": step.get("stage"), "text": _words(step)}
for step in steps[-MAX_STEPS:]],
"pictures": list(reversed(shown)), "private": private,
"questions": [question for step in record["steps"] if step.get("kind") == "panel" for question in _questions(step)],
"answered": [item for step in record["steps"] if step.get("kind") == "panel" for item in _answered(step)][-3:],
"asking": [step.get("gate", {}).get("gate") for step in record["steps"] if step.get("state") == "asking"],
"open_url": f"{ctx.get('base_url', '')}/",
}
def answer(ctx, panel, block, choice=None, text=None):
"""The user's answer from the card to one of the AI's questions (never a gate)."""
task, step = None, None
with ai_tasks._lock:
task, step = ai_panels._find(str(panel))
if not step:
raise ToolError("No such panel.")
try:
ai_panels.answer(task["id"], step["id"], str(block), choice, text)
except ai_panels.PanelError as error:
raise ToolError(str(error))
return {"answered": True}
def _app_tool(name, title, description, run, properties, required=()):
return name, {"run": run, "context": True, "spec": {
"name": name, "title": title, "description": description, "inputSchema": _schema(properties, required),
"annotations": {"title": title, "readOnlyHint": name == "app_task_view"},
"_meta": {"ui": {"resourceUri": CARD_URI, "visibility": ["app"]}},
}}
APP_TOOLS = dict([
_app_tool("app_task_view", "Card: the task", "Only for Anomalous's own card in the conversation; never call it.",
task_view, {"panel": {"type": "string"}, "task": {"type": "string"}}),
_app_tool("app_answer", "Card: answer", "Only for Anomalous's own card, when the user answers there; never call it.",
answer, {"panel": {"type": "string"}, "block": {"type": "string"}, "choice": {"type": "array", "items": {"type": "string"}},
"text": {"type": "string"}}, ["panel", "block"]),
])
+37
View File
@@ -0,0 +1,37 @@
"""Normalize an unambiguous argument name before approval, execution and task recording.
Only a known other name counts (ALIASES, names light models were seen to write in the tool exams,
docs/architecture/tool-exam.md): a value of the right type under any other name may mean something
else (set_prompt {style: "anime"} is not a prompt), and that call is answered with what the tool takes.
"""
ALIASES = { # needed argument -> other names that mean it
"workflow": {"name", "workflow_name"},
"image": {"file", "filename", "image_name"},
"prompt": {"text", "positive"},
"model_id": {"model", "id"},
"node_id": {"node", "id"},
}
JSON_TYPES = {"string": (str,), "integer": (int,), "number": (int, float), "boolean": (bool,), "array": (list,), "object": (dict,)}
def prepare_arguments(spec, arguments):
"""Return (arguments, (wrong, right) or None), without changing the caller's dictionary."""
unknown = set(arguments) - set(spec["properties"])
missing = [key for key in spec.get("required", []) if key not in arguments]
if len(unknown) != 1 or len(missing) != 1:
return arguments, None
wrong, right = next(iter(unknown)), missing[0]
if wrong not in ALIASES.get(right, ()):
return arguments, None
kinds = JSON_TYPES.get(spec["properties"][right].get("type"))
value = arguments[wrong]
if not kinds or not isinstance(value, kinds) or (isinstance(value, bool) and bool not in kinds):
return arguments, None
corrected = {key: value for key, value in arguments.items() if key != wrong}
corrected[right] = value
return corrected, (wrong, right)
def renaming_note(name, renamed):
return f"Took {renamed[0]} as {renamed[1]}: {name} calls it {renamed[1]}."
+101
View File
@@ -0,0 +1,101 @@
"""The way from an MCP tool to the open ComfyUI page, for what only the page can do (the
canvas lives in the browser).
`ask_page` sends {id, action, args} as the "anomalous.mcp" event over ComfyUI's websocket
to every open ComfyUI page and waits. One page claims it (the first claim wins, visible
pages claim first: web/modules/mcp_bridge.js; while the page that did the last action is open,
it gets the next too: it holds the task's tabs, and with ComfyUI open in two browser tabs one
must not open a task's tab and the other then not find it), does the action with Anomalous's own canvas
code and posts the outcome back; `claim` and `resolve` take those posts
(routes in mcp_server.py). With no page open, or none answering in time, the tool fails
with words the AI can pass on. Actions on the canvas carry the task's own tab
(ai_task_canvas.py), and the tab the page made or opened for it is kept.
"""
import threading
import time
import uuid
TIMEOUT_SECONDS = 30
MAX_WAITING = 20
HOLD_SECONDS = 3 # how long the page that did the last action has to claim the next before another page may
_lock = threading.Lock()
_waiting = {} # id -> {"event": Event, "claimed": bool, "outcome": dict | None}
_last = {"page": None} # the page that did the last action (its own id, web/modules/mcp_bridge.js pageId)
class PageUnavailable(Exception):
"""No ComfyUI page took or finished the action."""
class PageRefused(Exception):
"""The page tried and said why it could not (missing node, wrong model type…)."""
def _server():
from server import PromptServer # ComfyUI's; imported late so tests can run without it
return PromptServer.instance
def ask_page(action, args=None, timeout=TIMEOUT_SECONDS, required_capability=None):
"""Runs `action` in the open ComfyUI page; its result value. Call from a worker thread."""
server = _server()
if not getattr(server, "sockets", None):
raise PageUnavailable("ComfyUI is not open in a browser on this computer. Open it, then try again.")
from . import ai_task_canvas
task_canvas = ai_task_canvas.spec(action)
if task_canvas:
args = {**(args or {}), "_task_canvas": task_canvas}
request_id = uuid.uuid4().hex
entry = {"event": threading.Event(), "claimed": False, "outcome": None, "required": required_capability, "sent": time.monotonic()}
with _lock:
if len(_waiting) >= MAX_WAITING:
raise PageUnavailable("Too many canvas actions are waiting; try again in a moment.")
_waiting[request_id] = entry
try:
server.send_sync("anomalous.mcp", {"id": request_id, "action": action, "args": args or {}})
if not entry["event"].wait(timeout):
raise PageUnavailable("The ComfyUI page did not answer in time. Is it open and not frozen? "
"If the action was taken late, check the canvas before repeating it.")
finally:
with _lock:
_waiting.pop(request_id, None)
outcome = entry["outcome"] or {}
if task_canvas and outcome.get("tab"):
ai_task_canvas.remember(outcome["tab"])
if not outcome.get("ok"):
raise PageRefused(str(outcome.get("error") or "The page could not do that."))
return outcome.get("value")
def claim(request_id, capabilities=None, page=None):
"""True for the page that may do request `request_id`: the first that asks, but for HOLD_SECONDS
only the page that did the last action (`page`, its id), when it is another: "later" tells
another page to ask again then (it is the one left when that page was closed)."""
with _lock:
entry = _waiting.get(request_id)
if entry is None or entry["claimed"] or entry["outcome"] is not None:
return False
if entry.get("required") and (not isinstance(capabilities, list) or entry["required"] not in capabilities):
entry["outcome"] = {"ok": False, "error": "Refresh the ComfyUI browser page before running from AI. Nothing was queued."}
entry["event"].set()
return False
if page and _last["page"] and page != _last["page"] and time.monotonic() - entry.get("sent", 0) < HOLD_SECONDS:
return "later"
entry["claimed"] = True
if page:
_last["page"] = page
return True
def resolve(request_id, outcome):
"""The claiming page's outcome: {ok, value} or {ok: false, error}. False when nobody waits."""
with _lock:
entry = _waiting.get(request_id)
if entry is None or not entry["claimed"] or entry["outcome"] is not None:
return False
entry["outcome"] = outcome if isinstance(outcome, dict) else {"ok": False, "error": "Malformed answer"}
entry["event"].set()
return True
+425
View File
@@ -0,0 +1,425 @@
"""The MCP tools that build a workflow on the canvas the user sees: add_node, connect_nodes,
set_values, remove_nodes and new_workflow (a new tab, blank or built from a workflow in
ComfyUI's API form) run in the open page like mcp_actions.py's canvas tools
(web/modules/mcp_canvas_edit.js: each one Ctrl+Z step, in the activity log as the AI's, so the
AI workbench shows what it changed), and find_nodes reads the node types this ComfyUI has
from its own /object_info. Arguments are checked here for shape and size; the page checks
them against the nodes themselves before anything changes."""
import json
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 .mcp_actions import _action, _local, _page
from .media_routes import picture_size
from .mcp_tools import ToolError, _resolve_model
MAX_FOUND = 20
MAX_SEARCH = 5
MAX_CHOICES_NAMED = 60
MAX_REMOVE = 30
MAX_WORKFLOW_NODES = 200
MAX_WORKFLOW_BYTES = 1_000_000
INFO_SECONDS = 120
_info = {"at": 0.0, "data": None}
_info_lock = threading.Lock()
def object_info(ctx):
"""ComfyUI's node definitions, as its own page reads them (kept a little while)."""
with _info_lock:
if _info["data"] is None or time.monotonic() - _info["at"] > INFO_SECONDS:
status, data = _local(ctx, "GET", "/object_info", timeout=60)
if status != 200 or not isinstance(data, dict):
raise ToolError(f"ComfyUI's node list is unavailable ({status}).")
_info.update(at=time.monotonic(), data=data)
return _info["data"]
def forget_object_info():
"""After a model moved: the next look reads ComfyUI's lists anew."""
with _info_lock:
_info["data"] = None
def _input(name, conf, required, choices):
kind, options = (conf + [None, None])[:2] if isinstance(conf, list) else (conf, None)
options = options if isinstance(options, dict) else {}
out = {"name": name, "required": required}
listed = kind if isinstance(kind, list) else options.get("options") if kind == "COMBO" else None
if isinstance(listed, list):
out["type"] = "choice"
out["choices"] = [str(item) for item in listed[:choices]]
if len(listed) > choices:
out["choices_count"] = len(listed)
else:
out["type"] = str(kind)
for key in ("default", "min", "max", "multiline"):
if key in options and isinstance(options[key], (str, int, float, bool)):
out[key] = options[key] if not isinstance(options[key], str) else options[key][:200]
if options.get("forceInput"):
out["socket_only"] = True
return out
def _picked(ctx):
"""What the user picked for this task (ai_toolbox.py): its node types and packs come first in every search."""
return ai_toolbox.effective(ai_tasks.get_task(ctx["task"])) if ctx.get("task") else None
def _allowed(ctx, types):
"""Refuses node types the user's "only" picks leave out, and, while the user lets custom nodes in only
by the ways (ai_gates.node_rule), those of no way the task did not pick either, before the page is asked."""
ways_only = ai_gates.node_rule() == "ways"
if not ctx.get("task") and not ways_only:
return
info = object_info(ctx)
if ways_only:
box = ai_toolbox.effective(ai_tasks.get_task(ctx["task"])) if ctx.get("task") else None
ways = reference_blocks.node_types()
outside = sorted({name for name in types if name in info and not ai_toolbox.builtin(info[name])
and name not in ways and not ai_toolbox.chosen(box, name, info[name])})
if outside:
raise ToolError(f"The user lets custom nodes in only by the ready-made ways (add_nodes use=…): not {', '.join(outside)}. "
"Use a way or ComfyUI's built-in nodes, or ask the user in the AI workbench (show_panel).")
if not ctx.get("task"):
return
refused = ai_toolbox.refused(ctx["task"], types, info)
if refused:
box = ai_toolbox.effective(ai_tasks.get_task(ctx["task"])) or {}
allowed = [*(box.get("nodes") or []), *(f"every node of the pack {pack}" for pack in box.get("packs") or [])]
raise ToolError(f"For this task the user allowed only these node types (and ComfyUI's built-in ones): "
f"{', '.join(allowed) or 'none'}. Not allowed: {', '.join(refused)}. "
"If you need one, ask the user in the AI workbench (show_panel) instead.")
def _node(type_name, info, choices):
inputs = []
for section, required in (("required", True), ("optional", False)):
for name, conf in ((info.get("input") or {}).get(section) or {}).items():
inputs.append(_input(name, conf, required, choices))
names = info.get("output_name") or info.get("output") or []
outputs = [{"slot": slot, "name": str(name), "type": str(kind)}
for slot, (name, kind) in enumerate(zip(names, info.get("output") or []))]
source = ai_toolbox.source_of(info)
return {"type": type_name, "display_name": info.get("display_name") or type_name, "category": info.get("category") or "",
"source": source, "description": str(info.get("description") or "")[:300], "inputs": inputs, "outputs": outputs,
**({"paid": True} if source == "api" else {}), **({"deprecated": True} if info.get("deprecated") else {})}
def find_nodes(ctx, query="", types=None):
info = object_info(ctx)
if types:
missing = [name for name in types if name not in info]
found = [_node(name, info[name], MAX_CHOICES_NAMED) for name in types if name in info][:MAX_FOUND]
return {"nodes": found, **({"not_found": missing} if missing else {})}
words = str(query or "").lower().split()
if not words:
raise ToolError("Give query (words in a node's name, category or description) or types.")
picked = _picked(ctx)
scored = []
for name, item in info.items():
haystack = " ".join(str(part or "") for part in (name, item.get("display_name"), item.get("category"), item.get("description"))).lower()
if all(word in haystack for word in words):
label = f"{name} {item.get('display_name') or ''}".lower()
scored.append((not ai_toolbox.chosen(picked, name, item), 0 if name.lower() == " ".join(words) else 1 if all(w in label for w in words) else 2,
bool(item.get("deprecated")), ai_toolbox.source_of(item) == "api", len(name), name))
scored.sort()
# Searching identifies candidates; don't repeat hundreds of model choices/settings for each.
nodes = []
for *_rest, name in scored[:MAX_SEARCH]:
detail = _node(name, info[name], 0)
nodes.append({"type": name, "display_name": str(detail["display_name"])[:80],
"source": str(detail["source"])[:80],
"description": detail["description"][:96],
"inputs": [f"{i['name'][:48]}:{i['type'][:48]}" for i in detail["inputs"][:8]],
"outputs": [f"{i['name'][:48]}:{i['type'][:48]}" for i in detail["outputs"][:8]],
**{k: detail[k] for k in ("paid", "deprecated") if k in detail},
**({"more_inputs": len(detail["inputs"]) - 8} if len(detail["inputs"]) > 8 else {})})
for node in nodes:
if ai_toolbox.chosen(picked, node["type"], info[node["type"]]):
node["picked_by_user"] = True
while len(nodes) > 1 and len(json.dumps(nodes, ensure_ascii=False)) > 3000:
nodes.pop()
return {"nodes": nodes, "total_matches": len(scored),
"note": "Brief candidates; types=[chosen type] gives settings/choices and output slots when needed. "
"Narrow query for other matches. picked_by_user: preferred; paid: spends credits, use only when asked."}
def _id(value, what):
if isinstance(value, bool) or not isinstance(value, (int, str)) or not str(value).strip() or len(str(value)) > 40:
raise ToolError(f"{what} is a node id from describe_canvas or a tool's answer.")
return value
def _type(value):
if not isinstance(value, str) or not value.strip() or len(value) > 200:
raise ToolError("type is a node type, as find_nodes gives it (e.g. KSampler).")
return value.strip()
def _next(answer):
"""The checklist's next step after a change (the page answers with what is still unwired)."""
if isinstance(answer, dict):
answer["next"] = ("Connect the new branch to an output; it does not reach one yet."
if answer.get("reaches_output") is False else
"Wire unwired inputs with connect_nodes; unique types need only the two node IDs."
if answer.get("unwired") else "No required input is unwired; run_workflow validates and runs the graph.")
return answer
def _ids(value, label):
if value is None:
return []
items = value if isinstance(value, list) else [value]
if not 1 <= len(items) <= 8:
raise ToolError(f"{label} names 1 to 8 node IDs.")
return [_id(item, label) for item in items]
def add_node(ctx, type, values=None, title="", near_node_id=None, insert_after=None, insert_type=None,
wire_from=None, wire_to=None, replace=False):
if not isinstance(replace, bool):
raise ToolError("replace must be true or false.")
if insert_type is not None and (insert_after is None or not isinstance(insert_type, str) or not 0 < len(insert_type) <= 60):
raise ToolError("insert_type names a through type (e.g. IMAGE) and needs insert_after.")
wiring = {"wire_from": _ids(wire_from, "wire_from"), "wire_to": _ids(wire_to, "wire_to"), "replace": replace}
if insert_after is not None:
wiring.update(insert_after=_id(insert_after, "insert_after"), insert_type=insert_type)
_allowed(ctx, [_type(type)])
return _next(_page("add_node", {"type": _type(type), "values": mcp_fill.flat_values(values), "title": str(title or "")[:120],
"near_node_id": None if near_node_id is None else _id(near_node_id, "near_node_id"), **wiring}))
def _api_nodes(nodes, canvas_refs=False):
"""A workflow (or group) in ComfyUI's API form, checked for shape and size; the page checks the rest.
With canvas_refs a link may name a canvas node as "#12", an output by slot or name, and a node wire_to."""
if not isinstance(nodes, dict) or not 0 < len(nodes) <= MAX_WORKFLOW_NODES:
raise ToolError(f"nodes is a workflow in ComfyUI's API form: {{id: {{class_type, inputs}}}}, up to {MAX_WORKFLOW_NODES} nodes.")
if len(json.dumps(nodes, ensure_ascii=False)) > MAX_WORKFLOW_BYTES:
raise ToolError("That workflow is too large.")
for key, spec in nodes.items():
if (len(key) > 40 or not isinstance(spec, dict) or not isinstance(spec.get("class_type"), str)
or not isinstance(spec.get("inputs", {}), dict)):
raise ToolError(f"Node {key[:40]}: each node is {{class_type, inputs: {{name: value or [node id, output slot]}}}}.")
_type(spec["class_type"])
mcp_fill.flat_values({name: value for name, value in spec.get("inputs", {}).items() if not isinstance(value, list)})
if not canvas_refs:
continue
if key.startswith("#"):
raise ToolError(f"Node {key[:40]}: a key of the group cannot start with #, which names canvas nodes.")
for name, value in spec.get("inputs", {}).items():
if isinstance(value, list) and (len(value) != 2 or not isinstance(value[0], (str, int)) or isinstance(value[1], bool)
or not isinstance(value[1], (str, int)) or len(str(value[1])) > 100):
raise ToolError(f"Node {key[:40]}, input {name[:100]}: a link is [a key of this group or \"#<canvas node id>\", output slot or name].")
if isinstance(value, list) and isinstance(value[1], str) and value[1].strip().isdigit():
value[1] = int(value[1])
if spec.get("wire_to") is not None:
spec["wire_to"] = _ids(spec["wire_to"], f"{key[:40]}.wire_to")
return nodes
def _main_base(near_node_id):
"""The base model of the model the canvas's sampler uses, as the page reads its loader (model_advice.py)."""
line = _page("block_model", {"near_node_id": None if near_node_id is None else _id(near_node_id, "near_node_id")})
return model_advice._main_base((line or {}).get("model") or "")
def _base_of(name):
"""ControlNet file `name`'s base model, as Anomalous knows it."""
try:
path = model_advice.folder_paths.get_full_path("controlnet", name)
except (KeyError, TypeError):
path = None
return model_advice.base_of(path) if path else ""
def _picture_model(ctx, model_id, info):
"""(loader value, base model) of the model a standalone block draws with: `model_id`, else the first main
model the user picked for the task. Among diffusion-model files only Anima's text encoder and VAE are known."""
if not model_id:
task = ai_tasks.get_task(ctx["task"]) if ctx.get("task") else None
model_id = next((item for item in (ai_toolbox.effective(task) or {}).get("models") or []
if item.split(":", 1)[0] in model_advice.MAIN_TYPES), None)
if not model_id:
raise ToolError("use picture needs the model to draw with: model (a model_id from search_models; ask_user which), "
"or the one the user picks for the task.")
kind, _index, root, path = _resolve_model(model_id)
base = model_advice.base_of(path)
loader = {"checkpoints": ("CheckpointLoaderSimple", "ckpt_name")}.get(kind, ("UNETLoader", "unet_name"))
family = model_advice.family(base)
if kind not in model_advice.MAIN_TYPES or family not in (("anima",) if loader[0] == "UNETLoader" else ("SDXL", "SD 1.x", "")):
raise ToolError(f"{model_id} is no model use picture draws with: an SDXL or SD 1.5 checkpoint, or an Anima diffusion model "
"(its text encoder and VAE are known). Open a saved workflow for it instead (list_workflows).")
wanted = os.path.relpath(path, root).replace("\\", "/")
value = next((name for name in reference_blocks._choices(info, *loader) if name.replace("\\", "/") == wanted), None)
if value is None:
raise ToolError(f"ComfyUI's {loader[0]} does not list {wanted}: is it in that model folder?")
return value, base
def _empty_canvas():
"""Refuses a standalone block on a canvas that already makes a picture: it would run as a second one."""
try:
_page("block_model", {"near_node_id": None})
except ToolError as error:
if "No sampler" in str(error):
return
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):
if route is not None:
return mcp_routes.build(ctx, str(route), model)
if not isinstance(replace, bool):
raise ToolError("replace must be true or false.")
if [nodes is not None, block is not None, use is not None].count(True) != 1:
raise ToolError("Give one of: use (from begin_task's blocks, with want), block (a ready-made one by name) or nodes (a group in API form).")
if use is not None:
task = ai_tasks.get_task(ctx["task"]) if ctx.get("task") else None
try: # the way the user picked for this use, else the use's best block this ComfyUI can run
block = reference_blocks.pick(str(use), None if want is None else str(want), object_info(ctx), bool(scene),
(ai_toolbox.effective(task) or {}).get("ways") or ())
except reference_blocks.BlockError as error:
raise ToolError(str(error))
if block is not None:
loader, base = None, None
if reference_blocks.BLOCKS.get(str(block), {}).get("standalone"): # a picture from words: the model it draws with, an empty canvas
loader, base = _picture_model(ctx, model, object_info(ctx))
_empty_canvas()
if reference_blocks.BLOCKS.get(str(block), {}).get("takes") in ("reference", "picture") and image:
image = ai_pictures.to_input(image) # the picture to follow or work on, where LoadImage reads
if reference_blocks.BLOCKS.get(str(block), {}).get("scene") and scene:
scene = ai_pictures.to_input(scene)
def build(): # a ready-made block (reference_blocks.py): its roles are found on the canvas, what it cannot splice it replaces
return reference_blocks.build(str(block), object_info(ctx), image, (lambda: base) if loader else lambda: _main_base(near_node_id),
_base_of, scene, lambda name: picture_size(ai_tasks.folder_paths.get_annotated_filepath(name)), loader)
try:
try:
built = build()
except reference_blocks.MissingModel: # a model just downloaded into a subfolder: ComfyUI's lists see it once read anew
model_download.forget_file_lists()
forget_object_info()
built = build()
nodes, replace = built["nodes"], True
except reference_blocks.BlockError as error:
raise ToolError(str(error))
else:
_api_nodes(nodes, canvas_refs=True)
_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))
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"]:
answer["tested"] = built["tested"] # so the AI can say how long it will take
return answer
def connect_nodes(ctx, from_node_id, to_node_id, input=None, output=None, replace=None):
if output is not None and (isinstance(output, bool) or not isinstance(output, (int, str)) or len(str(output)) > 100):
raise ToolError("output is the output's slot number or its name.")
output = int(output) if isinstance(output, str) and output.strip().isdigit() else output
if input is not None and (not isinstance(input, str) or not input or len(input) > 100):
raise ToolError("input is the input's name, as add_node, find_nodes or describe_canvas give it.")
if replace is not None and not isinstance(replace, bool):
raise ToolError("replace must be true or false.")
# Preserve the old explicit-input form's default output and replacement behaviour.
if input is not None and output is None:
output = 0
return _next(_page("connect_nodes", {"from_node_id": _id(from_node_id, "from_node_id"), "output": output,
"to_node_id": _id(to_node_id, "to_node_id"), "input": input, "replace": replace}))
def set_values(ctx, node_id=None, values=None):
if node_id is None:
return _page("set_values", {"node_id": None, "values": mcp_fill.node_values(values)})
if not values:
raise ToolError("values is an object {setting name: value}.")
return _page("set_values", {"node_id": _id(node_id, "node_id"), "values": mcp_fill.flat_values(values)})
def remove_nodes(ctx, node_ids):
if not isinstance(node_ids, list) or not 0 < len(node_ids) <= MAX_REMOVE:
raise ToolError(f"node_ids lists 1 to {MAX_REMOVE} node ids.")
return _next(_page("remove_nodes", {"node_ids": [_id(item, "node_ids") for item in node_ids]}))
def new_workflow(ctx, name="", nodes=None):
if nodes is not None:
_api_nodes(nodes)
_allowed(ctx, [spec["class_type"] for spec in nodes.values()])
return _page("new_workflow", {"name": str(name or "")[:60], "nodes": nodes}, timeout=60)
def open_workflow(ctx, workflow):
return _page("open_workflow", mcp_fill.opening(workflow)[0], timeout=60)
_NODE_ID = {"type": "integer", "description": "A node id."}
_VALUES = {"type": "object", "description": "{setting: value}; a choice exactly as listed."}
_NEIGHBOURS = {"oneOf": [_NODE_ID, {"type": "array", "items": _NODE_ID, "minItems": 1, "maxItems": 8}]}
CANVAS_TOOLS = dict([
_action("find_nodes", "Look up node types",
"Node types, built in and custom. query: up to 5 brief candidates; types=[...]: their inputs, "
"choices, ranges and outputs.",
find_nodes, {"query": {"type": "string"}, "types": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_FOUND}},
read_only=True),
_action("add_node", "Add a node",
"Adds a node, wired in the same call: wire_from feeds it, wire_to takes its output (an input already "
"fed is spliced, its old source now feeding the new node: a ControlNet or LoRA before a KSampler); "
"insert_after=ID splices it behind a node. Same type or same socket name wires itself; anything "
"ambiguous changes nothing and lists the choices; replace:true replaces what it cannot splice. One "
"Ctrl+Z step. Several nodes wired to each other (an inpaint or upscale branch): add_nodes, in one call.",
add_node, {"type": {"type": "string", "description": "A node type from find_nodes, e.g. KSampler."},
"values": _VALUES, "title": {"type": "string"}, "near_node_id": _NODE_ID,
"insert_after": _NODE_ID, "insert_type": {"type": "string", "description": "Through type, only if asked (e.g. IMAGE)."},
"wire_from": _NEIGHBOURS, "wire_to": _NEIGHBOURS, "replace": {"type": "boolean"}}, ["type"]),
_action("add_nodes", "Add a group of nodes",
"Adds a group of nodes in one call, as one block, in ComfyUI's API form: {\"a\": {\"class_type\": "
"\"VAEEncode\", \"inputs\": {\"pixels\": [\"#12\", 0], \"vae\": [\"#4\", \"VAE\"]}}, \"b\": {..., \"inputs\": "
"{\"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.",
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"},
"image": {"type": "string", "description": "A repaint block's inpaint_image; a control block's picture to follow."},
"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."}}),
_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 "
"nothing and lists the choices; then give output and input. replace:true lets it replace wires. One "
"Ctrl+Z step.",
connect_nodes, {"from_node_id": _NODE_ID, "output": {"type": "string", "description": "Optional output slot number or name."},
"to_node_id": _NODE_ID, "input": {"type": "string"}, "replace": {"type": "boolean"}},
["from_node_id", "to_node_id"]),
_action("set_values", "Change a node's settings",
"Changes settings of any node, a third party's too, checked against what each takes: {setting: value} "
"goes to the one node that has it; name the node when several do: {\"12\": {\"denoise\": 0.5}}; or "
"node_id with its settings. Prompts: set_prompt; models: set_model. One Ctrl+Z step.",
set_values, {"node_id": _NODE_ID, "values": _VALUES}, ["values"], destructive=True),
_action("remove_nodes", "Remove nodes",
"Removes nodes from the open workflow, with their wires. One Ctrl+Z step.",
remove_nodes, {"node_ids": {"type": "array", "items": _NODE_ID, "maxItems": MAX_REMOVE}}, ["node_ids"],
destructive=True),
_action("new_workflow", "Start a new workflow",
"Opens a new workflow tab, the user's open one untouched: blank, or built from nodes in ComfyUI's API "
"form ({\"1\": {\"class_type\": \"CLIPTextEncode\", \"inputs\": {\"text\": \"...\", \"clip\": [\"2\", 1]}}}: a value "
"or [node id, output slot]). Checked before it opens; the answer maps your ids to node ids.",
new_workflow, {"name": {"type": "string"}, "nodes": {"type": "object"}}),
_action("open_workflow", "Open a saved workflow",
"Opens a saved workflow or a gallery picture's own in a new tab, the open one kept, "
"to run it or build on it with add_node, connect_nodes and set_values.",
open_workflow, {"workflow": {"type": "string", "description": "A list_workflows name or a search_images picture."}},
["workflow"]),
])
+117
View File
@@ -0,0 +1,117 @@
"""A directory of the tools by what an AI wants to do, so it goes straight to the right one, as
ARCHITECTURE.md does for the code: each tool under its stage of the work (action_catalog.TOOLS),
with its title, in the catalog's order. It is built from the registry, so a new tool shows up by
itself (tests/test_mcp_directory.py checks that every stage has a heading). mcp_server.py puts it
into begin_task's answer, and into get_situation's while there is no task yet.
AI apps keep the tool list they fetched (Antigravity files it away and fetches it again only when
it reconnects), so after Anomalous is updated an app can go on with the old one: new tools missing,
gone ones still called. listed() keeps which list each app fetched last (a fingerprint, in
anomalous/ai_tool_lists.json); a call from an app whose list is not today's tells the AI, and notes
it on the task (task["stale_tools"]) so the workbench tells the user how to reconnect, until the app
fetches the list again.
"""
import hashlib
import json
import os
import time
from . import action_catalog, ai_tasks, mcp_fit
from .mcp_tools import retext
from .path_utils import atomic_write_json
SECTIONS = ( # stage -> heading, in the order work usually goes
("run", "Run a workflow"), ("canvas", "The workflow on the canvas"), ("model", "Models"), ("prompt", "Prompts"),
("combo", "Combos"), ("picture", "Pictures"), ("voice", "Voices"), ("library", "The library"),
(None, "The task and the user"),
)
NOTE = "The tools by what you want to do: go straight to the one you need instead of looking around first."
STALE_NOTE = ("Your app holds an older list of Anomalous's tools (Anomalous was updated since it fetched it): some may "
"be missing or gone. The call went through, but tell the user in report to reconnect the app to Anomalous "
"(Antigravity: restart it, or switch the anomalous MCP server off and on) and to go on in a new chat.")
_lists = None # app -> fingerprint of the tool list it fetched last
def directory(specs, titles=True):
"""{heading: "tool (title), ..."} for the tools of `specs` {name: spec}; the names alone without `titles`."""
sections = {}
for name, entry in action_catalog.TOOLS.items():
if name in specs:
sections.setdefault(entry.stage, []).append(f"{name} ({specs[name].get('title') or name})" if titles else name)
return {"note": NOTE, **{heading: ", ".join(sections[stage]) for stage, heading in SECTIONS if stage in sections}}
def attach(result, name, specs):
"""Puts the directory into begin_task's answer, and into get_situation's while there is no task."""
data = result.get("structuredContent") if isinstance(result, dict) and not result.get("isError") else None
if not isinstance(data, dict) or not (name == "begin_task" or (name == "get_situation" and not data.get("task"))):
return result
answer = {**data, "directory": directory(specs)}
if mcp_fit.size(answer) > mcp_fit.BUDGET: # a long message of the user's with it: the tools by name, to stay readable
answer["directory"] = directory(specs, titles=False)
retext(result, answer)
return result
def fingerprint(tools):
return hashlib.sha256(json.dumps(tools, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()[:16]
def _path():
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_tool_lists.json")
def _known():
global _lists
if _lists is None:
try:
with open(_path(), encoding="utf-8") as source:
stored = json.load(source)
except (OSError, ValueError):
stored = {}
_lists = stored if isinstance(stored, dict) else {}
return _lists
def listed(client, tools):
"""The app `client` fetched `tools`: kept, and the notes on its tasks go."""
with ai_tasks._lock:
known = _known()
if known.get(client) != fingerprint(tools):
known[client] = fingerprint(tools)
atomic_write_json(_path(), known)
changed = [task for task in ai_tasks._load().values() if task.get("stale_tools") and task.get("client") == client]
for task in changed:
task.pop("stale_tools")
ai_tasks._save(task)
for task in changed:
ai_tasks._notify(task["id"])
def stale(client, tools):
"""Whether the app `client` fetched another list than `tools` last (one never seen fetching: no)."""
with ai_tasks._lock:
known = _known().get(client)
return bool(known) and known != fingerprint(tools)
def tell_stale(result, task_id):
"""Says so in the call's answer, and notes it on the task for the workbench."""
if isinstance(result, dict):
data = result.get("structuredContent")
first = (result.get("content") or [None])[0]
if isinstance(data, dict) and not result.get("isError"):
retext(result, {**data, "tool_list": STALE_NOTE})
elif isinstance(first, dict) and first.get("type") == "text":
result["content"][0] = {**first, "text": f"{first.get('text', '')} {STALE_NOTE}"}
if not task_id:
return result
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
if task is None or task.get("stale_tools"):
return result
task["stale_tools"] = time.time()
ai_tasks._save(task)
ai_tasks._notify(task_id)
return result
+143
View File
@@ -0,0 +1,143 @@
"""run_workflow's blanks: a daily run in one call, so a small model need not read and edit the canvas.
With `workflow` (a saved workflow's name, or one of its 方案 as "workflow / plan": a parameter set saved
under it, applied as its Apply button does, or a gallery picture, whose own workflow is opened: `opening`)
the run opens it in the task's tab the first time and reuses that tab after (the page marks a tab
open_workflow opened with the workflow's file, or "picture:" and the picture); what
the AI fills in goes into it first (web/modules/mcp_canvas_edit.js fill_slots): the positive and
negative prompt boxes, the seed of every sampler, the empty latent's width and height, the
picture its LoadImage reads (from the input or the output folder, found here, never copied;
image_node says which LoadImage when there are several), and `values`: any other setting by its
name, a third-party node's as well, for the one node that has it ({node id: {...}} when several
do; set_values takes the same). No fixed list of blanks grows here. The rest stays as saved. Without
`workflow` the blanks go into the canvas the run would run. All of it is checked before anything
changes, and it is one Ctrl+Z step logged as the AI's.
"""
import os
from . import ai_task_canvas, ai_tasks
from .ai_pictures import to_input
from .mcp_bridge import PageRefused, PageUnavailable, ask_page
from .mcp_tools import IMAGE_TYPES, ToolError, _resolve_folder_image, _resolve_image, plans
from .recipe_images import _embedded_workflow_payload
SLOTS = ("prompt", "negative", "seed", "width", "height", "image", "image_node", "values")
MAX_VALUES = 60
MAX_TEXT = 20000
def flat_values(values):
"""{setting name: value}, checked for shape and size; the page checks what each setting takes."""
if values is None:
return {}
if not isinstance(values, dict) or len(values) > MAX_VALUES:
raise ToolError(f"values is an object {{setting name: value}} of up to {MAX_VALUES} settings.")
for name, value in values.items():
if len(name) > 100 or not (isinstance(value, (bool, int, float)) or (isinstance(value, str) and len(value) <= MAX_TEXT)):
raise ToolError(f"The value of {name[:100]} is a number, true/false or text of up to {MAX_TEXT} characters.")
return values
def node_values(values):
"""Settings by name ({setting name: value}) or for one node ({node id: {setting name: value}})."""
if not isinstance(values, dict) or not values:
raise ToolError('values is {setting name: value}, or {"<node id>": {setting name: value}} for one node.')
nested = {key: value for key, value in values.items() if isinstance(value, dict)}
for key, settings in nested.items():
if not str(key).isdigit():
raise ToolError(f"values.{str(key)[:40]}: only a node id takes an object of settings.")
flat_values(settings)
flat_values({key: value for key, value in values.items() if key not in nested})
if len(values) - len(nested) + sum(len(settings) for settings in nested.values()) > MAX_VALUES:
raise ToolError(f"values sets up to {MAX_VALUES} settings at once.")
return values
def saved_workflow(name):
"""(file name, name, plan) of the user's saved workflow called `name`, or of its 方案 "workflow / plan"
(plan: the parameter set's file name, else None), or of the example called that."""
from . import example_workflows
from .recipe_store import _list_recipes, get_recipes_dir
name = str(name or "").strip()
files = {}
for recipe in _list_recipes(get_recipes_dir()):
files.setdefault(recipe["data"].get("name") or recipe["filename"], recipe["filename"])
if name and name in files:
return files[name], name, None
example = example_workflows.file_of(name)
if example:
return example, name, None
workflow, _, plan = name.rpartition(" / ")
found = next((file for file, plan_name in plans().get(files.get(workflow), []) if plan_name == plan), None)
if not found:
raise ToolError(f"No saved workflow or plan called {name}; list_workflows names them.")
return files[workflow], name, found
def picture_workflow(name):
"""{subfolder, filename} of gallery picture `name` (in the output folder) when the name is a
picture's and it carries a workflow; None when the name is no picture's."""
if os.path.splitext(str(name or "").strip())[1].lower() not in IMAGE_TYPES:
return None
rel, path = _resolve_image(str(name).strip())
if not (_embedded_workflow_payload(path) or {}).get("workflow"):
raise ToolError(f"{rel} carries no workflow to open: only the pictures ComfyUI saves with one do.")
subfolder, _, filename = rel.rpartition("/")
return {"subfolder": subfolder, "filename": filename}
def opening(name):
"""(what the page's open_workflow takes, the mark it leaves on the tab) for `name`: a saved workflow,
one of its plans or an example (saved_workflow), else a gallery picture's own workflow."""
try:
filename, shown, plan = saved_workflow(name)
except ToolError:
picture = picture_workflow(name) # not a saved one: a picture's own, to change what made it
if not picture:
raise
rel = "/".join(part for part in (picture["subfolder"], picture["filename"]) if part)
return {"picture": picture, "name": os.path.splitext(picture["filename"])[0]}, f"picture:{rel}"
return {"filename": filename, "name": shown, "plan": plan}, (f"{filename}#{plan}" if plan else filename)
def _picture(image):
"""The value a LoadImage takes for picture `image`: in the input folder as it is, from the
output folder its copy there (ai_pictures.to_input)."""
for folder in ("input", "output"):
try:
rel, _path = _resolve_folder_image(image, folder)
except ToolError:
continue
return rel if folder == "input" else to_input(rel)
raise ToolError(f"{image} is in neither ComfyUI's input nor its output folder; search_images lists the pictures in the output.")
def _page(action, args, timeout=30):
try:
return ask_page(action, args, timeout)
except (PageUnavailable, PageRefused) as error:
raise ToolError(str(error))
def fill(ctx, workflow=None, **slots):
"""Opens `workflow` for the task (once) and fills `slots` in; what was set, or None when nothing was asked."""
slots = {key: value for key, value in slots.items() if key in SLOTS and value not in (None, "")}
if not workflow and not slots:
return None
if "image" in slots:
slots["image"] = _picture(slots["image"])
if "values" in slots:
node_values(slots["values"])
args, opened = opening(workflow) if workflow else ({}, None) # opened: what the page marks the tab with
name = args.get("name")
task = ai_tasks.get_task(ctx.get("task")) if ctx.get("task") else None
no_tab_yet = bool(task) and ai_task_canvas.mode_of(task) == "new" and not (task.get("canvas") or {}).get("tab")
filled = None if workflow and no_tab_yet else _page("fill_slots", {"filename": opened, **slots})
if workflow and (filled is None or filled.get("needs_open")):
_page("open_workflow", args, timeout=60)
filled = _page("fill_slots", {"filename": opened, **slots})
if filled.get("needs_open"):
raise ToolError(f"The saved workflow {name} did not open; nothing was run.")
return {**({"workflow": name} if name else {}), **(filled.get("set") or {})}
+51
View File
@@ -0,0 +1,51 @@
"""Answers an AI app reads in the chat. Past about 4,000 characters Antigravity (and apps like it) keeps
a tool's answer as a file for the AI to open, and an AI told to use only Anomalous's tools never
opens it: it sees nothing and asks again with smaller limits (the 1.63 exam: search_models eight
times for one LoRA). An answer that is one list and longer than BUDGET keeps the items that fit, in
their order, and says how many it left out, by name when its items have one, and how to reach
them. Tools that fit their answers themselves (get_situation, search_models, list_workflows) are
under it already; this is the net under the rest (list_combos, recent_activity and any list to
come). Nothing is shortened inside an item: a prompt cut in half could be put in as it is.
mcp_server.py runs it after the step is recorded, so the timeline keeps the whole answer."""
from .mcp_tools import retext, tool_text
# UTF-8 bytes. Antigravity's logs show answers filed from 4,099 bytes (3,627 characters, some of them
# Chinese at three bytes each) while longer ones were shown at other times: stay under the lowest.
BUDGET = 3800
NAME = 60 # characters of a left-out item's name
def size(data):
return len(tool_text(data).encode("utf-8"))
# A canvas cut short would read as a smaller canvas: get_situation gives every node in brief.
WHOLE = {"describe_canvas"}
def _the_list(data):
"""The key of the one list of items in `data`, or None when there is none or more than one."""
keys = [key for key, value in data.items() if isinstance(value, list) and value and all(isinstance(item, dict) for item in value)]
return keys[0] if len(keys) == 1 else None
def fit(result, name):
"""`result` (an MCP tools/call result) with its one list cut to BUDGET, when it is longer."""
data = result.get("structuredContent") if isinstance(result, dict) else None
if name in WHOLE or result.get("isError") or not isinstance(data, dict) or size(data) <= BUDGET:
return result
key = _the_list(data)
if not key:
return result
items = list(data[key])
kept = {**data, key: items}
while size(kept) > BUDGET and len(items) > 1:
items.pop()
left = data[key][len(items):]
named = [str(item["name"])[:NAME] for item in left if item.get("name")]
kept["left_out"] = (f"{len(left)} more {key} left out to keep this answer readable in the chat"
+ (" (more_names)" if named else "") + ": a narrower query, a smaller limit or the next page lists them")
if named:
kept["more_names"] = named # what there is, to ask for by name
if len(items) < len(data[key]):
retext(result, kept)
return result
+86
View File
@@ -0,0 +1,86 @@
"""zoom_image, the AI's closer look: part of a picture (in ComfyUI's output, input or temp folder)
cut out and shown larger, and the same part of a second picture beside it to compare a before
and an after, attached to the answer. It reads only: the pictures stay as they are. With it an AI
need not crop, enlarge or compare pictures with code of its own (each such command asks the user
to allow it). The region is in fractions of the picture, as the user's marks give theirs."""
import base64
import io
from PIL import Image
from .mcp_tools import ToolError, _resolve_folder_image, _tool
MIN_SIZE, MAX_SIZE, DEFAULT_SIZE = 256, 1024, 768
MAX_ENLARGE = 4 # a tiny part is shown at most this many times its size
MIN_PIXELS = 8
def _box(region, width, height):
"""Region {x, y, width, height} (fractions 0-1, default the whole picture) as a pixel box."""
region = region if isinstance(region, dict) else {}
try:
x, y = (min(1.0, max(0.0, float(region.get(key, 0)))) for key in ("x", "y"))
w, h = (min(1.0, max(0.0, float(region.get(key, 1)))) for key in ("width", "height"))
except (TypeError, ValueError):
raise ToolError("region is {x, y, width, height}, each a fraction 0-1 of the picture.")
left, top = round(x * width), round(y * height)
right, bottom = min(width, round((x + w) * width)), min(height, round((y + h) * height))
if right - left < MIN_PIXELS or bottom - top < MIN_PIXELS:
raise ToolError(f"The region is under {MIN_PIXELS} pixels across; give a larger one.")
return left, top, right, bottom
def _part(path, region, size):
"""(facts, MCP image item) of `region` of the picture at `path`, its longer side `size` at most."""
try:
with Image.open(path) as picture:
box = _box(region, picture.width, picture.height)
part = picture.crop(box).convert("RGB")
whole = f"{picture.width}x{picture.height}"
except (OSError, ValueError, Image.DecompressionBombError):
raise ToolError("The picture cannot be read.")
scale = min(size / max(part.size), MAX_ENLARGE)
shown = part.resize((max(1, round(part.width * scale)), max(1, round(part.height * scale))), Image.LANCZOS)
data = io.BytesIO()
shown.save(data, "JPEG", quality=88)
facts = {"picture": whole, "region": f"x {box[0]}-{box[2]}, y {box[1]}-{box[3]} px", "shown": f"{shown.width}x{shown.height}"}
return facts, {"type": "image", "data": base64.b64encode(data.getvalue()).decode("ascii"), "mimeType": "image/jpeg"}
def zoom_image(image, region=None, folder="output", compare_with="", compare_folder="", size=DEFAULT_SIZE):
try:
size = min(MAX_SIZE, max(MIN_SIZE, int(size)))
except (TypeError, ValueError):
raise ToolError(f"size is {MIN_SIZE}-{MAX_SIZE} pixels.")
rel, path = _resolve_folder_image(image, folder)
facts, item = _part(path, region, size)
answer = {"image": rel, "folder": folder, **facts}
extra = [item]
if compare_with:
other_folder = compare_folder or folder
other_rel, other_path = _resolve_folder_image(compare_with, other_folder)
other_facts, other_item = _part(other_path, region, size)
answer["compare_with"] = {"image": other_rel, "folder": other_folder, **other_facts}
answer["note"] = "Two pictures are attached: first image, then compare_with, the same region of each."
extra.append(other_item)
return answer, extra
_REGION = {"type": "object", "description": "Fractions 0-1, like the user's marks; default all.",
"properties": {name: {"type": "number", "minimum": 0, "maximum": 1} for name in ("x", "y", "width", "height")}}
_FOLDER = {"type": "string", "enum": ["output", "input", "temp"], "default": "output"}
LOOK_TOOLS = dict([
_tool("zoom_image", "Look closer at a picture",
"Part of a picture larger, at full detail (a hand, a face, a seam). folder: output, input or temp. "
"compare_with: a second picture, the same region beside it. Use it instead of cropping with code; "
"change pictures only by running a workflow, never by editing their files.",
zoom_image, {"image": {"type": "string", "description": "As the other tools name it."},
"region": _REGION, "folder": _FOLDER,
"compare_with": {"type": "string", "description": "A second picture."},
"compare_folder": {"type": "string", "enum": ["output", "input", "temp"],
"description": "Default: folder."},
"size": {"type": "integer", "minimum": MIN_SIZE, "maximum": MAX_SIZE, "default": DEFAULT_SIZE}},
["image"]),
])
+204
View File
@@ -0,0 +1,204 @@
"""Where a model loads, for the AI: search_models and get_model (mcp_tools.py finds the files)
say which node types list each file among their choices (ComfyUI's /object_info, kept a little
while by mcp_canvas_edit.py), and, when a file's header says it is another kind than the folder
it is in (model_placement.py, the models page's Tidy view), where it belongs and that nothing
loads it where it is. So the AI picks the right node at once, or has the model moved, instead of
searching ComfyUI itself. move_model moves one model with its covers and notes as Tidy does,
behind the "files" gate (the user says yes first unless they let it go ahead)."""
import os
import threading
import folder_paths
from . import mcp_canvas_edit, mcp_fit, mcp_tools, model_advice, model_placement
from .mcp_actions import _action
from .mcp_tools import _LIMIT, _QUERY, ToolError
from .model_constants import MODEL_EXTENSIONS
from .model_import import Refused, import_types
MAX_LOADERS = 6
SEARCH_WORDS = 4 # trigger words a found model shows; get_model has them all
_lock = threading.Lock()
_index = {"of": None, "inputs": [], "by_choice": {}}
def _plain(name):
return str(name).replace("\\", "/")
def _choices(conf):
if not isinstance(conf, list) or not conf:
return None
if isinstance(conf[0], list):
return conf[0]
options = conf[1] if len(conf) > 1 and isinstance(conf[1], dict) else {}
return options.get("options") if conf[0] == "COMBO" and isinstance(options.get("options"), list) else None
def _built(info):
"""The drop-downs of model files in node definitions `info`: [(node type, choices)] and choice -> their places."""
with _lock:
if _index["of"] is info:
return _index
inputs, by_choice = [], {}
for node_type, item in info.items():
for section in ("required", "optional"):
for conf in ((item.get("input") or {}).get(section) or {}).values():
listed = [_plain(choice) for choice in _choices(conf) or [] if isinstance(choice, str)]
if not any(choice.lower().endswith(MODEL_EXTENSIONS) for choice in listed):
continue
for choice in listed:
by_choice.setdefault(choice, []).append(len(inputs))
inputs.append((node_type, frozenset(listed)))
_index.update(of=info, inputs=inputs, by_choice=by_choice)
return _index
def _folder(folder_type):
return frozenset(_plain(name) for name in folder_paths.get_filename_list(folder_type))
def _readers(ctx, folder_type, rel=None):
"""Node types with a drop-down of the whole folder `folder_type` (one that lists `rel`, when given)."""
index = _built(mcp_canvas_edit.object_info(ctx))
folder = _folder(folder_type)
if not folder:
return []
places = index["by_choice"].get(_plain(rel), []) if rel else range(len(index["inputs"]))
found = []
for at in places:
node_type, choices = index["inputs"][at]
if folder <= choices and node_type not in found:
found.append(node_type)
return found[:MAX_LOADERS]
def _about(ctx, model):
"""`model` (from mcp_tools) with what loads it: loaded_by, or belongs_in and placement when it is misfiled."""
folder_type, root, base_dir, path = mcp_tools._resolve_model(model["model_id"])
rel = os.path.relpath(path, base_dir).replace(os.sep, "/")
home = model_placement.belongs(model_placement.one(folder_type, root, rel, path))
if not home or home["works"]:
model["loaded_by"] = _readers(ctx, folder_type, rel)
return model
kind, to = home["kind"], home["to"]
readers = _readers(ctx, kind)
words = (f"Its header says it is a {kind} file, but it is in {folder_type}: the nodes listing it there cannot load it"
+ (f"; {', '.join(readers)} load {kind} files." if readers else "."))
there = os.path.join(folder_paths.get_folder_paths(kind)[to["root"]], *to["rel"].split("/"))
if os.path.isfile(there):
words += (f" A file of the same name is already in {kind} (model_id {kind}:{to['root']}:{to['rel']}): use that one. "
"If it is the same file, this copy is extra; the user can recycle it in Anomalous's models page (Tidy).")
else:
words += f" move_model with to_type {kind} moves it there (the user confirms first)."
model.update(belongs_in=kind, placement=words)
return model
def _compact(found):
"""search_models' answer small enough to be read (mcp_fit.BUDGET): what a model_id says already
(file, folder, type) left out, loaded_by once for a type when its models share it, a few trigger
words, and as many models as fit, the rest by name (more_names), so one answer shows all there is
and the AI need not search again to see the rest."""
models, kinds = found["models"], {}
for model in models:
kinds.setdefault(model["type"], []).append(model)
shared = {kind: group[0]["loaded_by"] for kind, group in kinds.items()
if all("loaded_by" in model and model["loaded_by"] == group[0]["loaded_by"] for model in group)}
for model in models:
for key in ("file", "folder"):
model.pop(key, None)
if model.pop("type") in shared:
model.pop("loaded_by")
words = model.get("trigger_words") or []
if len(words) > SEARCH_WORDS:
model.update(trigger_words=words[:SEARCH_WORDS], more_trigger_words=len(words) - SEARCH_WORDS)
answer = {"total_matches": found["total_matches"], **({"loaded_by": shared} if shared else {}), "models": models}
everything = list(models)
while mcp_fit.size(answer) > mcp_fit.BUDGET and len(models) > 1:
models.pop()
answer["more_names"] = [str(model.get("name") or model["model_id"])[:mcp_fit.NAME] for model in everything[len(models):]]
answer["left_out"] = (f"{len(answer['more_names'])} more, named in more_names to keep this answer readable: "
"get_model or a query with the name gives one in full")
return answer
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
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")
if weight:
model["author_weight"] = weight
return _compact(found)
def get_model(ctx, model_id, include_cover=False):
detail, extra = mcp_tools.get_model(model_id, include_cover)
folder_type, _idx, _base, path = mcp_tools._resolve_model(model_id)
examples = model_advice.author_examples(path, folder_type, detail.get("sha256", ""))
if examples:
detail["author_examples"] = examples
return _about(ctx, detail), extra
def move_model(ctx, model_id, to_type):
folder_type, root, base_dir, path = mcp_tools._resolve_model(model_id)
rel = os.path.relpath(path, base_dir).replace(os.sep, "/")
item = model_placement.one(folder_type, root, rel, path)
home = model_placement.belongs(item)
to_type = str(to_type or "")
if to_type == folder_type:
raise ToolError(f"It is in {to_type} already.")
if to_type not in import_types():
raise ToolError(f"to_type is one of {', '.join(import_types())}.")
to = home["to"] if home and home["kind"] == to_type else model_placement.home_for(item, to_type)
if not to:
raise ToolError(f"This ComfyUI has no {to_type} folder.")
if os.path.exists(os.path.join(folder_paths.get_folder_paths(to_type)[to["root"]], *to["rel"].split("/"))):
raise ToolError(f"A file of the same name is already in {to_type} (model_id {to_type}:{to['root']}:{to['rel']}); "
"nothing was moved. Use that one; the user can recycle an extra copy in Anomalous's models page (Tidy).")
try:
moved = model_placement.move({"type": folder_type, "root": root, "rel": rel,
"to_type": to_type, "to_root": to["root"], "to_rel": to["rel"]})
except Refused as error:
raise ToolError(f"Not moved ({error.code}).")
mcp_canvas_edit.forget_object_info()
answer = {"moved": {"from": f"{folder_type}/{rel}", "to": f"{moved['type']}/{moved['rel']}"},
"model_id": f"{moved['type']}:{moved['root']}:{moved['rel']}",
"loaded_by": _readers(ctx, moved["type"], moved["rel"])}
if moved["left"]:
answer["left_behind"] = moved["left"] # covers or notes that could not go along
return answer
MODEL_TOOLS = dict([
_action("search_models", "Search models",
"Model files by name, trigger word or base model: model_id, and loaded_by (the node types that list "
"the file). A misfiled one comes with belongs_in: nothing loads it where it is.",
search_models, {
"query": _QUERY,
"type": {"type": "string", "description": "A models folder: checkpoints, loras, vae…"},
"base_model": {"type": "string", "description": "e.g. SDXL, Pony, Flux."},
"source": {"type": "string", "enum": ["civitai", "inferred_from_file", "not_scanned"],
"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.",
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",
"Moves a model the user has in the wrong models folder (search_models says belongs_in) to where it loads, "
"with its covers and notes, as Anomalous's Tidy view does; never over a file. The answer gives its new "
"model_id and loaded_by; the canvas lists it from then on.",
move_model, {"model_id": {"type": "string", "description": "From search_models."},
"to_type": {"type": "string", "description": "The models folder to move it to: belongs_in from search_models."}},
["model_id", "to_type"], destructive=True),
])
+59
View File
@@ -0,0 +1,59 @@
"""Ready-made requests the user can start from their AI app (MCP prompts; Claude shows them as
slash commands such as /anomalous:pick_best): each is a few lines telling the AI how to do a
common job with Anomalous's tools so the user can watch, choose and step in on the AI workbench.
They only shape the AI's first message; they change nothing themselves."""
PROMPTS = {
"workbench_task": {
"title": "Start a task on the AI workbench",
"description": "Do something in ComfyUI the way the user can follow on Anomalous's AI workbench.",
"arguments": [{"name": "what", "description": "What to do", "required": True}],
"text": ("Use Anomalous for this: {what}\n"
"Call begin_task with a short title in my language first. Show your plan, then results, with "
"show_panel in the order they happen; when a choice is mine, ask it with ask_user (or a picks panel, "
"waiting with get_answers), never in this chat. Honour any user_messages that come with a tool result. End with report(done=true)."),
},
"pick_best": {
"title": "Make a few, let me pick",
"description": "Make several variations with the open workflow and let the user pick one.",
"arguments": [{"name": "what", "description": "What to vary (seed, LoRA weight, prompt…)", "required": True},
{"name": "count", "description": "How many (default 3)", "required": False}],
"text": ("Make {count} variations of the open ComfyUI workflow, changing: {what}.\n"
"Call begin_task first. Read the canvas (describe_canvas), change one thing per run, run the workflow "
"each time and find the pictures with search_images. Then show them with show_panel as a picks block, "
"each with what you changed as its reason, and wait for my pick with get_answers. Put the chosen "
"settings back on the canvas and report(done=true)."),
},
"fix_missing_models": {
"title": "Fix the missing models",
"description": "Find what the open workflow lacks and put it back, asking before downloads.",
"arguments": [],
"text": ("Check the open workflow's models with check_workflow_models (call begin_task first). Put back the ones "
"Anomalous found with certainty (fix_workflow_models). Show what is still missing with show_panel as a "
"table, and ask me with choices before downloading anything. report(done=true) when finished."),
},
"explain_canvas": {
"title": "Explain the open workflow",
"description": "A picture of the open workflow's main chain with a short explanation.",
"arguments": [],
"text": ("Explain the workflow open in ComfyUI. Call begin_task, read it with describe_canvas, then show_panel "
"with a diagram block of its main chain (loaders, prompts, sampler, decoding, saving; node_id on each "
"box) and a few lines of text on what each part does and which settings matter most. Change nothing."),
},
}
def list_prompts():
return {"prompts": [{"name": name, "title": item["title"], "description": item["description"], "arguments": item["arguments"]}
for name, item in PROMPTS.items()]}
def get_prompt(name, arguments=None):
"""prompts/get, or None for an unknown name or a missing required argument."""
item = PROMPTS.get(name)
arguments = arguments if isinstance(arguments, dict) else {}
if not item or any(arg["required"] and not str(arguments.get(arg["name"], "")).strip() for arg in item["arguments"]):
return None
values = {"what": "", "count": "3", **{key: str(value) for key, value in arguments.items()}}
return {"description": item["description"],
"messages": [{"role": "user", "content": {"type": "text", "text": item["text"].format(**values)}}]}
+137
View File
@@ -0,0 +1,137 @@
"""Routes: what to do with the models the user picked, chosen by the user, built by Anomalous. A light
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
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 .mcp_tools import ToolError, _resolve_model
from .recipe_store import _list_recipes, get_recipes_dir
with open(os.path.join(os.path.dirname(__file__), "routes.json"), encoding="utf-8") as source:
ROUTES = json.load(source)
MAX_CARDS = 3
QUESTION = "How should the two models you picked be used?"
TOOLBOX_NOTE = ("The user picked two main models: ask_user(routes=true) asks them how to use the two, with options Anomalous "
"prepared, and its answer says what to call. Skip it only when the user already said how.")
NEXT = "run_workflow(prompt=, negative=, base_only=true, batch=3) shows the user bases to pick from; then run_workflow runs the whole route."
ONE_PASS = "run_workflow(prompt=, negative=) draws it; there is no second pass, so no base to pick."
def _main(box):
return [item for item in (box or {}).get("models") or [] if item.split(":", 1)[0] in model_advice.MAIN_TYPES]
def _loras(box):
return [item for item in (box or {}).get("models") or [] if item.startswith("loras:")]
def _facts(model_id):
"""(file name without folders, base model) of `model_id`; ToolError when it is gone."""
_kind, _index, _root, path = _resolve_model(model_id)
return os.path.splitext(os.path.basename(path))[0], model_advice.base_of(path)
def _can_build(route, base, info):
if route.get("only") and model_advice.family(base) not in route["only"]:
return False
if model_advice.family(base) in route.get("not", ()):
return False
try:
reference_blocks.build("picture", info, main_base=lambda: base, model="model")
if route.get("way"):
name = reference_blocks.pick(*route["way"], info)
reference_blocks.build(name, info, main_base=lambda: base, base_of=lambda _name: base)
except reference_blocks.BlockError:
return False
return True
def _cards():
"""(name, stages, line) of each saved workflow."""
for recipe in _list_recipes(get_recipes_dir(), with_workflow=True):
data = recipe.get("data") or {}
workflow = data.get("workflow")
stages = [stage for stage in workflow_route.route(workflow)[0] if stage["state"] == "run"]
yield data.get("name") or recipe["filename"], stages, workflow_route.line(workflow)
def cards_of(model_id):
"""The saved workflows drawing with `model_id`, each with its route: the model's examples."""
name = _facts(model_id)[0]
return [{"card": card, "route": line} for card, stages, line in _cards() if any(stage["model"] == name for stage in stages)][:MAX_CARDS]
def offer(box, info):
"""{"options": [ask_user's choices], "calls": {option id: what to call}} for the two main models in `box`,
or None: fewer picked, one gone, or of different families (a switch reuses the first's prompts and VAE)."""
picked = _main(box)[:2]
if len(picked) < 2:
return None
try:
(first, base), (second, other) = _facts(picked[0]), _facts(picked[1])
except ToolError:
return None
if not model_advice.family(base) or model_advice.family(base) != model_advice.family(other):
return None
names = {"a": first, "b": second}
options, calls = [], {}
card = next((card for card, stages, _line in _cards() if {first, second} <= {stage["model"] for stage in stages}), None)
if card:
options.append({"id": "card", "label": f"Your saved card: {card}"[:80], "label_key": "aiRouteCard", "values": {"name": card},
"detail": "A route you saved with these two models.", "detail_key": "aiRouteCardDetail", "recommended": True})
calls["card"] = f'run_workflow(workflow="{card}", prompt=, negative=, base_only=true, batch=3) shows the user bases to pick from; then run_workflow.'
for key, route in ROUTES.items():
if _can_build(route, base, info):
options.append({"id": key, "label": route["label"].format(**names)[:80], "label_key": f"aiRoute_{key}", "values": names,
"detail": route["detail"], "detail_key": f"aiRouteDetail_{key}", "sample": key})
calls[key] = f'add_nodes(route="{key}") builds it. Then {NEXT if route.get("way") else ONE_PASS}'
if not card and options:
options[0]["recommended"] = True
return {"options": options, "calls": calls} if len(options) > 1 else None
def waiting(task):
"""Whether the task's picks still wait for the user's route: two main models picked, none asked, nothing run."""
return len(_main(ai_toolbox.effective(task))) >= 2 and not any(
step.get("routes") or step.get("tool") == "run_workflow" for step in task.get("steps") or [])
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
route = ROUTES.get(key)
if not route:
raise ToolError(f"No route {key!r}; there are: {', '.join(ROUTES)}.")
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.")
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}
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)
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)
mcp_canvas_edit.set_values(ctx, values={str(before): {"return_with_leftover_noise": "disable"}, str(after): {"add_noise": "enable"}})
return answer
+338
View File
@@ -0,0 +1,338 @@
"""MCP endpoint: AI apps (Claude, Cursor, Cherry Studio…) use Anomalous's library as tools.
POST /anomalous/mcp speaks the Model Context Protocol's Streamable HTTP transport, both eras:
- modern (2026-07-28): stateless; every request carries its protocol version in `_meta` and
the MCP-Protocol-Version / Mcp-Method / Mcp-Name headers, which must match the body;
- legacy (2025-03-26 to 2025-11-25): an `initialize` handshake first. No session is kept
(sessions are optional there), so both eras are served the same stateless way.
Only this computer may connect: the peer must be loopback, no proxy header may be present,
and Host and Origin (when sent) must name a loopback host, which stops DNS rebinding.
Answers are always one JSON object; there are no server-initiated messages. The tools are
in mcp_tools.py (reading), mcp_actions.py (acting) and mcp_task_tools.py (the AI workbench's
begin_task and report); every call is kept as a step of a task (ai_tasks.py) for the AI
workbench page, starting in state "running" and updated on completion, with unread user
messages and panel answers attached to the next successful tool response, and pictures changed
outside Anomalous since the AI's previous call shown and told (ai_outside.py). POST
/anomalous/mcp/bridge is where the open ComfyUI page claims and answers canvas actions
(mcp_bridge.py).
"""
import asyncio
import base64
import time
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 .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_model_use import MODEL_TOOLS
from .mcp_look import LOOK_TOOLS
from .mcp_situation import SITUATION_TOOLS
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}
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)
}
MODERN_VERSIONS = ("2026-07-28",)
LEGACY_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26")
SERVER_INFO = {"name": "anomalous-model-browser", "title": "Anomalous Model Browser", "version": "1.0.0"}
INSTRUCTIONS = (
"Tools over the user's ComfyUI library as Anomalous Model Browser keeps it: model files with "
"their Civitai information, output images with their generation settings, combos (a model, "
"LoRAs and a prompt), workflow recipes, saved prompts, generated audio, the scan status and "
"the activity log; and tools that act on the workflow open in ComfyUI (get_situation gives flow chains; "
"describe_canvas only for details) or build one where the user watches: find_nodes searches brief types; "
"add_node wires in the same call (wire_from/wire_to; wire_to=ID splices it in front of that node, "
"insert_after=ID behind it); add_nodes adds a whole group in ComfyUI's API form in one call; "
"connect_nodes needs only two IDs unless sockets are ambiguous. Reuse receipts instead of rereading "
"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: "
"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 "
"with a short title when you start what they asked for (it continues the waiting or active task; "
"new_task=true is only for a separate user request), and report to explain a choice, hand in "
"pictures made another way, or end the task (done=true). The user can write to you or answer "
"panel questions there: call get_answers to wait for or read them, and any unread user_messages "
"or user_answers attached to a tool result must also be honoured. While you work on an Anomalous task, ask "
"the user every question (a choice, a preference, yes or no) with ask_user, never in your own chat or its "
"question form: the user watches and answers in Anomalous. Until they say the task is done, end every round "
"with ask_user (what next, or whether to go ahead), not with a note or panel alone; only a run, download or "
"scan going on lets you end your turn without asking. Likewise write your explanations and replies "
"there with report (reply_to answers their message or marks), not only in your chat. Show plans, workflow explanations "
"and results in chronological order with show_panel; update_panel appends new content below what "
"the user already saw. Use run_workflow for execution: it returns real state/results after a bounded wait. "
"If you submit through /prompt or another tool, immediately call track_run with YOUR returned prompt_ids "
"and task ID, or the workbench cannot observe it. For running results use run_status(wait_seconds=55); "
"do not inspect output directories or resubmit to find out whether it finished. Change pictures only by "
"running workflows, never with code of your own; if you do use another tool (a command, a script, files), "
"say what you ran with log_outside."
)
LIST_TTL_MS = 300000
# Requests run tools in threads; a few at a time is plenty for one person's AI app.
_slots = asyncio.Semaphore(4)
PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS = -32700, -32600, -32601, -32602
HEADER_MISMATCH, UNSUPPORTED_VERSION = -32020, -32022
def _origin_ok(request):
origin = request.headers.get("Origin")
return origin is None or _loopback_host(urllib.parse.urlsplit(origin).hostname)
def _decode_header(value):
"""Mcp-Name values outside plain ASCII come as =?base64?…?=."""
if value and value.startswith("=?base64?") and value.endswith("?="):
try:
return base64.b64decode(value[9:-2], validate=True).decode("utf-8")
except (ValueError, UnicodeDecodeError):
return None
return value
def _error(id_, code, message, status=200, data=None):
error = {"code": code, "message": message}
if data is not None:
error["data"] = data
body = {"jsonrpc": "2.0", "error": error}
if id_ is not None:
body["id"] = id_
return web.json_response(body, status=status)
def _result(id_, result, modern):
if modern:
result = {"resultType": "complete", **result}
return web.json_response({"jsonrpc": "2.0", "id": id_, "result": result})
def _tools():
"""The tool list AI apps are offered, as tools/list gives it."""
return [mcp_apps.with_card(tool["spec"]) for tool in TOOLS.values()]
def _tool_list(modern):
result = {"tools": _tools()}
if modern:
result.update(ttlMs=LIST_TTL_MS, cacheScope="public")
return result
def _initialize(params):
requested = params.get("protocolVersion")
return {
"protocolVersion": requested if requested in LEGACY_VERSIONS else LEGACY_VERSIONS[0],
"capabilities": CAPABILITIES,
"serverInfo": SERVER_INFO,
"instructions": INSTRUCTIONS,
}
def _discover():
return {
"supportedVersions": list(MODERN_VERSIONS + LEGACY_VERSIONS),
"capabilities": CAPABILITIES,
"_meta": {"io.modelcontextprotocol/serverInfo": SERVER_INFO},
"instructions": INSTRUCTIONS,
"ttlMs": LIST_TTL_MS,
"cacheScope": "public",
}
def _modern_header_problem(request, method, params, version):
"""Why the mirrored headers do not match the body (2026-07-28), or None."""
if request.headers.get("MCP-Protocol-Version") != version:
return "MCP-Protocol-Version header missing or not the body's protocol version"
if request.headers.get("Mcp-Method") != method:
return "Mcp-Method header missing or not the body's method"
if method in ("tools/call", "prompts/get", "resources/read"):
name = params.get("uri") if method == "resources/read" else params.get("name")
if _decode_header(request.headers.get("Mcp-Name")) != name:
return "Mcp-Name header missing or not the body's name"
return None
async def _call(params, request):
name = params.get("name")
raw_arguments = params.get("arguments") or {}
if not isinstance(name, str) or name not in TOOLS or not isinstance(raw_arguments, dict):
return None
arguments = dict(raw_arguments)
client = ai_callers.client_of(request.headers, params)
session = ai_callers.session_of(request.headers, params)
task_id = ai_callers.task_of(request.headers, params, arguments)
if "task" in arguments and "task" not in TOOLS[name]["spec"]["inputSchema"].get("properties", {}):
arguments.pop("task", None)
arguments, renamed = prepare_arguments(TOOLS[name]["spec"]["inputSchema"], arguments)
ctx = {"base_url": f"{request.scheme}://{request.host}", "client": client, "session": session, "task": task_id} # loopback, checked in api_mcp
started = time.time()
read_only = bool(TOOLS[name]["spec"].get("annotations", {}).get("readOnlyHint", True))
step_ref = await asyncio.to_thread(
ai_tasks.begin_step, client, name, arguments, started, read_only, session=session, task_id=task_id,
)
active_task = step_ref[0] if step_ref else task_id
if active_task:
ctx["task"] = active_task
outside = None
if name not in mcp_apps.APP_TOOLS: # the card's own calls do not reach the AI
outside = await asyncio.to_thread(ai_outside.check, active_task or ai_tasks.active_task_id(client, session))
canvas_call = ai_task_canvas.begin_call(active_task) # the page works on the task's own tab
# A private task's tokens become their texts for the tool only; the record keeps the tokens (ai_privacy.py).
privacy_task = active_task or ai_tasks.active_task_id(client, session=session)
private = await asyncio.to_thread(ai_privacy.applies, privacy_task)
call_arguments = await asyncio.to_thread(ai_sealed.expand, privacy_task, arguments) # tokens from a prompt box work in any task
try:
# The user's gate first (ai_gates.py): it may wait for their OK, or answer for them.
result = await asyncio.to_thread(ai_gates.check, name, arguments, step_ref)
if result is None:
async with _slots:
result = await asyncio.to_thread(call_tool, TOOLS, name, call_arguments, ctx)
except BaseException as error:
failed_result = {"content": [{"type": "text", "text": f"{name} failed: {error}"}], "isError": True}
await asyncio.to_thread(
ai_tasks.finish_step, client, name, arguments, failed_result, started, read_only,
step_ref=step_ref, session=session, task_id=active_task,
)
raise
if renamed and not result.get("isError") and isinstance(result.get("structuredContent"), dict):
retext(result, {**result["structuredContent"], "arguments_note": renaming_note(name, renamed)})
step = await asyncio.to_thread(
ai_tasks.finish_step, client, name, arguments, result, started, read_only,
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":
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"])
if name not in mcp_apps.APP_TOOLS:
mcp_fit.fit(result, name) # read in the chat, not filed away unread; the user's news below stays whole
# The user's news and the run's go to the AI, never to the card's own calls (they do not reach the AI).
if name not in mcp_apps.APP_TOOLS and isinstance(result, dict) and not result.get("isError"):
resolved_task = active_task or await asyncio.to_thread(ai_tasks.active_task_id, client, session=session)
if name not in ("run_workflow", "run_status", "stop_run") and resolved_task:
await asyncio.to_thread(ai_task_runs.tell, result, resolved_task)
await asyncio.to_thread(ai_task_runs.show_pictures, result, resolved_task)
if name != "get_answers" and name not in mcp_apps.APP_TOOLS and isinstance(result, dict) and not result.get("isError"):
await asyncio.to_thread(ai_panels.attach_pending, result, client, task_id=resolved_task or ("" if session else None))
mcp_directory.attach(result, name, {key: tool["spec"] for key, tool in TOOLS.items() if key not in mcp_apps.APP_TOOLS})
if name not in mcp_apps.APP_TOOLS and mcp_directory.stale(client, _tools()):
await asyncio.to_thread(mcp_directory.tell_stale, result, active_task)
# Last: a private task's pictures and prompts never leave, whatever put them in (ai_privacy.py).
final_task = active_task or ai_tasks.active_task_id(client, session=session)
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)
def _node_list(ctx):
"""ComfyUI's node list, for telling which node inputs are text a private task keeps; {} when unreadable."""
try:
return object_info(ctx)
except ToolError:
return {}
async def api_mcp(request):
"""POST /anomalous/mcp - one JSON-RPC request or notification per POST."""
if request.method != "POST":
return web.Response(status=405, headers={"Allow": "POST"})
if not _is_local(request) or not _origin_ok(request):
return _error(None, INVALID_REQUEST, "Only this computer may use the Anomalous MCP endpoint.", status=403)
try:
body = await request.json()
except (ValueError, UnicodeDecodeError):
return _error(None, PARSE_ERROR, "Body is not JSON", status=400)
if not isinstance(body, dict) or body.get("jsonrpc") != "2.0" or not isinstance(body.get("method"), str):
return _error(None, INVALID_REQUEST, "Expected one JSON-RPC 2.0 request (batches are not supported)", status=400)
if "id" not in body:
return web.Response(status=202) # notifications (initialized, cancelled) need no answer
id_, method = body["id"], body["method"]
params = body.get("params") if isinstance(body.get("params"), dict) else {}
if method == "initialize":
return _result(id_, _initialize(params), modern=False)
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
version = meta.get("io.modelcontextprotocol/protocolVersion")
modern = version is not None
if modern:
if version not in MODERN_VERSIONS:
return _error(id_, UNSUPPORTED_VERSION, "Unsupported protocol version", status=400,
data={"supported": list(MODERN_VERSIONS + LEGACY_VERSIONS), "requested": version})
problem = _modern_header_problem(request, method, params, version)
if problem:
return _error(id_, HEADER_MISMATCH, f"Header mismatch: {problem}", status=400)
else:
header = request.headers.get("MCP-Protocol-Version")
if header is not None and header not in LEGACY_VERSIONS:
return _error(id_, INVALID_REQUEST, f"Unsupported protocol version {header}", status=400,
data={"supported": list(MODERN_VERSIONS + LEGACY_VERSIONS), "requested": header})
if method == "server/discover" and modern:
return _result(id_, _discover(), modern)
if method == "ping":
return _result(id_, {}, modern)
if method == "tools/list":
listing = _tool_list(modern)
await asyncio.to_thread(mcp_directory.listed, ai_callers.client_of(request.headers, params), listing["tools"])
return _result(id_, listing, modern)
if method == "prompts/list":
return _result(id_, mcp_prompts.list_prompts(), modern)
if method == "prompts/get":
prompt = mcp_prompts.get_prompt(params.get("name"), params.get("arguments"))
if prompt is None:
return _error(id_, INVALID_PARAMS, f"Unknown prompt or missing arguments: {params.get('name')}")
return _result(id_, prompt, modern)
if method == "resources/list":
return _result(id_, {"resources": mcp_apps.resources()}, modern)
if method == "resources/read":
resource = await asyncio.to_thread(mcp_apps.read, params.get("uri"))
if resource is None:
return _error(id_, INVALID_PARAMS, f"Unknown resource: {params.get('uri')}")
return _result(id_, resource, modern)
if method == "tools/call":
result = await _call(params, request)
if result is None:
return _error(id_, INVALID_PARAMS, f"Unknown tool or bad arguments: {params.get('name')}")
return _result(id_, result, modern)
return _error(id_, METHOD_NOT_FOUND, f"Method not found: {method}", status=404 if modern else 200)
async def api_mcp_bridge(request):
"""POST /anomalous/mcp/bridge - the open page claims a canvas action ({id, claim: true} ->
{granted}) and then answers it ({id, outcome: {ok, value} | {ok: false, error}})."""
if not _is_local(request) or not _origin_ok(request):
return web.json_response({"error": "This computer only"}, status=403)
try:
body = await request.json()
except (ValueError, UnicodeDecodeError):
body = None
if not isinstance(body, dict) or not isinstance(body.get("id"), str):
return web.json_response({"error": "Expected {id, claim} or {id, outcome}"}, status=400)
if body.get("claim") is True:
page = body.get("page") if isinstance(body.get("page"), str) and len(body["page"]) <= 64 else None
granted = mcp_bridge.claim(body["id"], body.get("capabilities"), page)
if granted == "later": # another page did the last action: ask again when it has had its turn
return web.json_response({"granted": False, "retry_ms": int(mcp_bridge.HOLD_SECONDS * 1000) + 300})
return web.json_response({"granted": granted})
return web.json_response({"accepted": mcp_bridge.resolve(body["id"], body.get("outcome"))})
def register_routes(app):
app.router.add_route("*", "/anomalous/mcp", api_mcp)
app.router.add_post("/anomalous/mcp/bridge", api_mcp_bridge)
+104
View File
@@ -0,0 +1,104 @@
"""get_situation: where an AI's task stands, in one answer small enough to stay in its chat.
The task (what the user asked last, questions and gates still waiting for them, decisions so
far, pictures made, steps done outside Anomalous), the canvas in brief (web/modules/
mcp_canvas_brief.js: flow chains, brief node lines, missing inputs and outputs that fit,
ComfyUI's errors), the latest run, and the one thing to do next. For an AI starting a new chat
on a task or unsure where it is: a small model reads this instead of describe_canvas, find_nodes
and the task's history. Unread messages of the user's come with it as with every answer
(ai_panels.attach_pending).
"""
from . import ai_decisions, ai_task_runs, ai_tasks, mcp_routes
from .mcp_bridge import PageRefused, PageUnavailable, ask_page
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.")
MAX_TEXT = 200
def _last_asked(task):
"""The user's latest words or marks in the task, in short."""
for step in reversed(task.get("steps") or []):
if step.get("kind") == "user" and step.get("tool") in ("user_message", "user_marks", "user_pictures"):
return {"what": step["tool"].removeprefix("user_"), "text": str(step.get("text") or "")[:MAX_TEXT], "id": step["id"]}
return None
def _canvas():
try:
return ask_page("describe_canvas", {"brief": True}, timeout=15)
except PageUnavailable as error:
return {"unavailable": str(error)}
except PageRefused as error: # no workflow open, or the task's tab cannot be opened
return {"unavailable": str(error)}
def _next(summary, run, canvas, record=None):
if summary["asks"]:
return f"{summary['asks']} question(s) or gate(s) wait for the user: get_answers waits for their answer."
if run.get("state") in ("queued", "running", "asking", "allowed"):
return "A run is going: run_status(wait_seconds=55) waits for it; do not run again."
if any("not in list" in str(item.get("error")) for item in canvas.get("errors") or ()):
return ("A model or file the workflow names is not on this computer: check_workflow_models says which and what "
"can stand in (fix_workflow_models, download_missing_models); do not guess other choices.")
if canvas.get("errors"):
return "ComfyUI shows errors on the canvas: fix the nodes in canvas.errors (set_values, connect_nodes, run_workflow image=), then run_workflow."
if canvas.get("unwired"):
return "Wire canvas.unwired with connect_nodes (fits lists outputs that fit each), then run_workflow."
if run.get("state") == "done" and run.get("pictures"):
return "Look at the run's pictures (zoom_image) and tell the user with report what came out."
if record and mcp_routes.waiting(record):
return mcp_routes.TOOLBOX_NOTE
if canvas.get("node_count") == 0:
return EMPTY_CANVAS
return "Go on with what the user asked (task.asked); run_workflow runs the canvas, report tells the user."
def get_situation(ctx, task=None):
client, session = ctx.get("client") or "AI", ctx.get("session") or ""
task_id = ai_tasks.active_task_id(client, session, task or ctx.get("task"))
record = ai_tasks.get_task(task_id) if task_id else None
run = {"state": "none"}
answer = {}
if record:
summary = ai_tasks.task_summary(record)
pictures = [item["filename"] for step in record.get("steps") or [] for item in step.get("results") or [] if item.get("type") == "image"]
answer["task"] = {"id": record["id"], "title": record.get("title") or "", "steps": summary["steps"],
"asked": _last_asked(record), "waiting_for_user": summary["asks"],
"decisions": ai_decisions.lines(record, limit=4), "pictures": pictures[-3:],
"outside_anomalous": summary["kinds"].get("outside", 0)}
status = ai_task_runs.status(client, session, task_id)
run = {key: status[key] for key in ("state", "summary", "pictures") if status.get(key)}
else:
summary = {"asks": 0}
answer["task"] = None
answer["task_note"] = "No task yet: begin_task names what the user asked for."
canvas = _canvas()
answer.update(run=run, canvas=canvas)
answer["next"] = _next(summary, run, canvas, record)
# Trim detail before chains and decisions; preserve actionable missing inputs and errors.
while len(tool_text(answer)) > BUDGET and canvas.get("lines"):
canvas["lines"].pop()
canvas["more_nodes"] = canvas.get("more_nodes", 0) + 1
while len(tool_text(answer)) > BUDGET and canvas.get("chains"):
canvas["chains"].pop()
canvas["more_chains"] = canvas.get("more_chains", 0) + 1
while len(tool_text(answer)) > BUDGET and record and answer["task"]["decisions"]:
answer["task"]["decisions"].pop(0)
return answer
SITUATION_TOOLS = {
"get_situation": {"run": get_situation, "context": True, "spec": {
"name": "get_situation", "title": "Where things stand",
"description": "The whole picture in one call: the task (what the user asked last, what waits for them, decisions, "
"pictures), the canvas in brief (flow chains, inputs nothing feeds and what fits them, errors), the "
"latest run, and next: the one thing to do now. Call it first in a new chat, and whenever unsure.",
"inputSchema": _schema({"task": {"type": "string", "description": "begin_task's id (optional)."}}),
"annotations": {"title": "Where things stand", "readOnlyHint": True, "destructiveHint": False,
"idempotentHint": True, "openWorldHint": False},
}},
}
+495
View File
@@ -0,0 +1,495 @@
"""MCP tools that shape the AI workbench's record (ai_tasks.py): begin_task names a piece of
work, report leaves a note for the user (with pictures made outside Anomalous's tools),
show_panel / update_panel draw a panel from Anomalous's blocks (ai_panels.py), get_answers
reads what the user picked or typed on it, log_outside keeps what the AI did with other tools
(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_tools import MAX_LIMIT, ToolError, _schema
PACK_TYPES = 150 # node types listed per picked pack
ASK_WAIT_MIN = 30 # ask_user waits this long even when told not to: the user, reading the work, often answers at once
ASK_NEXT = ("Before you end your turn, ask_user what to do next or whether to go ahead: a note or a panel alone leaves "
"the user waiting. Only a run, a download or a scan going on lets you end it without asking.")
def begin_task(ctx, title, new_task=False):
title = str(title or "").strip()
if not title:
raise ToolError("title is empty: a few words on what the user asked for.")
task = ai_tasks.begin_task(
ctx.get("client") or "AI", title, new_task=bool(new_task),
session=ctx.get("session") or "", task_id=ctx.get("task"),
)
answer = {"task": task["id"], "title": task["title"], "note": (
"Continue this task; do not begin another for its steps. Every AMB call is a step the user sees, opens and "
"undoes in Anomalous's AI workbench. Ask them with ask_user (they answer there, not in your chat); report "
"explains a choice or hands in pictures, done=true at the end. Run with run_workflow. In the task's own tab, "
"do not switch tabs yourself. "
"Everything is in AMB's tools (models, pictures, node types, the canvas, runs and their previews): do not "
"read ComfyUI's folders or source, run preprocessors or call its routes yourself. Change pictures only by "
"running workflows, never by editing files with code. If you use another tool anyway, log_outside right "
"after. The directory below lists the tools by what you want to do.")}
answer["next"] = ("get_situation shows where things stand; a daily run is one call: run_workflow(workflow: a saved "
"one, with the prompt, seed or size to change).")
answer["blocks"] = _blocks(ctx)
if ai_privacy.is_private(ai_tasks.get_task(task["id"])):
answer["privacy"] = ai_privacy.NOTE # the user writes the prompts and looks at the pictures: no skill for those
elif ai_skills.directory():
answer["skills"] = ai_skills.directory()
box = ai_toolbox.effective(ai_tasks.get_task(task["id"]))
if box:
answer["toolbox"] = ai_toolbox.words(box)
if _routes(ctx, task["id"]):
answer["next"] = mcp_routes.TOOLBOX_NOTE
goes_on = ai_task_links.carried(task)
if goes_on:
answer["continues"] = goes_on # the task this one goes on from, and what was decided there
decided = ai_decisions.lines(ai_tasks.get_task(task["id"]))
if decided:
answer["decisions"] = decided # what was settled so far, the user's own choices in Anomalous too
if task.get("about"):
# Handed over from a model page, an image or a combo in Anomalous: the ids the tools take.
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.")
return answer
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.",
**reference_blocks.directory(info)}
def report(ctx, text="", images=None, done=False, reply_to=None, task=None):
try:
step, missing = ai_tasks.report(
ctx.get("client") or "AI", text, images or [], bool(done),
session=ctx.get("session") or "", task_id=task or ctx.get("task"), reply_to=reply_to,
)
except ValueError as error:
raise ToolError(str(error))
answer = {"noted": True, "pictures": len(step["results"])}
if not done:
answer["next"] = ASK_NEXT
if any(mark in str(text or "") for mark in ("?", "?")):
answer["next"] = "Your note asks the user something: ask it with ask_user (options to press) instead. " + ASK_NEXT
if missing:
answer["not_found"] = missing
answer["note"] = "Pictures are paths inside ComfyUI's output folder (subfolder/name.png), as search_images gives them."
return answer
def log_outside(ctx, what, why="", files=None, task=None):
try:
step = ai_outside.log(ctx.get("client") or "AI", what, why, files, session=ctx.get("session") or "",
task_id=task or ctx.get("task"))
except ValueError as error:
raise ToolError(str(error))
return {"logged": True, "step": step["id"]}
def log_decision(ctx, title, decision, reason="", rejected=None, by="user", related=None, task=None):
try:
step = ai_decisions.log(ctx.get("client") or "AI", title, decision, reason, rejected, by, related,
session=ctx.get("session") or "", task_id=task or ctx.get("task"))
except ValueError as error:
raise ToolError(str(error))
return {"recorded": True, "step": step["id"], "related": step["decision"]["related"]}
def show_panel(ctx, title, blocks, task=None):
# A prompt box tells the user what suits the canvas's model (the prompt_pose skill's words for them).
boxed = isinstance(blocks, list) and any(isinstance(block, dict) and block.get("type") == "prompts" for block in blocks)
advice = ai_skills.for_user(_canvas_model()) if boxed else None
try:
panel = ai_panels.show(
ctx.get("client") or "AI", title, blocks,
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))
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"]]}
if asks:
answer["questions"] = asks
answer["note"] = ("The user answers in Anomalous when they get to it; call get_answers with this panel id "
"(wait_seconds up to 55 waits for it) before going on with what depends on it.")
else:
answer["next"] = ASK_NEXT
return answer
def _routes(ctx, task):
"""What the user can do with the two models they picked (mcp_routes.py), or None."""
from .mcp_canvas_edit import object_info
task_id = task or ctx.get("task") or ai_tasks.active_task_id(ctx.get("client") or "AI", ctx.get("session") or "")
box = ai_toolbox.effective(ai_tasks.get_task(task_id)) if task_id else None
return mcp_routes.offer(box, object_info(ctx)) if len(mcp_routes._main(box)) >= 2 else None
TRIAL_QUESTIONS = {"test", "testing", "test question", "hello", "hi", "ping", "测试", "试试", "试一下"}
def ask_user(ctx, question="", options=None, multiple=False, other=True, wait_seconds=ai_panels.MAX_WAIT, task=None, routes=False):
"""A question in the AI workbench, where the user watches the work: options to press (one may be
recommended), a line of their own; waits for the answer. `routes`: Anomalous's own question and options."""
extra, words = None, {}
if routes:
offered = _routes(ctx, task)
if not offered:
raise ToolError("No routes to ask about: they need two main models of one family picked for the task. Go on with what the user asked.")
question, options, multiple, other = mcp_routes.QUESTION, offered["options"], False, True
extra, words = {"routes": offered["calls"]}, {"title_key": "aiRouteTitle"}
if not str(question or "").strip():
raise ToolError("question is empty.")
if str(question).strip().strip("??!!.。 ").lower() in TRIAL_QUESTIONS: # with made-up options too ("方案1", "方案2")
raise ToolError("Not shown to the user: a trial question would wait for an answer nobody gives. Ask when you need "
"their answer: question (needed), options (the choices to press), multiple, other, wait_seconds.")
block = ({"type": "choices", "id": "answer", "options": options, "multiple": bool(multiple), "other": bool(other)}
if options else {"type": "input", "id": "answer", "question": str(question or "")[:200]})
try:
panel = ai_panels.show(ctx.get("client") or "AI", question, [block], session=ctx.get("session") or "",
task_id=task or ctx.get("task"), tool="ask_user", extra=extra, **words)
except ai_panels.PanelError as error:
raise ToolError(str(error))
return _answer_of(ctx, panel["id"], wait_seconds)
def propose_repaint_area(ctx, title, marks, areas=None, grow=12, feather=8, note="", wait_seconds=ai_panels.MAX_WAIT):
"""The area to repaint for the user's marks, theirs beside the AI's for them to pick (ai_repaint.py); waits like ask_user."""
try:
panel = ai_repaint.propose(ctx.get("client") or "AI", title, str(marks or ""), areas, grow, feather,
str(note or "")[:400], session=ctx.get("session") or "")
except ai_repaint.RepaintError as error:
raise ToolError(str(error))
return _answer_of(ctx, panel["id"], wait_seconds)
def _answer_of(ctx, panel_id, wait_seconds):
"""The answer to the question of panel `panel_id` once the user gives it, waiting at least ASK_WAIT_MIN."""
client = ctx.get("client") or "AI"
try:
answer = ai_answer_wait.collect(client, panel_id, max(ASK_WAIT_MIN, float(wait_seconds or 0)))
given = next((item for item in answer["answers"] if item["question"] == "answer"), None)
if not given:
if answer.get("messages") and answer.get("still_waiting"):
# Read the user's new instruction before considering an automatic choice.
answer.update(ai_answer_wait.wait_on(panel_id, allow_recommended=False))
return {"answered": False, "panel": panel_id, **answer}
except ai_panels.PanelError as error:
raise ToolError(str(error))
out = {"answered": True, "panel": panel_id, "next": "Go on with the user's answer; log_decision it when it settles a choice."}
if answer.get("messages"):
out["messages"] = answer["messages"]
if given.get("inpaint_image"):
out.update(inpaint_image=given["inpaint_image"], inpaint_note=given["note"])
elif given.get("note"):
out["next"] = given["note"]
if given.get("skipped"):
out.update(skipped=True, note="The user left this to you: decide it yourself, log_decision what you chose and "
"go on. Do not ask the same again.")
elif given.get("text"):
out["text"] = given["text"]
if given.get("choice") == ["other"]:
out["note"] = "None of your options: the user wrote their own answer."
else:
out.update(choice=given.get("choice"), labels=given.get("labels"))
if given.get("recommended_on_timeout"):
out.update(recommended_on_timeout=True, next=ai_answer_wait.TOOK)
return out
def update_panel(ctx, panel, title=None, blocks=None, closed=None, task=None):
del ctx, task
try:
updated = ai_panels.update(panel, title, blocks, closed)
except ai_panels.PanelError as error:
raise ToolError(str(error))
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):
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:
raise ToolError(str(error))
def show_run_map(ctx, title, run=None, task=None):
client, session = ctx.get("client") or "AI", ctx.get("session") or ""
task_id = ai_tasks.active_task_id(client, session, task or ctx.get("task"))
runs = [step for step in (ai_tasks.get_task(task_id) or {}).get("steps", []) if step.get("tool") == "run_workflow"] if task_id else []
found = next((step for step in reversed(runs) if step.get("map") and (not run or step["id"] == run)), None)
if not found:
raise ToolError("No run of this task has its shape yet: it is read once ComfyUI has queued the run (run_workflow).")
try:
panel = ai_panels.show(client, title, [{"type": "run_map", "step": found["id"]}], session=session, task_id=task_id,
tool="show_run_map")
except ai_panels.PanelError as error:
raise ToolError(str(error))
boxes = found["map"]["boxes"]
return {"panel": panel["id"], "shown": True, "run": found["id"], "changes": found.get("changes") or [],
"boxes": [{"node_id": box["id"], "title": box["title"], "role": box["role"]} for box in boxes]}
def stop_run(ctx, task=None):
client, session = ctx.get("client") or "AI", ctx.get("session") or ""
task_id = ai_tasks.active_task_id(client, session, task or ctx.get("task"))
run = next((step for step in reversed((ai_tasks.get_task(task_id) or {}).get("steps", []))
if step.get("tool") == "run_workflow"), None) if task_id else None
if not run:
raise ToolError("No run in this task yet.")
try:
done = ai_run_control.stop(run["id"], by="ai", task_id=task_id)
except ai_run_control.RunControlError as error:
raise ToolError(str(error))
return {**done, "stopped": True, "note": "Stopping: run_status(wait_seconds=10) confirms it ended."}
def get_toolbox(ctx, task=None, skill=None):
from .mcp_canvas_edit import find_nodes
task_id = task or ctx.get("task") or ai_tasks.active_task_id(ctx.get("client") or "AI", ctx.get("session") or "")
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)
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"))
return answer
box = ai_toolbox.effective(ai_tasks.get_task(task_id)) if task_id else None
if not box:
return {"picked": False, "note": "The user picked nothing for this task; use what fits."}
answer = {"picked": True, "mode": box["mode"], "note": ai_toolbox.words(box)}
if box.get("nodes"):
found = find_nodes(ctx, types=box["nodes"])
answer["nodes"] = found["nodes"]
if found.get("not_found"):
answer["page_only_nodes"] = found["not_found"] # helpers that live only in the page: add_node takes them
if box.get("packs"):
from .mcp_canvas_edit import object_info
info = object_info(ctx)
answer["packs"] = []
for pack in box["packs"]:
types = sorted(name for name, item in info.items() if ai_toolbox.source_of(item) == pack)
answer["packs"].append({"pack": pack, "count": len(types), "node_types": [
{"type": name, "display_name": info[name].get("display_name") or name, "category": info[name].get("category") or ""}
for name in types[:PACK_TYPES]]})
answer["packs_note"] = "Every node type of a pack is the user's pick; find_nodes(types=[...]) gives one in full."
for kind in ("combos", "workflows", "prompts"):
if box.get(kind):
answer[kind] = [found for name in box[kind] if (found := ai_message_refs.lookup(kind, name)) is not None]
if box.get("ways"):
answer["ways"] = [reference_blocks.call(name) or name for name in box["ways"]]
if box.get("models"):
answer["models"] = [ai_message_refs.lookup("models", model_id) or {"model_id": model_id, "missing": True} for model_id in box["models"]]
for model in answer["models"]:
if not model.get("missing") and model["model_id"].split(":", 1)[0] != "loras":
model["in_cards"] = mcp_routes.cards_of(model["model_id"]) # its examples: how the user's own routes use it
if _routes(ctx, task_id):
answer["routes"] = mcp_routes.TOOLBOX_NOTE
return answer
def _canvas_model():
"""The main model the canvas's sampler uses {name, params}, for advice that depends on its size; None when
the page cannot tell (no page, several samplers, no loader)."""
from .mcp_actions import _page
from .model_advice import main_params
try:
name = (_page("block_model", {"near_node_id": None}) or {}).get("model") or ""
except ToolError:
return None
return {"name": name, "params": main_params(name)} if name else None
def get_answers(ctx, panel=None, wait_seconds=0, task=None):
try:
client = ctx.get("client") or "AI"
task_id = task or ctx.get("task") or None
if not task_id and ctx.get("session"):
task_id = ai_tasks.active_task_id(client, ctx["session"]) or ""
answer = ai_answer_wait.collect(client, panel, wait_seconds, task_id=task_id)
return answer, ai_marks.image_items(answer.get("messages") or []) # marked pictures, as pictures
except ai_panels.PanelError as error:
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)."}
def _task_tool(name, title, description, run, properties, required=()):
return name, {"run": run, "context": True, "spec": {
"name": name, "title": title, "description": description,
"inputSchema": _schema(properties, required),
"annotations": {"title": title, "readOnlyHint": name in ("run_status", "get_toolbox"), "destructiveHint": False,
"idempotentHint": False, "openWorldHint": False},
}}
def _question_with_images(run):
"""The question receipt also carries pictures the user sent while it was waiting."""
def call(ctx, **arguments):
result = run(ctx, **arguments)
return result, ai_marks.image_items(result.get("messages") or [])
return call
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).",
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",
"Call first: names what the user asked for in Anomalous's AI workbench, where they see, open and undo "
"each of your steps. Continues the waiting or active task; new_task=true only for a separate request.",
begin_task, {"title": {"type": "string", "description": "A few words, in the user's language."},
"new_task": {"type": "boolean", "default": False}},
["title"]),
_task_tool("report", "Tell the user",
"A note in the AI workbench, where the user reads your work: your choices, what to look at, what is "
"left, your answers. Markdown headings, lists, bold and code are laid out. reply_to: the id of the "
"user's message you answer. images: output pictures (subfolder/name.png) to keep with the task. "
"done=true ends it.",
report, {"text": {"type": "string"},
"images": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_LIMIT},
"done": {"type": "boolean", "default": False},
"reply_to": {"type": "string", "description": "The message id you answer."},
"task": _TASK_PROP}),
_task_tool("log_outside", "Say what you did outside Anomalous",
"Right after using a tool that is not Anomalous's (a command, a script, files, another server): what "
"you ran, why, and the files it touched, for the task's timeline. Prefer Anomalous's tools.",
log_outside, {"what": {"type": "string"}, "why": {"type": "string"},
"files": {"type": "array", "items": {"type": "string"}, "maxItems": ai_outside.MAX_FILES},
"task": _TASK_PROP},
["what"]),
_task_tool("log_decision", "Record a decision",
"Records a settled decision of the task (a style, a model, a fix, what to drop) where the user looks "
"back on it: title, decision, reason, rejected options, by (user, ai or together), related step ids. "
"The user's own choices in Anomalous are kept already.",
log_decision, {"title": {"type": "string"}, "decision": {"type": "string"}, "reason": {"type": "string"},
"rejected": {"type": "array", "items": {"type": "string"}, "maxItems": ai_decisions.MAX_REJECTED},
"by": {"type": "string", "enum": list(ai_decisions.BY), "default": "user"},
"related": {"type": "array", "items": {"type": "string"}, "maxItems": ai_decisions.MAX_RELATED},
"task": _TASK_PROP},
["title", "decision"]),
_task_tool("show_panel", "Show the user a panel",
"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,
show_panel, {"title": {"type": "string"},
"blocks": {"type": "array", "items": {"type": "object"}, "maxItems": ai_panels.MAX_BLOCKS},
"task": _TASK_PROP},
["title", "blocks"]),
_task_tool("ask_user", "Ask the user",
"Asks the user in the AI workbench and waits: a question with 2-8 options ({label, detail, "
"recommended}; mark your suggestion recommended=true), and a line for their own answer unless "
"other=false; no options: a free answer. routes=true: Anomalous's own question on the two models picked. "
"Use it for every choice that is the user's, not your chat's "
f"question form. Waits at least {ASK_WAIT_MIN} s, up to wait_seconds; unanswered and keep_waiting: wait on "
"with get_answers. End every round of work with it.",
_question_with_images(ask_user), {"question": {"type": "string"},
"options": {"type": "array", "maxItems": 8, "items": {"type": "object", "properties": {
"label": {"type": "string"}, "detail": {"type": "string"}, "recommended": {"type": "boolean"}}}},
"multiple": {"type": "boolean", "default": False}, "other": {"type": "boolean", "default": True},
"wait_seconds": {"type": "number", "minimum": 0, "maximum": ai_panels.MAX_WAIT, "default": ai_panels.MAX_WAIT},
"routes": {"type": "boolean"}, "task": _TASK_PROP}),
_task_tool("propose_repaint_area", "Agree on the area to repaint",
"Before repainting only the marked areas: the user's marks are rough, so show them the area you would "
"repaint. marks: the marks' message id. areas: boxes or ellipses {shape, x, y, w, h} as fractions of the "
"picture (x, y its top left), or none to keep the marks; grow: pixels added around; feather: pixels of "
"soft edge. The user sees theirs and yours side by side and picks one, or marks again; with an area "
"picked the answer has inpaint_image (the picture with that area as its alpha, in ComfyUI's input "
"folder) and inpaint_note: add_nodes(use=\"repaint\", image=inpaint_image) builds the repaint. "
"Waits like ask_user.",
_question_with_images(propose_repaint_area), {"title": {"type": "string", "description": "A few words, in the user's language."},
"marks": {"type": "string"},
"areas": {"type": "array", "maxItems": ai_repaint.MAX_AREAS, "items": {"type": "object", "properties": {
"shape": {"type": "string", "enum": list(ai_repaint.SHAPES)}, "x": {"type": "number"},
"y": {"type": "number"}, "w": {"type": "number"}, "h": {"type": "number"}}}},
"grow": {"type": "integer", "minimum": 0, "maximum": ai_repaint.MAX_GROW, "default": 12},
"feather": {"type": "integer", "minimum": 0, "maximum": ai_repaint.MAX_FEATHER, "default": 8},
"note": {"type": "string", "description": "Why you moved it, in a line."},
"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.",
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"]),
_task_tool("run_status", "How the run is going",
"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.",
run_status, {"wait_seconds": {"type": "number", "minimum": 0, "maximum": ai_task_runs.MAX_STATUS_WAIT, "default": 0},
"task": _TASK_PROP}),
_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, "
"each one locates its node on the canvas, with what changed since the run before. Anomalous reads it "
"from the prompt ComfyUI queued, so use this instead of drawing the workflow yourself; add your "
"explanation with report. title: a few words in the user's language.",
show_run_map, {"title": {"type": "string"}, "run": {"type": "string"}, "task": _TASK_PROP}, ["title"]),
_task_tool("stop_run", "Stop the run",
"Stops your task's run: its queued prompts leave the queue, the running one is interrupted (never "
"anyone else's). When run_status warns it is far too big and the user agrees, or when the user asks.",
stop_run, {"task": _TASK_PROP}),
_task_tool("get_toolbox", "What the user picked for you",
"The node types, node packs, combos, workflows, prompts and models the user picked for this task, in "
"full, and how strictly: prefer (use them first) or only (add_node and new_workflow accept only those "
"node types, the packs' and ComfyUI's built-in ones). begin_task says when there are any. "
"skill: a skill begin_task lists, instead.",
get_toolbox, {"task": _TASK_PROP, "skill": {"type": "string"}}),
_task_tool("get_answers", "Read the user's answers and messages",
"What the user picked or typed on your panels and wrote to you in the AI workbench since you last "
"asked, each once (kind=change: something they changed themselves; keep it). panel: one panel. "
"wait_seconds (up to 55) waits when nothing is there. still_waiting: questions not answered, and "
"keep_waiting=true: call it again; skipped=true: they left it to you, decide yourself.",
get_answers, {"panel": {"type": "string"},
"wait_seconds": {"type": "number", "minimum": 0, "maximum": ai_panels.MAX_WAIT, "default": 0},
"task": _TASK_PROP}),
])
+578
View File
@@ -0,0 +1,578 @@
"""The MCP endpoint's (mcp_server.py) reading tools: views of the library (mcp_actions.py acts).
`call_tool` runs any tool of a registry, these and mcp_actions.py's. search_models and get_model
are offered through mcp_model_use.py, which adds what loads each model.
Each tool is a plain function over the same stores the browser's pages use (model catalog
and sidecars, output gallery, combos, workflow recipes, saved prompts, generated audio, scan
results, activity log). They never write. Results are small, already-summarised JSON (an AI
reads them), with ids the detail tools take back: model_id "type:path_idx:relative/path" and
image "subfolder/file.png" under ComfyUI's output folder. A tool's mistake (bad id, missing
file) comes back as a tool error the AI can read and correct, never as an exception.
"""
import base64
import datetime
import html
import json
import logging
import os
import re
import folder_paths
from .activity_log import list_entries
from .audio_catalog import _gallery_page as _audio_page
from .gallery_routes import _gallery_listing
from .image_search import read_png_text
from .kept_images import _kept_images
from .material_store import _list_materials, _read_material, get_materials_dir
from .media_routes import _build_card_thumbnail
from .metadata import get_metadata
from .picture_mask import input_facts
from .mcp_arguments import prepare_arguments, renaming_note
from .model_catalog import _iter_search_models
from .model_constants import MEDIA_EXTENSIONS, MODEL_EXTENSIONS, PREVIEW_SUFFIXES
from .notebooks import _list_notebooks
from .parameters import _read_parameter_notebooks, get_parameters_dir
from .path_utils import model_file_within, resolve_folder_subdir, resolve_within
from .recipe_store import _list_recipes, get_recipes_dir
from .scan_report import last_scan_path
from .scan_summary import _summarize
from . import example_workflows, workflow_route
MAX_LIMIT = 50
MAX_SEARCH_PICTURES = 6 # search_images(with_pictures): the first few attached
MAX_TEXT = 1500
MAX_PLANS = 12 # 方案 listed for one workflow
MAX_IMAGE_BYTES = 1_500_000
IMAGE_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
SOURCES = {"civitai": "civitai", "local": "inferred_from_file", "": "not_scanned"}
class ToolError(Exception):
"""A problem the AI can fix (bad id, nothing there); shown to it as the tool's result."""
# ---------------------------------------------------------------- helpers
def _text(value, limit=MAX_TEXT):
"""Plain text from HTML-ish Civitai descriptions, shortened."""
value = html.unescape(re.sub(r"<[^>]+>", " ", str(value or "")))
value = re.sub(r"\s+", " ", value).strip()
return value if len(value) <= limit else value[:limit] + "…"
def _when(seconds):
try:
return datetime.datetime.fromtimestamp(float(seconds)).isoformat(timespec="seconds")
except (TypeError, ValueError, OverflowError, OSError):
return None
def _limit(value, default=20):
try:
return max(1, min(MAX_LIMIT, int(value)))
except (TypeError, ValueError):
return default
def _terms(query):
return [term for term in str(query or "").lower().split() if term]
def _matches(terms, *texts):
haystack = " ".join(str(text or "") for text in texts).lower()
return all(term in haystack for term in terms)
def _model_id(folder_type, path_idx, base_dir, file_path):
return f"{folder_type}:{path_idx}:{os.path.relpath(file_path, base_dir).replace(os.sep, '/')}"
def _resolve_model(model_id):
"""(type, path_idx, base_dir, file_path) for a model_id from search_models."""
parts = str(model_id or "").split(":", 2)
if len(parts) != 3:
raise ToolError("model_id looks like 'loras:0:folder/file.safetensors'; take it from search_models.")
folder_type, path_idx, rel = parts
rel = rel.replace("\\", "/").strip("/")
subfolder, filename = os.path.split(rel)
try:
path_idx = int(path_idx)
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, "/" + subfolder)
file_path = model_file_within(target_dir, filename)
except (ValueError, KeyError, TypeError):
raise ToolError(f"No models folder for {model_id}.")
if not filename.lower().endswith(MODEL_EXTENSIONS) or not os.path.isfile(file_path):
raise ToolError(f"No model file at {model_id}; it may have been moved or renamed.")
return folder_type, path_idx, base_dir, file_path
def _resolve_image(image):
"""The output image at `image` ('subfolder/file.png'), checked to stay inside the output folder."""
rel = str(image or "").replace("\\", "/").strip("/")
try:
path = resolve_within(folder_paths.get_output_directory(), rel)
except ValueError:
raise ToolError("image is a path under ComfyUI's output folder, as search_images returns it.")
if os.path.splitext(path)[1].lower() not in IMAGE_TYPES or not os.path.isfile(path):
raise ToolError(f"No output image at {image}: it is not (or no longer) in ComfyUI's output folder; "
"the user may have deleted it. search_images lists the pictures there.")
return rel, path
def _resolve_folder_image(image, folder):
"""(rel, path) of picture `image` ('subfolder/file.png') in ComfyUI's output, input (where
LoadImage reads) or temp folder (where PreviewImage writes), checked to stay inside it."""
if folder == "output":
return _resolve_image(image)
if folder not in ("input", "temp"):
raise ToolError("folder is output, input or temp.")
rel = str(image or "").replace("\\", "/").strip("/")
try:
path = resolve_within(getattr(folder_paths, f"get_{folder}_directory")(), rel)
except ValueError:
raise ToolError(f"image is a path under ComfyUI's {folder} folder.")
if os.path.splitext(path)[1].lower() not in IMAGE_TYPES or not os.path.isfile(path):
raise ToolError(f"No picture at {image} in ComfyUI's {folder} folder.")
return rel, path
def _image_content(path):
"""An MCP image item: the 512 px thumbnail the gallery uses, or None when too big or unreadable."""
thumb = _build_card_thumbnail(path)
if not thumb:
return None
try:
with open(thumb, "rb") as source:
data = source.read(MAX_IMAGE_BYTES + 1)
except OSError:
return None
if len(data) > MAX_IMAGE_BYTES:
return None
mime = IMAGE_TYPES.get(os.path.splitext(thumb)[1].lower(), "image/webp")
return {"type": "image", "data": base64.b64encode(data).decode("ascii"), "mimeType": mime}
def _kept_lookup():
return {(item["subfolder"], item["filename"]): {kind: (value or {}).get("name") for kind, value in item.items()
if kind in ("recipe", "combo", "prompt") and value}
for item in _kept_images()}
def _model_brief(folder_type, path_idx, base_dir, file_path, meta):
rel_dir = os.path.relpath(os.path.dirname(file_path), base_dir).replace(os.sep, "/")
try:
size_mb = round(os.path.getsize(file_path) / 1048576, 1)
except OSError:
size_mb = None
return {
"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),
"type": folder_type,
"folder": "" if rel_dir == "." else rel_dir,
"base_model": meta.get("baseModel") or "",
"source": SOURCES.get(meta.get("info_source", ""), "not_scanned"),
"trigger_words": list(meta.get("trainedWords") or [])[:8],
"size_mb": size_mb,
}
def _each_model():
"""Every model file once (folders registered twice, like unet and diffusion_models, count once).
ComfyUI also registers custom_nodes, whose node packs carry files of their own: not the library."""
seen = set()
for folder_type, path_idx, base_dir, file_path in _iter_search_models():
if folder_type == "custom_nodes" or not file_path.lower().endswith(MODEL_EXTENSIONS):
continue
real = os.path.normcase(os.path.realpath(file_path))
if real in seen:
continue
seen.add(real)
yield folder_type, path_idx, base_dir, file_path
# ---------------------------------------------------------------- models and scans
def search_models(query="", type="", base_model="", source="", limit=20):
terms, wanted_base, found = _terms(query), str(base_model or "").lower(), []
total = 0
type = str(type or "").lower()
for folder_type, path_idx, base_dir, file_path in _each_model():
if type and type not in (folder_type, folder_type.rstrip("s")): # "checkpoint" finds the checkpoints too
continue
meta = get_metadata(file_path)
if wanted_base and wanted_base not in str(meta.get("baseModel") or "").lower():
continue
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 [])):
continue
total += 1
if len(found) < _limit(limit):
found.append(_model_brief(folder_type, path_idx, base_dir, file_path, meta))
return {"total_matches": total, "models": found}
def get_model(model_id, include_cover=False):
folder_type, path_idx, base_dir, file_path = _resolve_model(model_id)
meta = get_metadata(file_path)
detail = _model_brief(folder_type, path_idx, base_dir, file_path, meta)
detail.update({
"trigger_words": list(meta.get("trainedWords") or []),
"civitai_name": meta.get("name") or "",
"version": meta.get("version_name") or "",
"civitai_url": meta.get("civitai_url") or "",
"download_link": meta.get("source_url") or "",
"user_notes": meta.get("custom_notes") or "",
"fields_set_by_user": list(meta.get("user_fields") or []),
"description": _text(meta.get("description")),
"sha256": meta.get("hash") or "",
})
if meta.get("info_source") == "local":
detail["why_not_on_civitai"] = meta.get("unmatched_reason") or "unknown"
extra = []
if include_cover:
base = os.path.splitext(file_path)[0]
cover = next((base + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS
if os.path.splitext(suffix)[1].lower() in IMAGE_TYPES and os.path.isfile(base + suffix)), None)
item = _image_content(cover) if cover else None
detail["cover"] = "attached" if item else "none"
extra = [item] if item else []
return detail, extra
def library_overview():
by_type, by_base = {}, {}
for folder_type, _idx, _base, file_path in _each_model():
by_type[folder_type] = by_type.get(folder_type, 0) + 1
base = get_metadata(file_path).get("baseModel") or "unknown"
by_base[base] = by_base.get(base, 0) + 1
summary = _summarize()
return {
"models_by_type": dict(sorted(by_type.items(), key=lambda item: -item[1])),
"models_by_base_model": dict(sorted(by_base.items(), key=lambda item: -item[1])[:20]),
"scan": {key: summary[key] for key in ("total", "matched", "unmatched", "new", "pending")},
"combos": len(_list_notebooks()),
"workflow_recipes": len(_list_recipes(get_recipes_dir())),
"saved_prompts": sum(1 for item in _list_materials(get_materials_dir()) if item.get("kind") == "prompt_plan"),
}
def scan_report():
summary = _summarize()
try:
with open(last_scan_path(), encoding="utf-8") as source:
last = json.load(source)
except (OSError, ValueError):
last = None
report = {
"counts": {key: summary[key] for key in ("total", "matched", "unmatched", "new", "pending")},
"meaning": "matched: found on Civitai; unmatched: base model inferred from the file (reason says why); "
"new: never scanned; pending: unmatched only because Civitai was not asked (offline or no connection).",
"unmatched_models": summary["unmatched_models"][:MAX_LIMIT],
"not_scanned_models": summary["new_models"][:MAX_LIMIT],
}
if isinstance(last, dict) and last.get("counts"):
report["last_scan"] = {"finished": _when(last.get("finished")), "kind": last.get("kind"),
"counts": last.get("counts"), "civitai_unreachable": bool(last.get("civitai_down")),
"models": (last.get("files") or [])[:30]}
return report
# ---------------------------------------------------------------- outputs
def search_images(query="", limit=20, page=1, with_pictures=False):
images = _gallery_listing(folder_paths.get_output_directory(), False, str(query or "").strip())
limit = _limit(limit)
try:
page = max(1, int(page))
except (TypeError, ValueError):
page = 1
kept = _kept_lookup()
chosen = images[(page - 1) * limit:page * limit]
found = {
"total_matches": len(images), "page": page,
"images": [{"image": "/".join(part for part in (item["subfolder"], item["filename"]) if part),
"created": _when(item.get("mtime")),
**({"kept_as": kept[(item["subfolder"], item["filename"])]}
if (item["subfolder"], item["filename"]) in kept else {})}
for item in chosen],
}
if not with_pictures:
return found
pictures = [] # the first few themselves, in the order listed, so an AI that sees pictures need not ask for each
for entry in found["images"][:MAX_SEARCH_PICTURES]:
item = _image_content(_resolve_image(entry["image"])[1])
entry["attached"] = bool(item)
if item:
pictures.append(item)
return found, pictures
def get_image_info(image, include_image=False, folder="output"):
if folder != "output":
rel, path = _resolve_folder_image(image, folder)
info = {"image": rel, "folder": folder, "created": _when(os.path.getmtime(path)), **input_facts(path)}
extra = [item for item in [_image_content(path)] if include_image and item]
if include_image:
info["image_attached"] = bool(extra)
return info, extra
rel, path = _resolve_image(image)
info = {"image": rel, "created": _when(os.path.getmtime(path))}
chunks = read_png_text(path) if path.lower().endswith(".png") else {}
try:
prompt = json.loads(chunks.get("prompt") or "null")
except ValueError:
prompt = None
if isinstance(prompt, dict):
nodes, models = [], set()
for node_id, node in list(prompt.items())[:80]:
if not isinstance(node, dict):
continue
inputs = {}
for key, value in (node.get("inputs") or {}).items():
if isinstance(value, str):
inputs[key] = _text(value, 2000) if len(value) > 2000 else value
if value.lower().endswith(MODEL_EXTENSIONS):
models.add(value.replace("\\", "/"))
elif isinstance(value, (int, float, bool)):
inputs[key] = value
nodes.append({"id": node_id, "type": node.get("class_type"), "inputs": inputs})
info.update(models_used=sorted(models), nodes=nodes)
if chunks.get("workflow"):
info["to_change_it"] = f"open_workflow(workflow='{rel}') opens the workflow that made it, as it was"
elif chunks.get("parameters"):
info["parameters"] = _text(chunks["parameters"], 4000)
else:
info["note"] = "No generation settings are stored in this image."
kept = _kept_lookup().get(tuple(rel.rsplit("/", 1)) if "/" in rel else ("", rel))
if kept:
info["kept_as"] = kept
extra = []
if include_image:
item = _image_content(path)
info["image_attached"] = bool(item)
extra = [item] if item else []
return info, extra
def search_audio(query="", limit=20):
items, total, _more = _audio_page(folder_paths.get_output_directory(), 1, _limit(limit), str(query or "").lower().strip())
return {"total_matches": total, "audio": [{
"file": "/".join(part for part in (item["subfolder"], item["filename"]) if part),
"created": _when(item.get("mtime")),
"speech": (item.get("generation") or {}).get("speech") or "",
"voice_sample": (item.get("generation") or {}).get("sample") or "",
"seed": (item.get("generation") or {}).get("seed"),
} for item in items]}
# ---------------------------------------------------------------- what the user kept
def _model_name(model):
if isinstance(model, dict):
return model.get("filename") or model.get("name") or ""
return str(model or "")
def combo_view(note):
"""A combo (one of _list_notebooks) as the AI and the materials picker see it, with its LoRAs and texts."""
data = note.get("data") or {}
if data.get("kind") == "nodes": # its own node structure: model and text slots by their names
structure = data.get("structure") or {}
values = data.get("values") or {}
slots = structure.get("slots") or []
models = [{"slot": s.get("label") or s.get("widget"), "folder": s.get("folder") or "",
"file": os.path.basename(str(values.get(s["id"]) or "").replace("\\", "/"))}
for s in slots if s.get("kind") == "model"]
texts = [{"slot": s.get("label") or s.get("widget"), "text": _text(values.get(s["id"]), 400)}
for s in slots if s.get("kind") == "text"]
combo = {"name": note.get("name"), "kind": "node structure", "models": models, "texts": texts,
"nodes": [n.get("name") or n.get("type") for n in structure.get("nodes") or []],
"main_model": models[0]["file"] if models else "", "prompt": texts[0]["text"] if texts else ""}
return combo, [model["file"] for model in models[1:]] + [text["text"] for text in texts[1:]]
loras = [_model_name(lora) for lora in data.get("loras") or []]
return {"name": note.get("name"), "base_model": data.get("baseModel") or "", "main_model": _model_name(data.get("mainModel")),
"loras": loras, "prompt": _text(data.get("promptEn"), 800)}, loras
def list_combos(query="", limit=20, *, exact_name=False):
terms, found = _terms(query), []
for note in _list_notebooks():
combo, loras = combo_view(note)
if exact_name and combo["name"] != query:
continue
if not exact_name and not _matches(terms, combo["name"], combo["main_model"], combo["prompt"], " ".join(loras)):
continue
found.append(combo)
return {"total_matches": len(found), "combos": found[:_limit(limit)]}
def plans():
"""Each saved workflow's 方案: the parameter sets saved under it with values unlike its own (saving a workflow
keeps one equal to them, which the Workflows page leaves out too): {workflow file name: [(set file name, set name)]}."""
own = {recipe["filename"]: (recipe["data"].get("parameter_signature") or {}).get("value") for recipe in _list_recipes(get_recipes_dir())}
out = {}
for notebook in _read_parameter_notebooks(get_parameters_dir()):
workflow = notebook["data"].get("recipe_filename")
if workflow in own and not (own[workflow] and (notebook["data"].get("parameter_signature") or {}).get("value") == own[workflow]):
out.setdefault(workflow, []).append((notebook["filename"], notebook["name"]))
return out
def list_workflows(query="", limit=20, *, exact_name=False):
terms, found, saved_plans = _terms(query), [], plans()
for recipe in _list_recipes(get_recipes_dir(), with_workflow=True):
data = recipe.get("data") or {}
name = data.get("name") or recipe["filename"]
params = json.dumps(data.get("params") or {}, ensure_ascii=False)
own = [f"{name} / {plan}" for _file, plan in saved_plans.get(recipe["filename"], [])][:MAX_PLANS]
if exact_name and name != query:
continue
if not exact_name and not _matches(terms, name, " ".join(data.get("tags") or []), data.get("notes"), params, " ".join(own)):
continue
found.append({"name": name, "route": workflow_route.line(data.get("workflow")), "tags": data.get("tags") or [],
"notes": _text(data.get("notes"), 500), "saved": _when((data.get("timestamp") or 0) / 1000),
"settings": params if len(params) <= MAX_TEXT else params[:MAX_TEXT] + "…",
**({"plans": own} if own else {})}) # run_workflow(workflow=one of them) runs it with that set
fits = (lambda item: item["name"] == query) if exact_name else (lambda item: _matches(terms, item["name"], item["title"], item["notes"]))
return example_workflows.answer(found, fits, _limit(limit))
def saved_prompts():
"""Every saved prompt plan: {name, tags, positive, negative}."""
directory = get_materials_dir()
for summary in _list_materials(directory):
if summary.get("kind") != "prompt_plan" or summary.get("moved_to_recipe"):
continue
try:
plan = _read_material(os.path.join(directory, summary["filename"])).get("plan") or {}
except (OSError, ValueError, KeyError):
continue
yield {"name": summary.get("name"), "tags": summary.get("tags") or [],
"positive": _text(plan.get("positive"), 1200), "negative": _text(plan.get("negative"), 600)}
def list_saved_prompts(query="", limit=20):
terms = _terms(query)
found = [prompt for prompt in saved_prompts()
if _matches(terms, prompt["name"], " ".join(prompt["tags"]), prompt["positive"], prompt["negative"])]
return {"total_matches": len(found), "prompts": found[:_limit(limit)]}
def recent_activity(limit=10, source=""):
entries = list_entries(_limit(limit), source if source in ("canvas", "file") else None)
return {"entries": [{"time": _when(entry.get("time")), "source": entry.get("source"), "action": entry.get("action"),
"target": entry.get("target") or "", "detail": entry.get("detail") or {}} for entry in entries]}
# ---------------------------------------------------------------- registry
def _schema(properties=None, required=()):
schema = {"type": "object", "properties": properties or {}, "additionalProperties": False}
if required:
schema["required"] = list(required)
return schema
_QUERY = {"type": "string", "description": "Words that must all appear; empty lists all."}
_LIMIT = {"type": "integer", "minimum": 1, "maximum": MAX_LIMIT, "default": 20}
def _tool(name, title, description, run, properties=None, required=()):
return name, {"run": run, "spec": {
"name": name, "title": title, "description": description,
"inputSchema": _schema(properties, required),
"annotations": {"title": title, "readOnlyHint": True, "destructiveHint": False,
"idempotentHint": True, "openWorldHint": False},
}}
TOOLS = dict([
_tool("library_overview", "Library overview",
"How many models of each type and base model the user has, how many are scanned, and how many "
"combos, workflow recipes and saved prompts they kept. A good first call.", library_overview),
_tool("scan_report", "Scan status",
"Which models are matched on Civitai, which are not and why, which were never scanned, and what the last scan did.",
scan_report),
_tool("search_images", "Search output images",
"Output images, newest first, by words in their prompt, model or settings (a LoRA name, 'seed:123'). "
"with_pictures attaches the first 6 (512 px).",
search_images, {"query": _QUERY, "limit": _LIMIT, "page": {"type": "integer", "minimum": 1, "default": 1},
"with_pictures": {"type": "boolean", "default": False}}),
_tool("get_image_info", "Image generation settings",
"How an output image was made (prompts, models, seed, sampler…) and whether the user kept it. "
"folder=input: a picture LoadImage reads; folder=temp: a run's preview; both with their size and MASK "
"area. include_image attaches a 512 px copy; zoom_image shows a part larger.",
get_image_info, {"image": {"type": "string", "description": "From search_images; for folder=input, the name LoadImage takes."},
"include_image": {"type": "boolean", "default": False},
"folder": {"type": "string", "enum": ["output", "input", "temp"], "default": "output"}}, ["image"]),
_tool("list_combos", "Combos",
"The user's combos (搭配): a main model, LoRAs and a prompt kept together, or (kind \"node "
"structure\") model nodes and text nodes saved from the canvas with their links, listed by slot.", list_combos,
{"query": _QUERY, "limit": _LIMIT}),
_tool("list_workflows", "Workflow recipes",
"The user's saved workflow recipes: route, tags, notes, plans; settings when few match. `examples`: official ones by name, opened and run like saved ones.", list_workflows,
{"query": _QUERY, "limit": _LIMIT}),
_tool("list_saved_prompts", "Saved prompts",
"Prompts the user saved in the Prompt Studio, with positive and negative text.", list_saved_prompts,
{"query": _QUERY, "limit": _LIMIT}),
_tool("search_audio", "Generated audio",
"Speech the user generated (Anomalous TTS), newest first, with its text, voice and seed.", search_audio,
{"query": _QUERY, "limit": _LIMIT}),
_tool("recent_activity", "Activity log",
"What Anomalous changed recently, newest first: canvas edits it made and files it saved, scanned or deleted.",
recent_activity, {"limit": {**_LIMIT, "default": 10}, "source": {"type": "string", "enum": ["canvas", "file"]}}),
])
def tool_text(data):
"""A result as the AI reads it: JSON without spaces. Apps keep a long one out of the chat and
have the AI open it as a file (Antigravity past about 4,000 characters), so every character counts."""
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
def call_tool(tools, name, arguments, ctx=None):
"""An MCP tools/call result for tool `name` of `tools` (run in a worker thread). Tools marked
`context` get `ctx` ({base_url} of this server) first."""
tool = tools[name]
spec = tool["spec"]["inputSchema"]
arguments, renamed = prepare_arguments(spec, arguments)
unknown = set(arguments) - set(spec["properties"])
missing = [key for key in spec.get("required", []) if key not in arguments]
if unknown or missing:
wrong = "; ".join(part for part in (unknown and f"Unknown arguments {sorted(unknown)}", missing and f"missing {missing}") if part)
takes = ", ".join(key + (" (needed)" if key in spec.get("required", []) else "") for key in spec["properties"] if key != "task")
return _failure(f"{wrong}. {name} takes: {takes or 'nothing'}.")
try:
value = tool["run"](ctx, **arguments) if tool.get("context") else tool["run"](**arguments)
except ToolError as error:
return _failure(str(error))
except Exception as error: # noqa: BLE001 - an AI app gets a readable failure, never a dropped request
logging.getLogger(__name__).warning("Anomalous MCP tool %s failed", name, exc_info=True)
return _failure(f"{name} failed: {error}")
data, extra = value if isinstance(value, tuple) else (value, [])
if renamed and isinstance(data, dict):
data = {**data, "arguments_note": renaming_note(name, renamed)}
return {"content": [{"type": "text", "text": tool_text(data)}, *extra],
"structuredContent": data, "isError": False}
def retext(result, data):
"""A tool result with `data` as its structured content and as its text (the rest of its content kept)."""
result["structuredContent"] = data
content = list(result.get("content") or [])
text = {"type": "text", "text": tool_text(data)}
if content and isinstance(content[0], dict) and content[0].get("type") == "text":
content[0] = text
else:
content.insert(0, text)
result["content"] = content
def _failure(message):
return {"content": [{"type": "text", "text": message}], "isError": True}
+103 -6
View File
@@ -3,15 +3,21 @@
Card images are 512 px WebP copies of the covers (a video cover gets one of its first
frame, the poster), kept in the user folder so they survive ComfyUI restarts (ComfyUI
empties its temp folder on every start). Listing a folder queues the covers not made yet
for one background worker, so scrolling and the next visit find them ready.
for one background worker, so scrolling and the next visit find them ready. A picture past
Pillow's pixel limit (a 4x upscale of a big one) is shrunk once in a child process
(huge_picture.py), so ComfyUI's own memory never holds it; a card never falls back to a large
original, it gets a placeholder with the picture's size instead.
"""
import asyncio
import collections
import hashlib
import json
import logging
import os
import struct
import subprocess
import sys
import tempfile
import threading
import time
@@ -26,8 +32,16 @@ from .path_utils import require_filename, resolve_folder_subdir, resolve_within
CARD_THUMBNAIL_EDGE = 512
CARD_THUMBNAIL_CACHE_LIMIT = 256 * 1024 * 1024
CARD_THUMBNAIL_STATIC_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.avif'}
CARD_POSTER_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov'}
CARD_POSTER_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov', '.avi', '.mkv'}
CARD_WARM_QUEUE_LIMIT = 512
ORIGINAL_CARD_BYTES = 8 * 1024 * 1024 # an unreadable picture this small is shown as it is
HUGE_PICTURE_PIXELS = 400_000_000 # the most a child process decodes (about 1.2 GB as RGB)
HUGE_DECODE_SECONDS = 180
_HUGE_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'huge_picture.py')
_TOO_LARGE = object() # _build_card_thumbnail_impl: past Pillow's limit, for the child process
_huge_lock = threading.Lock() # one child decode at a time
_huge_failed = set() # cache paths a child could not make: not tried again for that file
log = logging.getLogger(__name__)
_thumbnail_cleanup_lock = threading.Lock()
_thumbnail_generation_slots = threading.BoundedSemaphore(2)
_thumbnail_last_cleanup = 0.0
@@ -173,14 +187,34 @@ def _save_card_image(image, cache_dir, cached_path):
_prune_thumbnail_cache(cache_dir, created=True)
def picture_size(path):
"""(width, height) of a picture from its header without decoding it (past Pillow's pixel
limit too, for PNG); None when unknown."""
from PIL import Image
try:
with open(path, 'rb') as file:
head = file.read(24)
if head[:8] == b'\x89PNG\r\n\x1a\n' and head[12:16] == b'IHDR':
return struct.unpack('>II', head[16:24])
with Image.open(path) as image:
return image.size
except (OSError, ValueError, Image.DecompressionBombError):
return None
def _build_card_thumbnail_impl(source_path):
"""Return a cached 512px WebP card image, or the original on any safe fallback."""
"""A cached 512px WebP card image; the original when it is small already (or an animation);
_TOO_LARGE past Pillow's pixel limit; None when it cannot be read and is not small."""
try:
from PIL import Image, ImageOps
cache_dir, cached_path = _cached_card_path(source_path)
if _reuse_cached(cache_dir, cached_path):
return cached_path
size = picture_size(source_path)
if size and size[0] * size[1] > Image.MAX_IMAGE_PIXELS:
return _TOO_LARGE
with Image.open(source_path) as image:
if getattr(image, 'is_animated', False):
@@ -191,13 +225,73 @@ def _build_card_thumbnail_impl(source_path):
_save_card_image(image, cache_dir, cached_path)
return cached_path
except Exception:
return source_path
try:
return source_path if os.path.getsize(source_path) <= ORIGINAL_CARD_BYTES else None
except OSError:
return None
def _build_card_thumbnail(source_path):
"""The card image of a picture (_build_card_thumbnail_impl), one past Pillow's pixel limit
made by a child process (shrunk_in_child). None: show picture_placeholder instead."""
# Bound simultaneous decodes so opening a large folder cannot monopolize CPU/RAM.
with _thumbnail_generation_slots:
return _build_card_thumbnail_impl(source_path)
made = _build_card_thumbnail_impl(source_path)
# Outside the slots: a huge decode takes long and must not hold the other cards up.
return shrunk_in_child(source_path, CARD_THUMBNAIL_EDGE) if made is _TOO_LARGE else made
def shrunk_in_child(source_path, edge, kind=''):
"""A cached WebP of a very large picture, at most `edge` px, made once by a child process at
low priority (its memory goes back when it ends), one at a time. None past
HUGE_PICTURE_PIXELS, or when it failed for this file before."""
size = picture_size(source_path)
if not size or size[0] * size[1] > HUGE_PICTURE_PIXELS:
return None
try:
cache_dir, cached_path = _cached_card_path(source_path, kind)
except OSError:
return None
with _huge_lock:
if _reuse_cached(cache_dir, cached_path):
return cached_path
if cached_path in _huge_failed:
return None
temp_path = f"{cached_path}.{os.getpid()}.huge.tmp"
flags = getattr(subprocess, 'BELOW_NORMAL_PRIORITY_CLASS', 0) | getattr(subprocess, 'CREATE_NO_WINDOW', 0)
try:
subprocess.run([sys.executable, '-I', _HUGE_SCRIPT, source_path, temp_path, str(edge), str(HUGE_PICTURE_PIXELS)],
check=True, capture_output=True, timeout=HUGE_DECODE_SECONDS, creationflags=flags)
os.replace(temp_path, cached_path)
except (OSError, subprocess.SubprocessError) as error:
_huge_failed.add(cached_path)
log.warning("Anomalous: could not shrink the %dx%d picture %s: %s", size[0], size[1],
os.path.basename(source_path), (getattr(error, 'stderr', None) or b'')[-300:] or error)
return None
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
_prune_thumbnail_cache(cache_dir, created=True)
return cached_path
def picture_placeholder(source_path):
"""The card of a picture with no card image (too large, unreadable): its pixel size and file
size on a plain tile, numbers only (nothing to translate)."""
size = picture_size(source_path)
try:
megabytes = f"{os.path.getsize(source_path) / 1048576:.0f} MB"
except OSError:
megabytes = ""
pixels = f"{size[0]} × {size[1]}" if size else ""
svg = ('<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">'
'<rect width="512" height="512" fill="#2b2f36"/>'
'<g fill="none" stroke="#8b93a1" stroke-width="10" stroke-linejoin="round">'
'<rect x="176" y="150" width="160" height="130" rx="14"/><path d="m186 262 46-44 34 30 28-22 42 36"/></g>'
'<circle cx="226" cy="196" r="14" fill="#8b93a1"/>'
f'<text x="256" y="340" fill="#d5d9e0" font-family="sans-serif" font-size="30" text-anchor="middle">{pixels}</text>'
f'<text x="256" y="382" fill="#8b93a1" font-family="sans-serif" font-size="26" text-anchor="middle">{megabytes}</text></svg>')
return web.Response(body=svg.encode('utf-8'), content_type='image/svg+xml', headers={'Cache-Control': 'no-cache'})
def _build_video_poster_impl(source_path):
@@ -302,7 +396,8 @@ async def api_serve_image(request):
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mov': 'video/quicktime',
'.avi': 'video/x-msvideo'
'.avi': 'video/x-msvideo',
'.mkv': 'video/x-matroska'
}
content_type = content_types.get(ext)
if content_type is None:
@@ -312,6 +407,8 @@ async def api_serve_image(request):
variant = request.query.get('variant')
if variant == 'card' and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS:
served_path = await asyncio.to_thread(_build_card_thumbnail, file_path)
if served_path is None:
return picture_placeholder(file_path)
if served_path != file_path:
content_type = 'image/webp'
elif variant == 'poster':
+37 -8
View File
@@ -12,8 +12,11 @@ import folder_paths
import struct
try:
from ..model_identity import USER_INFO_SUFFIX, is_unmatched, read_json, sidecar_file_hash, sidecar_info, unmatched_reason
from ..model_sidecars import find
except ImportError:
from model_identity import USER_INFO_SUFFIX, is_unmatched, read_json, sidecar_file_hash, sidecar_info, unmatched_reason
from model_sidecars import find
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"}
@@ -85,8 +88,18 @@ def _positive_civitai_id(value):
return None
return parsed if parsed > 0 else None
def _info_base(file_path, base_path):
"""Where the model's info files are read: beside the model as named; for a link with none
beside it, beside the file it links to, where another tool (A1111's Civitai Helper) may
have left them. Only read there: covers, edits and scans stay beside the link."""
if os.path.islink(file_path) and not (find(base_path, ".info") or os.path.exists(base_path + ".civitai.info")):
return os.path.splitext(os.path.realpath(file_path))[0]
return base_path
def _read_metadata(file_path):
base_path = os.path.splitext(file_path)[0]
info_base = _info_base(file_path, base_path)
metadata = {
"name": os.path.basename(base_path),
"description": "",
@@ -101,13 +114,17 @@ def _read_metadata(file_path):
"hash_source": "",
"custom_name": "",
"custom_notes": "",
"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)
"info_source": "",
"info_source": "",
"unmatched_reason": "", # for "local": not_found | network | offline | "" (scanned before this was kept)
"user_fields": [],
"scan_choice": "", # the user's "skip" / "scan" (model_identity.scan_choice)
"not_drawing": False, # not scanned yet, and its header looks like no drawing model
}
info_files = [f"{base_path}.info", f"{base_path}.civitai.info"]
info_files = [path for path in (find(info_base, ".info"), f"{info_base}.civitai.info") if path]
for info_file in info_files:
if os.path.exists(info_file):
try:
@@ -156,6 +173,8 @@ def _read_metadata(file_path):
if civitai_url: metadata["civitai_url"] = civitai_url
if model_id is not None: metadata["model_id"] = model_id
if version_id is not None: metadata["version_id"] = version_id
if version_id is not None and isinstance(data.get("model"), dict) and isinstance(data.get("name"), str):
metadata["version_name"] = data["name"].strip()
if hash_val: metadata["hash"] = hash_val
if "anomalous_custom_name" in data and data["anomalous_custom_name"]: metadata["custom_name"] = data["anomalous_custom_name"]
if "anomalous_custom_notes" in data and data["anomalous_custom_notes"]: metadata["custom_notes"] = data["anomalous_custom_notes"]
@@ -163,12 +182,21 @@ def _read_metadata(file_path):
except Exception:
pass
scan = sidecar_info(base_path)
scan = sidecar_info(info_base)
if scan is not None:
metadata["info_source"] = "local" if is_unmatched(scan) else "civitai"
metadata["info_source"] = "local" if is_unmatched(scan) else "civitai"
metadata["unmatched_reason"] = unmatched_reason(scan)
if scan is None:
found = inspect_file(file_path)
if not metadata["baseModel"]:
metadata["base_guess"] = found.get("base") or ""
# A header that reads but names no drawing model kind (a voice model, say).
metadata["not_drawing"] = found["format"] == "safetensors" and not found["kind"]
# The user's own layer comes last and wins, also when a field was cleared on purpose ("").
user = read_json(base_path + USER_INFO_SUFFIX) or {}
user_file = find(base_path, USER_INFO_SUFFIX)
user = (read_json(user_file) if user_file else None) or {}
if user.get("scan") in ("skip", "scan"):
metadata["scan_choice"] = user["scan"]
for key, field in USER_FIELDS.items():
if isinstance(user.get(key), str):
metadata[field] = user[key]
@@ -188,9 +216,9 @@ def _metadata_signature(file_path):
base_path = os.path.splitext(file_path)[0]
return (
_file_signature(file_path),
_file_signature(f"{base_path}.info"),
_file_signature(find(base_path, ".info") or f"{base_path}.info"),
_file_signature(f"{base_path}.civitai.info"),
_file_signature(base_path + USER_INFO_SUFFIX),
_file_signature(find(base_path, USER_INFO_SUFFIX) or base_path + USER_INFO_SUFFIX),
)
@@ -204,7 +232,8 @@ def _get_metadata_cached(file_path, signature):
def get_metadata(file_path):
normalized_path = os.path.realpath(file_path)
# As named, not resolved: a model that is a link has its covers and info beside the link.
normalized_path = os.path.abspath(file_path)
signature = _metadata_signature(normalized_path)
# Metadata contains mutable lists. Never expose the cached object directly.
return copy.deepcopy(_get_metadata_cached(normalized_path, signature))
+201
View File
@@ -0,0 +1,201 @@
"""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
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);
- whether a LoRA suits the main model it is put on: add_lora and set_model compare their base
models (the scan's, else what the file's header tells) and say so when the families differ
(an SD 1.5 LoRA on an SDXL checkpoint does nothing useful) or, more softly, when two bases of one
family differ (a Pony LoRA on an Illustrious checkpoint). A note only: the call is made;
- how large the main model is (main_params), for advice that depends on it (ai_skills.py).
"""
import json
import os
from collections import Counter
import folder_paths
from . import metadata
try:
from ..model_kind import diffusion_params
except ImportError: # loaded outside the package (tests)
from model_kind import diffusion_params
# Base model names (Civitai's, or what model_kind.py infers) by the architecture their files share.
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")),
)
# 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")
MAIN_TYPES = ("checkpoints", "diffusion_models", "unet")
MAX_EXAMPLES = 20
def family(base):
"""The architecture family of a base model name ("" when none is known)."""
lowered = str(base or "").strip().lower()
if not lowered or lowered in ("other", "unknown"):
return ""
for name, marks in FAMILIES:
if lowered.startswith(marks):
return name
return lowered
def base_of(file_path):
"""A model file's base model: the scan's, else what its header tells."""
meta = metadata.get_metadata(file_path)
return meta.get("baseModel") or meta.get("base_guess") or ""
def _main_base(value):
"""The base model of a main loader's choice (its drop-down value), or ""."""
for folder_type in MAIN_TYPES:
try:
path = folder_paths.get_full_path(folder_type, value)
except (KeyError, TypeError):
path = None
if path:
return base_of(path)
return ""
def main_params(value):
"""How many parameters the drawing model of a main loader's choice (its drop-down value) has, from
its header; 0 when it is not found or its header cannot tell."""
for folder_type in MAIN_TYPES:
try:
path = folder_paths.get_full_path(folder_type, value)
except (KeyError, TypeError):
path = None
if path:
return diffusion_params(path)
return 0
def lora_paths(values):
"""The files of LoRA drop-down values that ComfyUI can find."""
paths = []
for value in values or []:
try:
path = folder_paths.get_full_path("loras", value)
except (KeyError, TypeError):
path = None
if path:
paths.append(path)
return paths
def lora_note(lora_path, line):
"""A note when the LoRA at `lora_path` does not suit the main model of `line` (the page's
{"model": the main loader's choice}), else None."""
main = _main_base((line or {}).get("model") or "")
lora = base_of(lora_path)
if not main or not lora or main.lower() == lora.lower():
return None
if family(main) != family(lora):
return (f"Base model clash: this LoRA is for {lora}, the main model ({line['model']}) is {main}; it will not "
f"work as meant. Pick one with search_models(type='loras', base_model='{main}'), or remove it.")
if lora.lower().startswith(OWN_WAYS) or main.lower().startswith(OWN_WAYS):
return (f"This LoRA was made for {lora}, the main model is {main}: same family, so it loads, but it may "
f"look weaker or off. search_models(type='loras', base_model='{main}') finds ones made for it.")
return None
def _number(value):
try:
number = float(str(value).strip())
except (TypeError, ValueError):
return None
return number if number == number and abs(number) < 1e6 else None
def _span(numbers):
"""'25' or '20–30' for a list of numbers, '' for none."""
if not numbers:
return ""
low, high = min(numbers), max(numbers)
say = lambda number: f"{number:g}"
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."""
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})"
def _loras(meta):
"""[(who, weight)] of the LoRAs one picture's settings name: who is a hash, a file name or "v<version>"."""
found = []
for item in meta.get("resources") or []:
if isinstance(item, dict) and str(item.get("type", "")).lower() == "lora":
found.append((str(item.get("hash") or item.get("name") or "").lower(), _number(item.get("weight"))))
for item in meta.get("additionalResources") or []:
if isinstance(item, dict) and str(item.get("type", "lora")).lower() == "lora":
found.append((os.path.splitext(os.path.basename(str(item.get("name", ""))))[0].lower(), _number(item.get("strength"))))
for item in meta.get("civitaiResources") or []:
if isinstance(item, dict) and str(item.get("type", "")).lower() == "lora" and item.get("modelVersionId") is not None:
found.append((f"v{item['modelVersionId']}", _number(item.get("weight"))))
return [(who, weight) for who, weight in found if who and weight is not None]
def _lora_weights(metas, own):
"""The weights this LoRA was used at in its pictures: those naming it (`own`: its hash, file
name, version), else the one LoRA most of them name; none when two are named as often."""
per = [_loras(meta) for meta in metas]
named = Counter(who for found in per for who in {who for who, _weight in found})
mine = {who for who in named if who in own}
if not mine:
common = [(who, times) for who, times in named.most_common(2) if times * 5 >= len(metas) * 3]
if common and (len(common) == 1 or common[0][1] > common[1][1]):
mine = {common[0][0]}
return [weight for found in per for who, weight in found if who in mine]
def _record(file_path):
"""The Civitai record kept beside the model (its .info), or None."""
base_path = os.path.splitext(file_path)[0]
info_base = metadata._info_base(file_path, base_path)
for path in (metadata.find(info_base, ".info"), f"{info_base}.civitai.info"):
if path and os.path.isfile(path):
try:
with open(path, encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, ValueError):
continue
if isinstance(data, dict) and isinstance(data.get("images"), list):
return data
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."""
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]
if not metas:
return {}
sizes = [meta.get("Size") or (f"{meta['width']}x{meta['height']}" if meta.get("width") and meta.get("height") else "")
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)),
"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)),
"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}
+25 -123
View File
@@ -1,7 +1,6 @@
"""Model folder discovery, listing, search, and presentation lookup."""
import asyncio
import json
import os
import urllib.parse
@@ -9,13 +8,31 @@ from aiohttp import web
import folder_paths
from .metadata import get_metadata
try:
from ..model_sidecars import HIDDEN_DIR
except ImportError:
from model_sidecars import HIDDEN_DIR
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES, is_model_file
from .model_media import _cache_token, _preview_url_for_model
from .folder_types import (
get_active_folder_types, get_active_model_roots,
get_active_physical_basenames, get_folder_view_mode,
)
from .path_utils import resolve_folder_subdir
from .path_utils import model_reference_within, resolve_folder_subdir
def _folder_tree(base_dir):
"""{'/'-relative folder: {path, name, has_models, model_count}} under a model root; the
.amb folders hold side files only (model_sidecars)."""
tree = {}
for root, dirs, files in os.walk(base_dir):
dirs[:] = [name for name in dirs if name != HIDDEN_DIR]
count = sum(1 for f in files if is_model_file(f))
rel = os.path.relpath(root, base_dir)
rel = '/' if rel == '.' else '/' + rel.replace('\\', '/')
tree[rel] = {"path": rel, "name": os.path.basename(root) if rel != '/' else '[Root]',
"has_models": count > 0, "model_count": count}
return tree
def _collect_folder_models(target_dir, folder_type, path_idx, rel_subfolder, page, limit):
"""Collect one folder in a worker thread with a single directory listing."""
@@ -121,20 +138,7 @@ def _collect_folders():
for item in matched:
base_dir = item["base_dir"]
tree = {}
for root, dirs, files in os.walk(base_dir):
has_models = any(is_model_file(f) for f in files)
rel = os.path.relpath(root, base_dir)
if rel == '.':
rel = '/'
else:
rel = '/' + rel.replace('\\', '/')
tree[rel] = {
"path": rel,
"name": os.path.basename(root) if rel != '/' else '[Root]',
"has_models": has_models,
"model_count": sum(1 for f in files if is_model_file(f))
}
tree = _folder_tree(base_dir)
label = item["bn"]
if len(matched) > 1:
@@ -164,20 +168,7 @@ def _collect_folders():
continue
seen_dirs.add(real_path)
tree = {}
for root, dirs, files in os.walk(base_dir):
has_models = any(is_model_file(f) for f in files)
rel = os.path.relpath(root, base_dir)
if rel == '.':
rel = '/'
else:
rel = '/' + rel.replace('\\', '/')
tree[rel] = {
"path": rel,
"name": os.path.basename(root) if rel != '/' else '[Root]',
"has_models": has_models,
"model_count": sum(1 for f in files if is_model_file(f))
}
tree = _folder_tree(base_dir)
try:
folder_basename = os.path.basename(os.path.normpath(base_dir))
@@ -448,15 +439,14 @@ def _resolve_paths_to_model_info_sync(paths, folder_types=None, exact_only=False
real_base_dir = os.path.realpath(base_dir)
roots.append((folder_type, path_idx, real_base_dir))
for original, _, relative_path in requested:
relative = relative_path.replace('/', os.sep)
candidate = os.path.realpath(os.path.join(real_base_dir, relative))
try:
if os.path.commonpath((real_base_dir, candidate)) != real_base_dir:
continue
candidate = model_reference_within(real_base_dir, relative_path.replace('/', os.sep))
except ValueError:
continue
if os.path.isfile(candidate) and is_model_file(candidate):
exact_results[original] = _model_info_for_path(folder_type, path_idx, base_dir, candidate)
# The candidate lies under the real folder: a folder that is a link (a junction
# to another drive, a short 8.3 name) would give a subfolder of "../.." or fail.
exact_results[original] = _model_info_for_path(folder_type, path_idx, real_base_dir, candidate)
unresolved = [(original, normalized) for original, normalized, _ in requested if original not in exact_results]
if exact_only or not unresolved:
@@ -601,91 +591,3 @@ async def api_get_all_scan_models(request):
limit = int(request.query.get('limit', 0))
payload = await asyncio.to_thread(_collect_all_scan_models, page, limit)
return web.json_response(payload)
async def api_batch_select(request):
folder_key = request.query.get('folderKey', 'ALL')
action = request.query.get('action', 'all')
def matches_condition(file_path, root, base_name):
if action == 'all':
return True
elif action == 'no_preview':
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
if os.path.exists(os.path.join(root, base_name + ext)):
return False
return True
elif action == 'no_desc':
info_file = file_path + '.info'
civitai_info = os.path.join(root, base_name + '.civitai.info')
if os.path.exists(civitai_info):
try:
with open(civitai_info, 'r', encoding='utf-8') as f:
data = json.load(f)
if data.get('description', '').strip():
return False
except: pass
if os.path.exists(info_file):
try:
with open(info_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if data.get('description', '').strip():
return False
except: pass
return True
return False
results = {}
if folder_key == 'ALL':
target_types = get_active_folder_types()
seen_dirs = set()
for t in target_types:
try: paths = folder_paths.get_folder_paths(t)
except Exception: continue
if not paths: continue
for path_idx, base_dir in enumerate(paths):
if base_dir in seen_dirs: continue
seen_dirs.add(base_dir)
if not os.path.exists(base_dir): continue
for root, dirs, files in os.walk(base_dir):
for f in files:
if is_model_file(f):
file_path = os.path.join(root, f)
base_name = os.path.splitext(f)[0]
if matches_condition(file_path, root, base_name):
rel_subfolder = os.path.relpath(root, base_dir)
if rel_subfolder == '.': rel_subfolder = ''
else: rel_subfolder = rel_subfolder.replace('\\', '/')
fkey = f"{t}|{path_idx}|{rel_subfolder}"
if fkey not in results: results[fkey] = []
results[fkey].append(f)
else:
parts = folder_key.split('|')
if len(parts) >= 3:
t = parts[0]
path_idx = int(parts[1])
subfolder = parts[2]
try: paths = folder_paths.get_folder_paths(t)
except Exception: paths = []
if paths and path_idx < len(paths):
try:
base_dir, target_dir = resolve_folder_subdir(t, path_idx, subfolder)
except ValueError:
return web.json_response({"selected": {}})
if os.path.exists(target_dir):
try: entries = os.listdir(target_dir)
except: entries = []
for f in entries:
if is_model_file(f):
file_path = os.path.join(target_dir, f)
if os.path.isfile(file_path):
base_name = os.path.splitext(f)[0]
if matches_condition(file_path, target_dir, base_name):
if folder_key not in results: results[folder_key] = []
results[folder_key].append(f)
return web.json_response({"selected": results})

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