Compare commits

...
671 Commits
Author SHA1 Message Date
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 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 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
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 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
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
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
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 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 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 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
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 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 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 aa9c237f98 Only this computer can change things; other devices can look (v1.58.1)
With ComfyUI started with --listen, other devices could use most of AMB's
actions: delete and rename models, save and delete workflows and prompts,
start downloads and scans, export a backup of all data, translate with the
user's key, change settings, switch the plugin version. A probe of all
routes as a LAN device found 9 of 112 refusing.

api/local_guard.py is one middleware in front of every /anomalous/ route:
anything but GET answers only this computer unless it is a read sent as POST
(OPEN_POSTS); a GET is open unless it is more than a read (LOCAL_GETS:
backup file, GitHub releases, local folders). Local = loopback peer, no
proxy headers, loopback Host (DNS rebinding is refused too). Refusals are
403 local_only with a bilingual message; GET /anomalous/access lets the page
show a "view only" chip (remote_view.js). AGENTS.md rule 4 now names it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:38:20 +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 9481729c14 Models found offline no longer get their name doubled on each scan
A record inferred from the file alone names model and version after the file. With renaming on, the scan renamed it to name_name, and every later scan doubled it again; the details showed name - name. Such records are no longer renamed (physically or virtually, an earlier doubled virtual name is dropped), and a model and version of one name read once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:03:47 +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 f0dca38ffb Workflows page no longer blank after opening a workflow on the canvas
Opening a workflow on the canvas hid the workspace without restoring the page under it, so
opening the browser again showed an empty page. It now only hides the browser; the Workflows
list stays in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:53:07 +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 9c7fe63f60 Keep a picture without changing the canvas's values
ComfyUI keeps widget values by the root graph's id; a picture's workflow laid on a
detached graph under the open canvas's id shared and overwrote the canvas's values.
detached_graph.js gives the copy an id of its own; image_keep and recipe_save use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:18:57 +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 32c7fbd95a Fix picker jumping to the top, combo saved per tag, unreadable long-text activity
Same fixes as feature/next 99576e6, brought into v1.58.0 (the user fixes the
released line directly while it has few users):
- Current node's model picker repaints the chosen card in place instead of
  redrawing the grid, which scrolled back to the top.
- The classic combo editor no longer saves when its bilingual tags are shown nor
  once per translated tag; one save follows the last translation.
- A long activity before/after pair is cut around its first difference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:02:21 +08:00
DemonGatanjieuandClaude Opus 5.5 d1c820d931 docs: remove outdated plans and the September audit
docs/plans held task briefs written for other agents, most of them done or
abandoned (the toolbox shortcuts, the Material Library node capture), and
docs/audits a 2026-09-19 report on the old Model Doctor and Material Library.
Read publicly they looked like current plans. GEMINI.md now points front-end work
to AGENTS.md section 4 and docs/architecture/frontend.md; the audits row and the
audits ignore rules are gone. History keeps every file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:50:20 +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 354b8e16a4 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:17:51 +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 26a39f781d docs(agents): read-only routes, read limits and a resource section
Two more security-floor rules from the Edit emotions search: read-only routes follow
rule 3 too (by name or id where possible), and reading user files is capped, with
unreadable or oversized files skipped. A new "资源占用" part: act only on user action
(timers only while their view is open), heavy I/O off the event loop (moved here),
data lives with its view, thumbnails rather than originals. The delivery checklist
asks about paths, read limits and idle cost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:11:59 +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 9d4fe22030 Merge feature/ui-shell: v1.58.0 Beta
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:52:05 +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 314dc1cea4 Clicks on a scrolled combo or recipe page reach the header again
Once the combo editor or a recipe's details was scrolled, Chrome hit-tested content
that had scrolled out of view as still sitting under the header: "‹ All combos" hit
a LoRA tile, and the recipe's Back and tabs hit its cover. The combo tiles lose
content-visibility: auto (their off-screen boxes kept taking clicks); the recipe
detail's scroll box gets clip-path: inset(0), which Chrome also applies to hit tests.
Found with a sweep that scrolls every scroll box of each page in steps and checks
each visible button still gets its own click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:26:44 +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
DemonGatanjieuandClaude Opus 5.5 59b46f07f9 chore(release): v1.58.0 Beta
- CHANGELOG: Unreleased becomes v1.58.0 Beta (2026-10-09), its entries grouped by
  subject (layout, keeping results, prompts and Current node, Model Check, scanning,
  deleting, memory, activity log, voices, fixes); no entry dropped. Stale wording
  fixed: the rail's real order (no toolbox), no material workspace, no help page,
  Activity canvas entries do have Undo, combos instead of prompt notes.
- README: changelog badge v1.58.0 Beta.
- What's new guide: the five steps are the release's main changes (rail and Home,
  ☆ keep and combos, Prompt Studio, Model Check, voices); the model-types,
  activity and memory steps are gone (Activity is named in the layout step).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:25:59 +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
DemonGatanjieuandClaude Opus 5.5 1e1555ff27 docs(agents): security floor from the ComfyUI-Manager review
No code from downloads or user files runs; downloads are checked by
SHA-256; routes take no arbitrary local paths; routes that write, move,
delete or go online on the user's behalf answer only this computer;
deletions go to the Recycle Bin and nothing is overwritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:51:30 +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
DemonGatanjieuandClaude Opus 5.5 2eb35f113c docs(tts): the folder rules no longer mirror the node's removed browse.py
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
DemonGatanjieuandClaude Opus 5.5 e4d351a44e fix(tts): ask GPT-SoVITS again before refusing an import
The voices page keeps the node's status for a minute. On a fresh install
the first status said the storage place (models/gpt_sovits) was missing,
so "Import character" refused with "存放位置不能写入" until the cached
answer expired, although the folder existed by then. Before refusing, the
import asks for the status once more. (Anomalous_TTS now also creates the
folder when it starts.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:12:21 +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 888e7c8dc4 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:32:59 +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
Claude fe8e08d8cf fix(tts): follow Anomalous_TTS interface 13, no local paths
Anomalous_TTS no longer takes a folder or file path on this computer from
a request (ComfyUI-Manager review of the node): the storage place, other
character folders and pretrained sources are set only in its settings
file, and imported files are always uploaded.

- GPT-SoVITS settings shows the storage place, other places and package
  sources without change, move or remove buttons, and names the node's
  settings file (status.settings_file) with a copy button and how to edit it.
- The import window drops "from this computer": no server path picker
  (ui_tts_path_picker.js is gone), no local folder scan or local clip
  preview; a very large upload is confirmed instead.
- tts_setup_api.js loses the removed routes; formatSize moves there.
- The storage-change activity entry, unused locale keys and picker CSS go.

Verified: tools/check_structure.mjs; tts_setup_api in Node with fetch
stubbed; the settings card, batch hero, add menu, file sources and player
rendered with jsdom and stubbed ComfyUI scripts. Not run in ComfyUI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018P2T2R7TFbJsc3C9maCD8B
2026-10-08 20:21:50 +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 d8e12b8a59 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 02:23:01 +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 afe73258d5 docs(readme): pictures of each feature and how it is used
Seven short "see it in action" steps in English and Chinese, each with a
still or a few-second animation (WebP, about 500 KB in all) from the
isolated demo ComfyUI: scan results, Model Check putting models back,
gallery search and drag to canvas, a combo dragged onto the canvas,
Prompt Studio tags, the activity log with Undo, character voices; the
model cover wall under the title. .gitignore keeps docs/images/*.webp.
The Activity row no longer says it only records: canvas changes have Undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:42:40 +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
DemonGatanjieuandClaude Opus 5.5 0c528cc281 Every drop onto the canvas becomes a ComfyUI history step
Audit of canvas writes for the Model Check gap: every path records a step except
a character voice dropped on a TTS node (audio_voice_drag.js), where a reload
right after the drop brought the old voice back. material_drag.js now records
after any drop handler finishes, so no drop target can miss it; handlers that
already record add no extra step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:42:31 +08:00
DemonGatanjieuandClaude Opus 5.5 517e7efaf2 Model Check put-ins become a ComfyUI history step
ComfyUI was not told about a model put in by Model Check, so reloading the page
right after Put in (or Ctrl+Z) brought back the old, missing name and its red
mark. A single put-in now records one step, and putting in several records one
step for all of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:33:55 +08:00
DemonGatanjieuandClaude Opus 5.5 6c8d3e8eb4 Model Check bar: "Don't show again" and a check-on-open switch
- The bar starts with "Model Check:" so it is clear it comes from the browser.
- "Don't show again" turns the check on opening off and says where to turn it
  back on; Settings → Workflows has the same switch (on by default), and turning
  it on checks the open workflow right away. While off, the bar never shows.
- The tour's Model Check step mentions the button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:27:24 +08:00
DemonGatanjieuandClaude Opus 5.5 01dcdf37a9 Combo group on the pointer: Esc cancels, prompt nodes always, common negative
- While a combo group follows the pointer, Esc removes it again; the activity log
  holds back until it is put down, so a cancelled group leaves no entry.
- The positive and negative prompt nodes are always laid out (positive empty when
  the combo has no prompt); the negative takes Prompt Studio's common negative card
  (starter cards moved to prompt_composition.js as PROMPT_PRESETS).
- The UNet "connect clip yourself" message shows once the group is down; it used to
  pop up while the group followed the pointer, and closing it put the group down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:09:25 +08:00
DemonGatanjieuandClaude Opus 5.5 7f96afe459 Combos only lay out a new group; replacing into the workflow is gone
"Use" tried to put a combo into the open workflow (main loader, LoRA
chain rebuilt, positive prompt) with a list of what changed and what was
skipped and why, which read as guesswork. A combo is a shortcut: its one
button, Put on canvas (放到画布), and dragging its card both lay out the
main model loader, LoRAs and prompt nodes as a new wired group, and never
change nodes already on the canvas. An existing node's values are changed
in Current node.

notebook_apply.js, the Use confirmation and its strings are removed; the
editor's More menu keeps only Delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:51:26 +08:00
DemonGatanjieuandClaude Opus 5.5 800e51a6b4 Combo Use: a replacing LoRA keeps the canvas's strengths
Rebuilding the LoRA chain kept strengths only for a LoRA the combo
kept; a different LoRA went back to the node's 1.00, silently changing
values the user had set (0.80 became 1.00). Now the new LoRAs take over,
in order, the strengths of the LoRAs they replace. LoRAs added beyond
the old chain start at the node's default, and the confirmation says
which ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:38:43 +08:00
DemonGatanjieuandClaude Opus 5.5 c7a3ce5b1b Combo Use: say only Ctrl+Z undoes a rebuilt LoRA chain
The confirmation always said the activity log could undo Use too, but
the log only undoes changed values; a LoRA chain rebuild removes and
rewires nodes, so the log offers no Undo. When the LoRAs change, the
confirmation now names Ctrl+Z alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:28:48 +08:00
DemonGatanjieuandClaude Opus 5.5 8e05c82cbf Drag combo cards onto the canvas; un-keep from the gallery star
A combo card dropped on a main model loader does Use for that loader
(same confirmation), and on empty canvas builds its new group of nodes
where it was dropped; other nodes refuse it with a hint.

The star's keep menu has Remove (取消收藏) beside each kept row: after a
confirmation the recipe, combo or saved prompt goes to the Recycle Bin
through its own delete route, and the star (and the gallery's cached
list) goes back to empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:31:45 +08:00
DemonGatanjieuandClaude Opus 5.5 7245037315 Tour: go through the pages in the order they are used
Ten steps: the rail as one line, Gallery and what its star keeps,
Workflows, Combos, Prompts, Current node, scanning, Model Check, the
"!" guide and Settings. Steps that still described translation on
Current node and the old scan wizard are rewritten, and the tour's text
moved into locales.js instead of inline Chinese/English pairs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:57:28 +08:00
DemonGatanjieuandClaude Opus 5.5 41c62d8667 Prompt Studio: a ✕ beside the node's name switches to the draft
"Use draft" (改草稿) read as "edit the draft". The node's name now has a ✕
that stops editing it, and the draft is labelled as not in any node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:35:01 +08:00
DemonGatanjieuandClaude Opus 5.5 d00882925c Prompt Studio: meanings in a chosen language, any language to English
Tag meanings are brighter and slightly larger, and the top bar picks
their language (Simplified/Traditional Chinese, Japanese, Korean,
Russian, Spanish, French, German, Portuguese, or none) for every
interface language; the old Chinese switch carries over. Typed text in
any language that is not plain English is translated to English, and
translating a box's foreign tags drops one whose English is already
there. The backend skips DeepL for targets it does not take instead of
falling back to English, and lets MyMemory detect other sources.
Unused prompt helpers in translation_service.js are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:24:04 +08:00
DemonGatanjieuandClaude Opus 5.5 e6de76502a Prompt Studio edits the selected node's prompt boxes as tags
The assembly board, its write-to-node step and the positive/negative tabs
are gone. The studio now shows the prompt boxes of the prompt node last
selected on the canvas (or a draft) as tags that are edited in place:
weight, edit, remove, reorder, type to add with Chinese translated to
English, and an optional Chinese meaning under each tag. Every change is
one node write with Ctrl+Z, and the studio has its own Undo.

Cards: three common ones instead of six, plus saved prompts; a saved
prompt's text is read once instead of every 30 seconds. Current node
lists the prompt boxes with Edit in Prompt Studio instead of its own
translation. The inspector modal, starter-card data, plan loading and
about 2,000 lines of CSS for the old board are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 03:05:08 +08:00
DemonGatanjieuandClaude Opus 5.5 3a55eb08a9 Show a prompt's Chinese meaning in full in Current node
The note used a line clamp with padding, which painted the next line into
the bottom padding. It now shows the whole translation and scrolls when long.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:21:42 +08:00
DemonGatanjieuandClaude Opus 5.5 1d02d5b74d Remove the Material Library page; Prompts opens Prompt Studio
The page held four unrelated things, so each now lives where it is used:
the rail's Prompts entry (and the Ctrl+Shift+L shortcut) opens Prompt
Studio beside the canvas, where saved prompts are managed; the browser
comes back when the studio closes. Saved node values stay in Current
node, which can now delete them; moved whole workflows are no longer
offered there twice. Recent results, combos and whole workflows already
have their own homes.

Deleted ui_materials.js, ui_material_cards.js, ui_material_detail.js,
ui_material_application.js (its receipt is ui_apply_receipt.js) and
material_feedback.js, the recipe page's copy-to-library buttons and the
save_parameter_material route, with about 2,000 lines of CSS and 200 locale
keys. Words that said "material library" now say where things go.
Material files are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:59:35 +08:00
DemonGatanjieuandClaude Opus 5.5 fc4e1a7fd1 Rename, delete and drag saved prompts in Prompt Studio
Library cards in the studio were read-only. Their preview now renames a
saved prompt (tags kept) or deletes it to the Recycle Bin, and any card
can be dragged out of the drawer onto a canvas prompt box (outlined,
previewed, one undo step) or onto empty canvas for a prompt node; over
the drawer the drag passes through to the assembly board.

The preview moved to ui_prompt_card_popover.js (the deck goes from 751 to
480 lines) and the canvas drag is prompt_card_drag.js; material_drag.js
takes passThrough and effectAllowed, prompt_drop.js a known-text drag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:39:44 +08:00
DemonGatanjieuandClaude Opus 5.5 302fe7e6e8 Keep gallery results as a workflow, a combo or prompts
Every PNG in the gallery has a star that asks what to keep: the whole
workflow as a Workflow Recipe, its main model, LoRAs and positive prompt
as a combo (models looked up on this computer, missing ones left out), or
its prompts as a saved prompt. What is kept remembers its image, so
GET /anomalous/kept_images fills the star and the menu opens what was
kept. The image workbench's Keep opens the same menu instead of its name
and tags form. Things are named after the prompt's first tags.

The Material Library's Recent shelf and its route are gone: the gallery
lists the same images. Gallery cards moved to ui_gallery_card.js, and
deleting asks in the usual dialog. Two missing locale keys are fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:32:51 +08:00
DemonGatanjieuandClaude Opus 5.5 074e9e43a0 Make Prompt Notes a Combos page with one Use button
A note was always a main model, LoRAs and a prompt kept together, so it is
now a combo (搭配) with its own rail page and Home card: a card per combo
with its model's cover, search and New; a card opens the editor, Back
returns to the list. Use puts the combo into the open workflow when the
canvas has a main model loader, builds a new group of nodes when it has
none, and asks when several loaders leave it unclear. The notes column,
quick-jump list and the Material Library's notes shelf are gone, with
their CSS and words; note files are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:19:27 +08:00
DemonGatanjieuandClaude Opus 5.5 0f7d6317ad Put Prompt Notes on the Material Library's Prompts page
ui_material_notes.js lists the notes above the saved prompts, each with
its model, LoRAs and prompt, Put into this workflow and New group of
nodes; a card opens the note's editor, which now has a way back to the
library, and New note starts one. The separate Prompt Notes button goes,
and so does the editor's "save to library" card with the route only it
used (save_prompt_note_material): a note is in the library already.
Notes keep their own files; prompt materials saved from notes before
still list and open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:39:38 +08:00
DemonGatanjieuandClaude Opus 5.5 7bea74dd09 Keep whole workflows as recipes, not materials
Recent's star and the image workbench's whole-workflow save now make a
Workflow Recipe with the image as cover (recipe_save.js lays the image's
workflow on a canvas of its own and saves it through the same path as the
canvas save, now shared). The Material Library's "all" lists the pieces
only and loses its Workflow pill. Whole workflows kept as materials
before can be moved from the Workflows page: each becomes a recipe and
its material gains moved_to_recipe, which keeps it out of every listing
while the file stays. Recent marks images kept as recipes and opens them;
partial recipes are "Modules" everywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:20:28 +08:00
DemonGatanjieuandClaude Opus 5.5 dbf2022b06 Put a Prompt Note into the open workflow
notebook_apply.js plans and makes a note's changes to the canvas: the main
loader takes the note's model (same kind only), the LoRA chain right after
it is rebuilt with the note's LoRAs, rewiring MODEL and, for LoraLoader,
CLIP to what the old chain fed (a kept LoRA keeps its strengths), and the
positive prompt box of the sampler that model reaches takes the prompt. A
selected loader or prompt node decides when there are several. The note
editor's "Put into this workflow" shows each change and what is left as
is before writing, as one Ctrl+Z step; "New group of nodes" (formerly
Send to canvas) now wires a UNet note's prompts to the canvas's only CLIP
loader, and says so when there is none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:50:50 +08:00
DemonGatanjieuandClaude Opus 5.5 a93ed042d2 Slim the recipe detail and leave model recovery to Model Check
The overview's models are one compact list (preview, name, node, here
under the saved name or missing, size, note, download page). The recipe's
own match / apply-match flow, identity badges, advanced path and hash
blocks, origin editor and recipe fingerprint are gone; Model Check finds
renamed or moved models once the workflow is on the canvas, and saving
the recipe again keeps it. ui_recipe_model_matching.js becomes
ui_recipe_models.js; ui_recipe_detail.js drops from 776 to 292 lines.
Plain wording for the save dialog, empty page and status; no per-node
save-to-library buttons on the parameters page. Recipe CSS rules for
classes no script uses (107 rules) are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:41:51 +08:00
DemonGatanjieuandClaude Opus 5.5 3593e50e9f Carry model download links in saved and shared workflows
When a workflow is serialized (save, export, image metadata, share code),
a model present here without a link kept in the workflow gets its own
information's link (the editor's, else Civitai's), marked auto so a newer
one replaces it, and links for names no node holds any more are dropped;
the live graph is untouched. So after Model Check swaps in a local model,
the export names that file, its fingerprint and link. Fingerprints now
cover GGUF, .pth and .sft too. The share-code export shows how many models
carry a fingerprint, warns about those without, and can leave links out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:21:43 +08:00
DemonGatanjieuandClaude Opus 5.5 0b13f3b431 Mark fingerprint matches in Model Check as sure
Every match waits for a press, so the fingerprint ones get the top of the
same scale: ●●● sure (green), and ●●○ likely moves to the accent colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:56:39 +08:00
DemonGatanjieuandClaude Opus 5.5 012c5ea8b3 Show how sure each Model Check match is
Next to the model's name: ●●○ likely (same name and size), ●○○ maybe
(same name or same size), ○○○ unclear / does not match / not found.
Fingerprint matches carry none; their button already says it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:54:36 +08:00
DemonGatanjieuandClaude Opus 5.5 e04fa66b0d Fix Model Check hashing same-size files inside ComfyUI
model_resolution imported calculate_sha256 and the header inference from
scraper.py, which only works when the plugin folder is on sys.path (the
tests); inside ComfyUI the import failed, so a renamed model the scan had
not hashed was reported missing and the route answered 500. Both
functions now live in model_identity.py, which the API and the scanner
already share; scraper.py keeps its printing wrapper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:46:57 +08:00
DemonGatanjieuandClaude Opus 5.5 4cfff5382e Make Model Check offer same-name files as candidates, not as the same file
For a workflow without fingerprints, a missing ordinary model took the
local same-name file's hash as its evidence, so it showed as "the same
file under another name" and counted among those put in at once. Only the
workflow's own record is evidence now; the one same-name file the node can
load is a candidate, and a size candidate with the same name is shown as
likely, first. Foundation components still go by fingerprint only. Model
Check also reads GGUF values and resolves upscale models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:23:05 +08:00
DemonGatanjieuandClaude Opus 5.5 2104bb659f List GGUF, .pth, .sft and .bin models, in any case
The Models page, the folder counts, search and lookups only matched
.safetensors, .ckpt and .pt in lower case, so quantized GGUF models and
most upscalers never showed. One list, MODEL_EXTENSIONS, and
is_model_file() now decide what a model file is everywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:51:46 +08:00
DemonGatanjieuandClaude Opus 5.5 81787cf139 Make model folder settings a readable view of the settings page
Folder management was a pop-up listing ComfyUI's raw folder names with
eye/cross emoji, "Category mode / Physical mode" radios and a Save and
reload button. It is now a view of the settings page with Back: grouping as
two plain choices (by model type, by folders on disk), the shown types
first with their usual names, folder names, a switch and up/down, then the
ones that are off, the rarely used ComfyUI folders folded away. Each change
is saved at once and the folder list reloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:50:08 +08:00
DemonGatanjieuandClaude Opus 5.5 915feff820 Drop the help manual pop-up for a tour, what's new and the full guide
The manual was a long block of text that had fallen behind the window (it
still called recipes and materials "in testing") and was reachable only
from Settings. Pages explain themselves, Home lists what you can do and !
shows what changed, so Settings -> Help and feedback now offers the
interface tour, what's new, the full guide (the README on GitHub, at its
Chinese half in Chinese) and feedback. Removes ui_help.js, its strings and
styles, and the recipe "beta" notice code that no longer had an element.
The README's backup note no longer calls the features a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:43:31 +08:00
DemonGatanjieuandClaude Opus 5.5 c86d48faa2 Write feedback in a small window first, then send it to GitHub
The prefilled issue looked like a form to fear: raw headings in two
languages, an HTML comment and a block of technical lines. Report a problem
and Suggest something now open a window with one text box (a sentence or
two, any language); Submit on GitHub opens the issue with that text, titled
from its first words, in the browser's language, and the environment folded
in <details> at the end when ticked (it can be looked at first). The issue
forms ask just what went wrong, with optional screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:32:33 +08:00
DemonGatanjieuandClaude Opus 5.5 5b82452514 Make feedback easy: report a problem or an idea on GitHub
Home ends with "Found a problem, or have an idea?" and Settings has the
same under Help and feedback. Report a problem / Suggest something open a
new GitHub issue with headings and the environment written in (plugin and
ComfyUI versions, Python/PyTorch, GPU and memory, OS, browser, page and
language; never a path or ComfyUI's command line); Copy diagnostics copies
it for other channels (feedback.js). Bilingual issue forms for problems and
suggestions are added under .github/ISSUE_TEMPLATE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:21:32 +08:00
DemonGatanjieuandClaude Opus 5.5 00db5c6fc5 Follow a ComfyUI language change when the browser opens
With the language on "Follow ComfyUI" (the default), the browser read
ComfyUI's language only when the page loaded, and ComfyUI switches its
language without a reload. Opening the browser now takes up the current
ComfyUI language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:13:57 +08:00
DemonGatanjieuandClaude Opus 5.5 a8b75d33a6 Faster model cards, video posters, and a settings page
Card thumbnails lived in ComfyUI's temp folder, which ComfyUI empties on
every start, so each first visit after a restart remade them all. They now
live in <user>/anomalous/cache/card_thumbnails (still capped at 256 MB).
Listing a model type queues the missing ones for one background thread;
the first screen of cards loads before the rest. A video cover gets a
first-frame poster (PyAV) and fetches nothing until it plays; hover-play
is now the default, as the setting already recommended.

The gear opens a settings page instead of a pop-up with a dialog inside:
look and language, model cards and memory with the thumbnail cache (size,
empty), folders, workflow fingerprints, opening mode and window, help.
Display preferences live behind owner.displayPrefs. Also removes a dead
overlay rule that shared the new page's id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:00:47 +08:00
DemonGatanjieuandClaude Opus 5.5 87aa9a1ac2 Put Voice-over back on the Voices page, with calmer settings and cards
Voices has two views switched at the top, Characters and Voice-over, sharing
the character list (clicking a character on Voice-over makes it speak); the
rail's Voices entry reopens the view used last, and the separate Voice-over
rail entry is gone. Generation settings: language as choices and speed as a
slider in view, the sampling numbers folded under fine-tuning with plain
names, no sideways scroll. A finished take says how long it took and links
to the audio gallery. Line cards show editing buttons on hover, the emotion's
reference clip sits beside its chips, and a character with only its main
voice shows no chips. The audio gallery shows each file's lines once and
can open them on Voice-over for another take; the list no longer repeats
the gallery. Also fixes the audio list staying empty after a reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:06:45 +08:00
DemonGatanjieuandClaude Opus 5.5 65ffa602e2 Give voice-over its own page
The Script Director was a drawer behind one of four equal buttons on
Voices, over the character cards, and its name did not say what it does.
It is now Voice-over (配音), a rail page between Voices and Audio
(ui_script_page.js mounts the director); each character card has a
Voice-over button that opens it with that character chosen, and Home's
"Give a character a voice" goes there. Generate is the main button;
pushing, dragging and copying sit under "Into your own workflow", and a
push or drop is one Ctrl+Z step. Plainer words throughout; the Voices
page keeps import, refresh and search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:19:08 +08:00
DemonGatanjieuandClaude Opus 5.5 cab02179e8 Turn the scan page's Advanced fold into a Scan settings page
The fold-out section did not match the rest of the page, which now opens
everything as a page of its own. The scan page shows a card saying how the
next scan goes (all or picked models, online or offline, renaming files and
so on, the ones that change files in yellow); it opens Scan settings, a page
with Back and one card per question.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:07:39 +08:00
DemonGatanjieuandClaude Opus 5.5 c863dacf36 Lay out the scan page's Advanced section like settings
Which models is two choices side by side; every option is a row with a
switch on the right; renaming files and fetching again carry a short tag
instead of whole paragraphs in yellow; the online-only options sit under
their own heading and fade for an offline scan; the API key row keeps its
button on one line. The folded title lists the choices that differ from
the usual.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:47:20 +08:00
DemonGatanjieuandClaude Opus 5.5 718cc0be70 Give the scan page's lists pages of their own
The scan page keeps only the counts, the scan button, the last scan in one
card and Advanced. Not found, not scanned yet, other formats and the last
scan's models each open a page with Back, why those models are there, one
button for the whole list and the rows; a model opened from a list returns
to it where it was scrolled. "Show the result" after a scan opens the last
scan's page. Also fixes "formerly Model Check" in the changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:38:15 +08:00
DemonGatanjieuandClaude Opus 5.5 20424e4428 Add a Recent shelf to the Material Library: one star keeps a result
Saving parameters asked people to decide, then save, while busy
generating. The library's first category, Recent, now lists the newest
output PNGs that carry their workflow, each with the start of its positive
prompt, the main model and steps / CFG / sampler / size (GET
/anomalous/recent_generations, read from PNG text chunks and cached). A star
keeps one as a whole-workflow material through the existing
save_image_material route, named after the prompt's first tags; a kept
image shows a filled star (matched by the image's SHA256, as the save
route's duplicate check does) and opens its material. The picture opens the
image workbench for keeping single nodes; the search box searches Recent
too. The empty-library guide points here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:53:13 +08:00
DemonGatanjieuandClaude Opus 5.5 4dd183ead4 Make Model Sources a view of the Models page
The Sources chip at the end of the model types now shows, in place of the
cards, where each model is downloaded: for this workflow or all models,
filtered by with / without a link, searchable. Each row's link can be
typed or pasted (any site), found, opened and saved with one button; a
link goes to the model's own information (the editor's download link) and,
for the workflow's models, into the workflow too, as one Ctrl+Z step. The
canvas note and the clipboard list stay. Model Check's "Where to download"
opens the view on this workflow.

The pop-up goes, with its lock-then-edit links and four ways of saving;
ui_model_sources.js (815 lines) splits into the view and
model_source_links.js (data and actions). Old hub CSS and locale keys go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:41:42 +08:00
DemonGatanjieuandClaude Opus 5.5 d379581853 Rename Model Doctor to Model Check
"Doctor" said little about what it does: check the opened workflow's
models and put back the ones this computer has. The rail, page, home card,
help, tour, README, the unreleased changelog and the architecture docs now
say Model Check (模型检查); the help paragraph now describes the check as
it works today. Code, CSS and locale keys keep "doctor"; released changelog
entries, audits and decisions keep the old name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:32:33 +08:00
DemonGatanjieuandClaude Opus 5.5 054cb91b9b style: one palette for every page, from the theme tokens
Pages made at different times used their own greys (slate here, neutral
there) and accents (sky, violet, indigo), and most ignored the Abyssal
Scarlet theme. About 1,850 literal colours in web/styles and in CSS and
inline styles written from JS now use the --amb-* tokens by what they are
for: surfaces, text (new --amb-text-soft and --amb-text-dim), borders (new
--amb-border-strong), the primary button, and one accent (new --amb-link,
tinted with color-mix). Both themes define the new tokens.

Literal colours stay where the colour is the meaning: status, prompt
roles, voice emotions, canvas node colours, shadows and overlays; theme
override rules are untouched. The share-code dialogs move from inline
styles to .anomalous-share-* classes. Stylesheet versions are bumped.

Checked: text contrast on every rail page, Prompt Studio, Prompt Notes,
Model Sources, settings, model detail and editor, share codes, in both
themes; structure check and JS tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:19:55 +08:00
DemonGatanjieuandClaude Opus 5.5 51b7113f5c feat(drag): preview a drop before release
While a prompt is over a prompt box, the text a release writes is laid
over that box and the opposite-role box on the same sampler; leaving the
box, the canvas or the drag removes it. The overlay never touches the
widget's value.

Over a node of a type the material holds values for, the hint lists what
would change ("steps 12 -> 28"), as applyMaterialBlock does it: seeds stay,
and a choice this computer lacks is named. Every dragged material is now
fetched when its drag starts (prepareMaterialDrag).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:05:43 +08:00
DemonGatanjieuandClaude Opus 5.5 a2a11ca2cf feat(scan): the scan page says in plain words why a model is not there
The lead says what a scan does (the file's fingerprint looked up on
Civitai). "Not found" lists the usual reasons (not published on Civitai,
as voice and upscale models mostly are; trained or merged; changed after
download) and that the model works as usual. A not-found model that is no
known image model says it may be a voice or upscale model.

The summary also lists model files in other formats (.pth, .ckpt, .bin...),
which scans never read, outside hidden folders; the page shows them under
"Other formats" with why, only when there are some.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:00:08 +08:00
DemonGatanjieuandClaude Opus 5.5 4146872765 fix(materials): the top bar wraps instead of cutting off its buttons
Docked to the side (660px) the Material Library's top bar was 153px too
wide: Prompt Studio was half hidden, Prompt Notes and refresh not shown.
The bar now wraps; the buttons go to a second row, right-aligned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:52:07 +08:00
DemonGatanjieuandClaude Opus 5.5 5492d4a5e1 feat(undo): every canvas write is a Ctrl+Z step; undo from the toast and the activity log
ComfyUI takes undo steps on mouse-up and key-up; a drag-and-drop ends in
neither, and writes made in click handlers or after a fetch landed in no
step, so Ctrl+Z could undo the step before instead. canvas_history.js asks
for a step after each write (applied values, drops, created or inserted
nodes, recipes, prompt notes).

A drop's receipt also pops up at the top right with Undo (shared with the
receipt's own button); prompt nodes made by a drop on empty canvas can be
undone there. canvas_undo.js keeps what each canvas activity entry did, in
full, while the page is open; the log offers Undo while nothing changed it
since. Entries that removed nodes or rewired existing ones, seeds, and
choices this computer no longer has are never offered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:48:14 +08:00
DemonGatanjieuandClaude Opus 5.5 264b098b34 docs(tour): describe the doctor, Current node and material drags as they work now
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:22:10 +08:00
DemonGatanjieuandClaude Opus 5.5 307381c439 feat(shell): every tool opens from its page; the toolbox goes
The rail keeps scan, Model Doctor and Current node (ui_rail_tools.js,
which replaces ui_toolbox.js). The tools the toolbox held now sit where
their things are:

- Workflow share codes (AMB0/AMB1 import / export): the Workflows page's
  top bar, in place of the disabled "import" button.
- Prompt Studio and Prompt Notes: the Material Library's top bar.
- Model Sources: the end of the type chips on Models (library) and
  "where to download" in Model Doctor (the open workflow).

Removed with it: the toolbox popover, its tool catalog and custom-item
hook (no plugin used it), the generic tool dispatch (Home opens the doctor
directly), the tour step, its styles and the toolbox and old "planned
tools" strings. The scan button's tooltip no longer says "wizard".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:20:28 +08:00
DemonGatanjieuandClaude Opus 5.5 96736560af feat(node): translate prompts on the node; the separate translator goes
Current node lists the selected node's prompt boxes (prompt_boxes.js, so
each is marked positive or negative by its wiring). Chinese text gets
"translate to English", written back only if the box still holds the same
text when the translation arrives (one undo); English text can show its
Chinese meaning without writing.

The standalone Prompt Translator (ui_prompt_translator.js, 838 lines, its
four fallback ways of guessing a text box, its styles and its toolbox
entry) is removed, with Prompt Studio's appendPromptToStudio that only it
used. Prompt Studio keeps its own translate buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:14:05 +08:00
DemonGatanjieuandClaude Opus 5.5 a173e4e561 feat(node): Current node panel with one list of saved parameters
The Node Assistant becomes "Current node" (当前节点): one page instead of the
"actions" / "parameter sets" tabs, model actions on top, Parameters below.

- node_parameter_sets.js: the node type's saved values from the material
  library and from recipes' parameter sets as one list; the same values
  saved in both are one entry naming both sources. For the selected node,
  exactly what an entry would change; seeds (and their after-run control),
  model files and choices this computer lacks are kept.
- ui_node_parameters.js: the list, "apply these N" (one undo in the
  receipt) and "save these values".
- api/node_material.py: POST /anomalous/save_node_material keeps one
  node's values as a node_parameter_selection material, deduplicated by
  type and values; the library lists it under parameters.
- Removed: ui_node_presets.js with its hover popovers, the beta notice and
  their styles and strings. The panel and help texts use the new name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:09:49 +08:00
DemonGatanjieuandClaude Opus 5.5 86d165b891 feat(prompts): a dragged prompt shows where it goes before release
While a prompt material is dragged, every prompt box on the canvas is
outlined by role (green positive, red negative, grey unwired) and the box
under the pointer is the target. The hint says what release writes where,
including the opposite-role box on the same sampler, which is filled with
the material's other side. A node with several boxes needs the pointer on
one; a node without a box says so instead of falling through to "new node".

- prompt_drop.js: outlines, target box, hint; the material is fetched when
  the drag starts so the hint knows which sides it has.
- material_drag.js: accepts/targetHint/rejectHint/drop get the event;
  onStart/onMove hooks; a node's refusal beats the blank-canvas action.
- Dropping a prompt plan on empty canvas created an empty node (summaries
  carry no text); it now fetches the plan and creates one node per side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 05:57:12 +08:00
DemonGatanjieuandClaude Opus 5.5 4abb0100a7 feat(prompts): find prompt boxes by node definition and wiring
One module, prompt_boxes.js, now decides which boxes of a live node take a
prompt and whether each is positive or negative. A prompt box is a
multiline STRING input by the node's ComfyUI definition, not a widget
whose name looks right; its role is its own name (positive / negative) or
the wiring down to a sampler's positive / negative input, not the title.

- Material drops, Prompt Studio, the translator and recipe capture use it;
  their name regexes and fallbacks ("any string widget") are gone.
- Prompt Studio's role detection read graph.links[id], which is a Map in
  current ComfyUI, so it never followed a link; it uses the module now.
- Material writes call onWidgetChanged(name, ...) as ComfyUI expects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 05:46:21 +08:00
DemonGatanjieuandClaude Opus 5.5 715b8b7271 docs: call the scanning section what it is now, not the removed deep scan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 04:38:20 +08:00
DemonGatanjieuandClaude Opus 5.5 2358649f03 feat(doctor): Model Doctor checks each opened workflow and says what to do
A bar over the canvas appears when an opened workflow uses models this
computer does not have: how many, and how many are here under another
name or folder (same hash), with one press to put those in. The doctor
page is redone on the scan page's layout: what needs you first, why in
plain words, and a button per row (put in, use a same-size file, pick
one, look it up on Civitai); models that are fine fold away.

- model_check.js: the check (no DOM, changes nothing) and the fixes,
  which call the node's widget hooks so ComfyUI drops its red mark;
  the node picker does the same now.
- Opening the doctor no longer rewrites the workflow by itself.
- Removed: the auto-detect switch (nothing read it), the doctor's own
  deep hash scan and its routes (the resolver hashes same-size files on
  demand; the page points to the scan page), the hash dialog, the old
  resolver in hash_resolver.js, the unused pick-for-node path in
  ui_detail.js, 45 old strings.
- Node Assistant's diagnoseNode and LoRA insertion moved to
  ui_node_assistant.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 03:36:41 +08:00
DemonGatanjieuandClaude Opus 5.5 e466e9dfcf feat(scan): list unmatched and unscanned models, show each scan's result, explain why
The scan page now says which models are unmatched or not scanned yet (type, folder,
why Civitai had nothing) and what each scan did, model by model; rows open the model,
scan it, look it up again or search Civitai. The progress box shows the counts and,
when floating, a way to the result. The activity log records a scan when it ends with
its result, and model edits (from -> to) and deletions (files moved) in detail.

- scraper.py reports one JSON line per changed model (--report-file); a failing model
  no longer ends the scan. Unmatched info keeps why (not_found / network / offline);
  online scans look network/offline ones up again. After two models in a row without
  an answer the rest of the scan infers from the files (--civitai-down for the next
  folders) instead of waiting out every timeout.
- civitai_client.py: the network side moved out of scraper.py; tells a 404 from
  Civitai being unreachable (CivitaiUnreachable).
- api/scan_report.py: ScanJob turns a scan's reports into one result, kept as the last
  scan (/anomalous/last_scan) and logged; /anomalous/scan_model opens a listed model.
- /anomalous/scan_all takes picked or listed models as `targets`, so one scan has one
  result; scan_summary lists unmatched and new models with their reason.
- Activity middleware takes details a handler attaches (request[ACTIVITY_DETAIL]).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 02:52:17 +08:00
DemonGatanjieuandClaude Opus 5.5 e689cc3dac chore: restore consistent CRLF line endings in six files
Edits in the last commits left some LF-only lines in files the repository
keeps with CRLF. Line endings only; no content change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:57:06 +08:00
DemonGatanjieuandClaude Opus 5.5 e647ad03b1 feat(models): mark where a model's information came from; fix the locale file; check syntax
Cards look as before for models matched on Civitai. Only the exceptions are
marked: a base model inferred from the file shows "≈ <base>" with a dashed
outline, and a .safetensors model not scanned yet says so; both explain
themselves in a tooltip (model_source.js). The detail header's line adds
the source and the fields the user edited; the green title that meant
"renamed" gives way to it (ui_detail.js 755 -> 752 lines).

Fix: the previous commit's locales.js had raw line breaks inside the
"fetch again" confirmation strings, so the plugin did not load. Neither
`node --check` (it passes such .js files on Node 24) nor the structure
check noticed. check_structure.mjs now compiles every web file as the ES
module the browser loads and fails on a parse error; AGENTS.md says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:56:40 +08:00
DemonGatanjieuandClaude Opus 5.5 00ea3f78c4 feat(models): the user's edits are a layer of their own; scans keep the user's covers
A model's information now has three layers, the user's first:
<model>.anomalous.json (written only by the model editor), the scan result
(<model>.info, or another tool's .civitai.info with Civitai ids; id -1 =
inferred from the file) and the file itself. get_metadata reads the user
layer last, so it wins (an emptied field stays empty), and reports
info_source (civitai / local / "") and user_fields. Edits saved by earlier
versions in .civitai.info keep working.

- The editor writes .anomalous.json (atomically) instead of mixing edits
  into .civitai.info, which other tools rewrite.
- scan_info_path: a .civitai.info holding only old edits is not a scan
  result. Editing a never-scanned model used to make scans skip it for good.
- Covers: a cover is Civitai's only while byte-identical to
  <model>.civitai_bak.*; any other cover is the user's and is never
  replaced. "Fetch again" updates only Civitai's own cover, keeps names,
  notes and covers, and asks before starting.
- A scan no longer downloads covers and model pages again for models whose
  information is already on disk (it did for every model, every scan).
- The earlier "keep the editor's name" change in the scraper is reverted:
  editor names never lived in .info; the CHANGELOG entry is corrected.
- .anomalous.json moves and goes to the Recycle Bin with its model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:49:10 +08:00
DemonGatanjieuandClaude Opus 5.5 a2e3077726 feat(files): deleting the user's files goes to the Recycle Bin
recycle_bin.move_to_trash is now the only way the plugin deletes the
user's files: SHFileOperationW with FOF_ALLOWUNDO on fixed Windows drives
(send2trash or gio elsewhere). Where there is no Recycle Bin (USB stick,
network share) it raises TrashUnavailable and nothing is deleted; the API
says so (api/trash.trash_failure), including "file in use".

Routed through it: model delete (model + sidecars in one operation, or
nothing), cover replacement and cover reset, output images, generated
audio, recipes with history and images, materials with images, prompt
notes, parameter notes, the duplicate copy a physical rename finds and the
covers a forced rescan replaces. Temp files, caches and the bounded recipe
history are still removed directly.

The unused POST /anomalous/clean_civitai_info, which erased every
.civitai.info in the library (with the names users set), is removed with
its activity-log label. Delete confirmations say where things go. Tests use
a fake Recycle Bin (none of their files reach the real one).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:42:49 +08:00
DemonGatanjieuandClaude Opus 5.5 fb21e6c6e9 feat(scan): the scan page, scan runner and scanner changes (completes 5af9b6d)
5af9b6d was committed with only the removal of ui_scan_wizard.js staged;
this adds everything its message describes, so the branch works again:

- ui_scan_page.js / scan_runner.js / 18-scan-page.css: the scan page with
  matched / unmatched / not-scanned counts, one scan button, "look up the
  unmatched models again", progress in the page, Advanced options.
- api/scan_summary.py: GET /anomalous/scan_summary.
- scraper.py: --retry-unmatched (saved hash when the file is unchanged,
  covers kept); display names set in the editor survive scans.
- api/scanner.py: one request-to-switch mapping (_scraper_flags) and one
  cache-clearing helper for both scan routes.
- model_identity.py: sidecar_info / is_unmatched shared by scraper and API.
- Shell wiring (rail, Home, panel lists, close), the provenance switch in
  Settings -> Model settings, locale keys, docs and CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:14:54 +08:00
DemonGatanjieuandClaude Opus 5.5 5af9b6d260 feat(scan): a scan page replaces the scan wizard; unmatched models can be looked up again
The rail's scan button and Home's "Scan model folders" open a tool page
instead of the seven-switch dialog: counts of models matched on Civitai,
not matched and not scanned yet (GET /anomalous/scan_summary), one button
to scan, progress inside the page (scan_progress.js hosts its panel there;
it floats again when the page or the browser is closed). Offline, renaming
files, fetching everything again, picking models and the API key (with its
set / not set state) are under Advanced; the two risky switches reset after
each scan. The provenance switch moved to Settings -> Model settings.

Scanner:
- --retry-unmatched: only models whose info was written from the file alone
  (Civitai had no match or could not be reached) are looked up again, with
  the saved hash when the file is unchanged and without replacing covers.
  Before, such models stayed local-only unless every model was refetched,
  which also deleted custom covers.
- A display name set in the model editor is no longer replaced by Civitai's
  name on each scan or when the info is fetched again.
- Both scan routes map request fields through _scraper_flags; folder cache
  clearing is one helper (scanner.py 602 -> 579 lines).
- sidecar_info / is_unmatched live in model_identity.py for scraper and API.

ui_scan_wizard.js is gone: scan_runner.js starts and follows scans,
ui_scan_page.js renders the page; 36 wizard-only locale keys removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 01:14:32 +08:00
DemonGatanjieuandClaude Opus 5.5 f13b725d97 feat(audio): open a character from the TTS node; install card links the published pack
- window.anomalous_open_voice(character) opens the browser on that
  character's voice card (browser.openVoice); the Anomalous_TTS node's
  character menu calls it.
- voiceGroupKey() is the one place that builds a GPT-SoVITS group key.
- The install card always says how to install (ComfyUI Manager or the
  project page) and links the Anomalous_TTS repository, which is going
  public; the "not published yet" text is gone.
- The voice card drag hint no longer promises a tag button on every row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:52:49 +08:00
DemonGatanjieuandClaude Opus 5.5 8c1ad49e25 docs(copy): README, help and the "!" guide describe the rail layout
The README, the help page and the image-side "!" guide still described the
old top tabs and bottom bar and an earlier release's features. They now
cover the rail, Home, model type chips, the activity log, character voices
(with the optional Anomalous_TTS node pack) and the lighter memory use.

- New update guide id 2026-10-rail-and-voices with five steps; the old
  guide's locale keys (and the never-shown navigation/transfer/library
  ones) are removed.
- Help: the Civitai API key goes in with the key button in the scan wizard,
  not in Settings.
- The audio guide says the tag copy button only exists for characters
  with emotions.
- update-guide.md loses its section on the removed bottom bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:58:10 +08:00
DemonGatanjieuandClaude Opus 5.5 3a221b71d3 feat(models): type chips head the model grid; voice tag buttons say what they copy
The models page starts with a chip per models folder (Checkpoint, LoRA, ...)
and its count. A chip lists the whole folder with its subfolders
(GET /anomalous/type_models); a folder picked in the list narrows the grid
to that folder, shown as a crumb whose x goes back to the whole type. The
chosen type is remembered.

owner.modelScope now says what the grid lists; currentType/PathIdx/Subfolder
stay the folder of the model at hand, set from each card (focusModel), so
the editor, the scanner and "add to canvas" get the right subfolder.

Voice clips: the copy button reads "Copy {tag}" with a tooltip on what
pasting it does, and is gone for characters with only a main voice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:09:40 +08:00
DemonGatanjieuandClaude Opus 5.5 230566b864 perf(gallery): show thumbnails in the output gallery; release its images when closed
The gallery grid loaded every output at full size: a 1024x1536 PNG decodes to
about 6 MB in the browser, so a few pages of outputs held hundreds of MB to
GBs of browser memory and graphics memory. Cards now use a cached 512 px WebP
from /anomalous/output_thumbnail (the model cards' thumbnail cache; about
0.7 MB decoded); dragging to the canvas, the viewer and the workbench still use
the original. When the browser has been closed for 90 s its gallery images are
released like the model grid, and reopening on the gallery reloads them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:39:48 +08:00
DemonGatanjieuandClaude Opus 5.5 9b0b95ff22 fix(shell): the narrow-window list is a drawer; cap the activity log at 2 MB
In a narrow or docked window the open list now sits against the rail over a
dimmed page, and closes when a folder or character is picked, when the dimmed
page is pressed (that press does not reach the cards) or on Esc. Wide windows
are unchanged.

The activity log also drops its oldest entries past 2 MB, so long canvas
entries cannot outgrow the JSON writer's limit and stop the log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:14:28 +08:00
DemonGatanjieuandClaude Opus 5.5 376284e647 feat(activity): log what Anomalous changed on the canvas and in files
A new Activity page on the rail (and the home page's recent list) shows, by day,
what the plugin changed. Record only; undo comes later (AD-018).

- api/activity_log.py keeps the newest 500 entries in the ComfyUI user folder.
  A middleware records this plugin's and Anomalous_TTS's write requests after
  they succeed (including the 200 + {"status": "error"} style as failure), from
  one route table; the log never fails the request it records.
- activity_canvas.js: a press inside Anomalous snapshots the canvas; the next
  press or key outside it (or the next press inside) logs the difference, so
  every button, drag and dialog that writes nodes is covered and the user's own
  edits are not. Opening another workflow is logged as that. <html> carries an
  Anomalous class and does not count as the plugin's interface.
- activity_diff.js: pure snapshot and difference; activity_log.js: client and
  wording; ui_activity.js: the page (filters, details, find on canvas, clear)
  and the home list.
- Tests: tests/activity_diff.mjs, tests/test_activity_log.py (local).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:00:28 +08:00
DemonGatanjieuandClaude Opus 5.5 4ade73550c chore(rules): files over 600 lines may only shrink
New files stay under 600 lines; files already over it may only shrink or stay
the same when touched, so they get smaller with everyday work instead of a
dedicated refactor. tools/check_structure.mjs now compares with the last commit
and fails on a new oversized file or a grown one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:48:05 +08:00
DemonGatanjieuandClaude Opus 5.5 46facb9115 feat(shell): icon rail, per-page list column and a home page
The browser now navigates from one narrow icon rail on the left: Home, Models,
Gallery, Workflows, Materials, then Voices and Audio, then scan, Model Doctor,
Node Assistant and the toolbox, with settings at the bottom. It replaces the
header tabs, the image/audio toggle and the tool bar under the folder list, so
the list column holds only a list and can close without hiding a command.

- ui_shell_rail.js builds the rail; ui_shell_nav.js owns goTo(page), which page
  has a list and whether it is open (remembered per page; below 760 px it starts
  closed and overlays), the page title in the header and the page reopened next
  time. Clicking the current page's icon toggles its list (from a model's detail
  it returns to the grid).
- ui_home.js: task cards and first steps; the first open lands there.
- Window drag/resize moved to ui_shell_frame.js and scan polling to
  ui_scan_watch.js; ui_sidebar.js goes from 864 to 464 lines.
- Workspaces (recipes, notes, materials) keep the rail visible; the list steps
  aside while one is open.
- Removed what the rail replaced: sidebar_actions.js (bottom-bar hover labels and
  tooltip bubble), the domain toggle button, their CSS and unused text keys.
- Spotlight tour: the rail step replaces the header-tabs step, cards open to the
  right of rail items, and the audio tour points at the rail's Audio entry.
- Decision record AD-017; architecture map, frontend and update-guide docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:08:16 +08:00
DemonGatanjieuandClaude Opus 5.5 7da4e1d91e fix(audio): the audio guide's tour walks the audio page; suggested folder layout
The "!" guide on the audio page hid its tour banner with the hidden attribute,
but the banner's display:flex overrode it, so the banner showed and started the
image-side tour, whose buttons are not on the audio page. The audio guide now
has its own spotlight steps, and tour steps whose target is not on screen are
skipped. The batch import screen shows a folded example of a folder layout that
sorts cleanly: one folder per character, named after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:32:46 +08:00
DemonGatanjieuandClaude Opus 5.5 805b297a80 Merge main (v1.57.3 hotfix) into feature/audio-engines
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:01:36 +08:00
DemonGatanjieuandClaude Opus 5.5 6331b25241 chore(release): v1.57.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:59:03 +08:00
DemonGatanjieuandClaude Opus 5.5 c8689d56e4 fix: offline scans stay offline, workshop snap dock, 400 on malformed requests
From an outside audit (same change as 32a3719 on feature/audio-engines):
- The scan wizard's offline mode still sent the greyed-out rename and
  overwrite options, and scraper.py fetched Civitai model pages and preview
  media for models whose .info held a Civitai id. Offline now implies no
  media and no model page; the folder scan and scan_all refuse a missing or
  non-object body instead of defaulting to an online scan.
- The Prompt Workshop's snap dock referenced leftSearch from the source deck
  module (ReferenceError on click); the deck now exposes focusSearch().
- translate, delete_model, delete_gallery_image and the image-material
  routes answer malformed bodies with 400 instead of 500.
- Removed six unused imports in ui_doctor.js and 16 duplicate locale keys
  per language (the later, effective entry is kept, so nothing changes on
  screen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:44:04 +08:00
DemonGatanjieuandClaude Opus 5.5 32a3719d97 fix: offline scans stay offline, workshop snap dock, 400 on malformed requests
From an outside audit:
- The scan wizard's offline mode still sent the greyed-out rename and
  overwrite options, and scraper.py fetched Civitai model pages and preview
  media for models whose .info held a Civitai id. Offline now implies no
  media and no model page; the folder scan and scan_all refuse a missing or
  non-object body instead of defaulting to an online scan.
- The Prompt Workshop's snap dock referenced leftSearch from the source deck
  module (ReferenceError on click); the deck now exposes focusSearch().
- translate, delete_model, delete_gallery_image and the image-material
  routes answer malformed bodies with 400 instead of 500.
- Removed six unused imports in ui_doctor.js and 16 duplicate locale keys
  per language (the later, effective entry is kept, so nothing changes on
  screen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:36:07 +08:00
DemonGatanjieuandClaude Opus 5.5 a083cfdc54 feat(audio): wider Script Director with a generate column, full cover when docked
The drawer grows to 1000px. From 760px wide the line cards take the full
height and the generation settings, generate bar, result and canvas buttons
sit in a 320px column beside them. When narrow (docked browser) the drawer
covers the studio column and keeps generating below the cards; only the
cards scroll in either layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:17:42 +08:00
DemonGatanjieuandClaude Opus 5.5 21d8ae5800 feat(audio): Script Director keeps one scroll area and a short bottom bar
Long scripts squeezed the line cards between the list and a footer that
scrolled on its own. Generation settings (language, speed, sampling) are
now one folded line above the cards that scrolls with them, and the footer
is a short bar: generate / retake / cancel, the result and the canvas
buttons. The fold state is remembered per browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:09:53 +08:00
DemonGatanjieuandClaude Opus 5.5 721a006b16 refactor(audio): drop F5-TTS, the audio page manages GPT-SoVITS only
The F5-TTS voice library (input/F5-TTS scanning, voice upload with ffmpeg
conversion, romanization, Load Audio clip drags) and the engine switch are
removed. The audio page now works with one engine, Anomalous_TTS
(GPT-SoVITS): characters, drags onto its node, the Script Director with
direct generation, pronunciation and settings. The audio gallery and its
stream route stay (output and temp folders only). The root PATH hack that
existed for F5-TTS's ffmpeg lookup is gone. Recorded as AD-016.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:53:07 +08:00
DemonGatanjieuandClaude Opus 5.5 6cb9ea39be feat(audio): pronunciation table for GPT-SoVITS characters
"Pronunciation" on a character card edits the node's `replace` setting
(script text -> what the character reads). Each row can be heard as the
character reads it now (the saved table applies) and as replaced, through
a Preview Audio run that stays out of the audio gallery; one preview at a
time, cancelled when the dialog closes. The file is re-read before editing
and every other settings field is kept. buildTtsPrompt gains `preview`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:41:44 +08:00
DemonGatanjieuandClaude Opus 5.5 e341b8a26d feat(audio): sampling parameters in the Script Director, roomier drawer, no audio workflow tab
- Generate section: top_k / top_p / temperature / repetition_penalty folded
  under "Advanced", ranges and defaults read from the node's /object_info;
  "save as this character's default" keeps language, speed and these (only
  values that differ from the node's defaults). The seed is not saved.
- Drawer 560px wide, line cards keep 180px, footer scrolls; for GPT-SoVITS
  the bundle preview is hidden and the canvas buttons are compact.
- The audio domain no longer shows the "Audio Workflows" header tab (the
  recipe library filtered to "audio"); a `hidden` header button now really
  hides despite the header's display !important rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 02:16:07 +08:00
DemonGatanjieuandClaude Opus 5.5 ff726db2f6 feat(audio): generate GPT-SoVITS scripts in the Script Director, drop the Arona template
The Script Director gets a Generate section for GPT-SoVITS characters
(ui_script_run.js): it queues the script as an API prompt with this
page's client id (audio_tts_run.js), shows queued/running/progress,
plays the result and saves it to output/audio/<character>/. The canvas
is never touched. A line card's retake button adds [take:N] to that
line so only it is regenerated; "New take of all" changes the seed.
Cancel deletes a queued job or interrupts it by prompt id when running
(checked against /queue, since the start event can arrive first).
Events that arrive before /prompt answers are buffered and replayed.
Language/speed start from the character's `defaults` and can be saved
back (read-modify-write, unknown fields kept). Needs Anomalous_TTS
interface 11; older versions get a hint and keep push-to-node.

The Arona multi-voice template read a file only one machine has; the
button, route, helper (alignedVoiceValue) and strings are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 01:25:05 +08:00
DemonGatanjieuandClaude Opus 5.5 a4a49d9d0c Merge main (v1.57.2 hotfix) into feature/audio-engines
The save and Prompt Note fixes were cherry-picked onto main for the
replaced v1.57.2; their changelog entries move from Unreleased into the
v1.57.2 section. Code is unchanged here, feature already had both fixes;
feature keeps its own cache parameters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:03:51 +08:00
DemonGatanjieuandClaude Opus 5.5 3ba16e874c fix(notebooks): make UNet notes runnable and list unlabeled models
Sending a Prompt Note with a UNet main model chained its LoRAs through
LoraLoader, whose clip input nothing could feed. UNet LoRAs now use
LoraLoaderModelOnly and the editor says to connect a text encoder.

Models without a baseModel in their metadata matched no base model, so
they never appeared; base_models now reports them and
compatible_models serves them for "__unlabeled__". Both routes share
one walker and run it off the event loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:57:47 +08:00
DemonGatanjieuandClaude Opus 5.5 42d8c7bbfc fix(backend): restore imports lost in the module split
Saving a workflow recipe failed for any workflow with a model loader:
recipe_images.py called _resolve_exact_model_reference and hashlib, which
stayed behind in recipe_schema.py when 6ad975c split the module. The
resolver moves to path_utils.py so both recipe modules can import it
without a cycle.

The same split left media_routes.py without time/tempfile (card
thumbnails silently fell back to full-size covers) and model_metadata.py
without _resolve_paths_to_model_info_sync (editing a moved model failed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:57:24 +08:00
DemonGatanjieuandClaude Opus 5.5 7c97b73779 fix(notebooks): make UNet notes runnable and list unlabeled models
Sending a Prompt Note with a UNet main model chained its LoRAs through
LoraLoader, whose clip input nothing could feed. UNet LoRAs now use
LoraLoaderModelOnly and the editor says to connect a text encoder.

Models without a baseModel in their metadata matched no base model, so
they never appeared; base_models now reports them and
compatible_models serves them for "__unlabeled__". Both routes share
one walker and run it off the event loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:33:35 +08:00
DemonGatanjieuandClaude Opus 5.5 c19e3bb711 fix(backend): restore imports lost in the module split
Saving a workflow recipe failed for any workflow with a model loader:
recipe_images.py called _resolve_exact_model_reference and hashlib, which
stayed behind in recipe_schema.py when 6ad975c split the module. The
resolver moves to path_utils.py so both recipe modules can import it
without a cycle.

The same split left media_routes.py without time/tempfile (card
thumbnails silently fell back to full-size covers) and model_metadata.py
without _resolve_paths_to_model_info_sync (editing a moved model failed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:28:18 +08:00
DemonGatanjieuandClaude Opus 5.5 685de2fa15 fix(workspace): keep the header tabs usable, close with Esc, restore after the translator
- The recipe / notes / material workspace (nbPanel) starts below the 48px
  header instead of covering it, so Models / Gallery / Workflows stay
  clickable while it is open.
- Esc closes that workspace like a click beside it, unless one of this
  plugin's dialogs or a ComfyUI dialog is above it or a text field has focus.
- Closing the Prompt Translator shows the browser again when opening it had
  folded the browser away (not after sending text on to the studio).

Found in a browser audit; checked in an isolated ComfyUI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:24:52 +08:00
DemonGatanjieuandClaude Opus 5.5 5fd9e8c872 chore(repo): keep test screenshots and local paths out of the repository
The doc folders now track only their Markdown; screenshots, logs and raw
test data belong under the ignored .agents/. AGENTS.md section 0 says so,
and forbids local paths and file:/// links in committed files. Fixes the
existing audit index link that pointed at a local path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:17:36 +08:00
DemonGatanjieu 713e06550c Merge pull request #20 from DemonGatanjieu/claude/awesome-wright-48lzun
Sync main (v1.57.2 changelog and material card cache version)
2026-09-27 03:35:05 +08:00
Claude 660f1839b0 Merge remote-tracking branch 'origin/main' into claude/awesome-wright-48lzun
# Conflicts:
#	web/styles.css
2026-09-26 19:34:54 +00:00
Claude a4cbe72086 docs(changelog): v1.57.2 also fixes Material Library cards with a node selected
The two apply-mode card fixes (cherry-picked from feature/audio-engines)
ship in v1.57.2; the stylesheet cache version is bumped so browsers load
the new card CSS. Checked in ComfyUI with a selected KSampler: before, two
"⚡ ⚡ Apply Scheme" buttons per card and four overlapping text pairs; after,
one button, no overlaps, and applying still writes the scheme's values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RMuwbsWZHnx2eCWpxYqAB
2026-09-26 19:34:38 +00:00
DemonGatanjieu 4e8603b4cc fix(materials): stop apply-mode card text from overlapping
The grid used one fixed row height, so the extra apply button squeezed the
badges, date and node types on top of each other. Cards in apply mode now get
a taller row (the list is marked is-apply-mode when it renders), the apply
button sits at the bottom, and only the tag row may be clipped when space
runs out. The normal row is a little taller so a tag row fits as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
(cherry picked from commit 9e761d0705)
2026-09-26 19:32:04 +00:00
DemonGatanjieu e1ac84f6d8 fix(materials): one apply button per card, without a doubled icon
In apply mode each card rendered a second apply button (a clone meant for
the list layout's hover actions) that showed as an extra row in the grid,
and both labels prefixed another icon to a locale string that already has
one. Cards now render a single button whose text comes straight from the
locale (textContent), in grid and list layouts alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
(cherry picked from commit 01fc925bd6)
2026-09-26 19:32:03 +00:00
DemonGatanjieu 120f0f7665 Merge pull request #19 from DemonGatanjieu/claude/awesome-wright-48lzun
Merge v1.57.2 canvas performance hotfix from main
2026-09-27 03:22:32 +08:00
Claude a7749beed4 Merge remote-tracking branch 'origin/main' into claude/awesome-wright-48lzun
# Conflicts:
#	CHANGELOG.md
#	web/main.js
#	web/modules/browser_entry.js
#	web/styles.css
2026-09-26 19:21:26 +00:00
Claude af3bb60981 fix(perf): stop a :has() rule from slowing canvas dragging (v1.57.2)
`body:has(#…-modal[style*="display: flex"]) #…-tooltip-bubble` made the
browser re-check the whole page on every inline-style change anywhere.
ComfyUI moves every DOM widget (text boxes) on each pan/zoom frame, so every
frame paid for it, with the browser closed too, and much more with plugins
that add DOM per widget (ComfyUI Prompt Assistant).

Measured in ComfyUI (frontend 1.53.6, Chromium) panning a 300-node
workflow for 120 frames, style recalculation in total:
- no plugins 60 ms; AMB 690 ms -> 63 ms
- AMB + Prompt Assistant 2300 ms (~19 ms/frame) -> 86 ms (PA alone 80 ms)

The rule was redundant: both popups hide the tooltip when they open and
showSharedTooltip refuses while either is open (checked in the browser).
Cache-busting versions bumped so browsers load the new stylesheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RMuwbsWZHnx2eCWpxYqAB
2026-09-26 19:20:03 +00:00
DemonGatanjieu 4fe746af2c Merge pull request #18 from DemonGatanjieu/claude/awesome-wright-48lzun
feat(tts-import): folder-first grouping, visible skips, batches, preview
2026-09-26 23:27:22 +08:00
Claude d4603f8e8a feat(tts-import): folder-first grouping, visible skips, batches, preview
- A folder holding one character's GPT + SoVITS weights and its clips is that
  character, named after the folder: two characters whose weights are both
  "model" no longer merge, pinyin weights in a Chinese-named folder take the
  folder's name, version folders read "派蒙 v2". Package weight folders keep
  the name-by-stem rule.
- Skip rules match the node (interface v9): Python environments and base
  models always; GPT_SoVITS / logs / output / TEMP / tools only inside a
  package; the chosen or dropped folder never. The drop walker and the folder
  dialog apply them where the listing is known (the old re-check on each
  file's path skipped a chosen folder called "output"), local scans prefix
  the chosen folder so every source sorts alike, and the window names the
  skipped and too-deep folders instead of losing files quietly. Drops only
  open importable files and stop at 5000.
- Same-name files inside a character are renamed with their folder
  (难过_01.wav, sent as the file's "name") instead of dropped; an unclaimed
  .list goes to every character.
- Big adds open 20 characters (about 800 files) at a time; "next batch" opens
  the rest, and it opens by itself once a batch is imported, replacing the
  imported cards. Unassigned files upload only once put into a character.
- Every clip can be previewed while importing, local paths included (through
  the node's preview route): one at a time, ▶ turns into stop, another clip
  switches.
- Upload queue and preview player move to their own modules, the "add more"
  menu to the screens module (ui_tts_import.js 647 → 652 lines with batches and renames added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RMuwbsWZHnx2eCWpxYqAB
2026-09-26 15:20:24 +00:00
DemonGatanjieuandClaude Opus 5.5 4396ca375c feat(tts-import): big batches stay responsive; unusable clips left out
- A folder per character: clips and line files follow the weights in their
  nearest folder, so pinyin weights, Chinese folders and English clips still
  land together (a real 34-character folder: 3065 unassigned files -> 0).
- Clips outside 3-10 s and their same-name line files are left out of the
  import and folded under "left out" instead of one warning per clip.
- GPT-SoVITS program and training folders are skipped in drops (not even
  walked) and picked folders; a very large upload (>1500 files or 2 GB)
  first offers the local folder picker.
- Uploads run three at a time, status repaints are coalesced, a card shows
  its first 60 clips, move menus are filled when opened, and the unassigned
  card folds into one line per folder that can be assigned at once.
- New ui_tts_import_sources.js brings files in (dialogs, drops, local picker).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:17:47 +08:00
DemonGatanjieuandClaude Opus 5.5 9fc38dc69f docs(agents): hard per-commit rules and a structure check
AGENTS.md section 0 lists what every commit must do, with a table of which
document each kind of change updates. tools/check_structure.mjs (tracked)
fails when a source file has no line in the architecture map or a web module
is unreachable from an entry, and lists files over the size threshold.
ARCHITECTURE.md now points to section 0 instead of repeating it; CLAUDE.md
joins GEMINI.md as a reading entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:57:41 +08:00
DemonGatanjieuandClaude Opus 5.5 bd7fb8beaa chore(shortcuts): remove the never-wired shortcut organizer
ui_shortcut_organizer.js and shortcut_layout.js only referenced each other;
the bottom bar is the fixed one built in ui_toolbox.js. Also drops their
eleven locale keys and marks the customizable-shortcuts plan as abandoned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:57:41 +08:00
DemonGatanjieuandClaude Opus 5.5 55208061a8 docs(architecture): the ownership map doubles as the file directory
Adding, deleting, renaming or splitting a source file now always counts as a
module-owner change, checked by the local tests/architecture_map.mjs. Lists
the eight modules the map had missed: model_constants.py,
recipe_constants.py, model_policies.js, scan_progress.js,
shortcut_controls.js, ui_dialog.js and ui_prompt_toast.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:49:15 +08:00
DemonGatanjieuandClaude Opus 5.5 3274636720 feat(audio): guided GPT-SoVITS import (choose, checklist, tour)
The import window now asks what the user has first: one character, many
characters or a package, or files for an existing character (picked from
a list, or opened straight from a card's "Add files"). Dropped files skip
the question.

Each character card leads with a checklist (GPT model, SoVITS model,
clips, main voice, name) that lights up the next step and offers its
button in that line; the chosen weights sit in their lines, and the
card header says how many steps are left. A main voice (3-10 s) is now
required for a new character, as GPT-SoVITS needs one.

A spotlight tour explains the first question and the card the first time
each is shown; "?" in the header replays it. ui_spotlight_tour.js takes
its steps and an onClose from the caller now; the browser tour is
unchanged. The fixed screens and tour steps live in
ui_tts_import_screens.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:30:25 +08:00
DemonGatanjieuandClaude Opus 5.5 f220c63315 feat(audio): redesign the GPT-SoVITS import window
The two-column workbench was hard to read on first open. The window now
opens as one drop area that says what to bring (GPT model, SoVITS model,
3-10 s clips) with two buttons and a quiet "files on this PC" line, and
three steps at the top show where the user is.

With files, each character is a card: folded, one line with the name
(edited in place), a short status and what it holds; unfolded, two model
tiles, one line per clip (main-voice star, emotion, line) and the line
files and language at the bottom. One card is open at a time; files that
match no character get a card of their own at the top. "Add more" is a
single button with a menu, the owner picker on each clip only shows on
hover, and the footer says what still needs a look and where it saves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 16:55:24 +08:00
DemonGatanjieuandClaude Opus 5.5 56882fd1e4 feat(audio): batch import workbench for GPT-SoVITS characters
The import form becomes a two-column workbench for one or many
characters. Files and whole folders can be dropped, or a folder on this
PC scanned (Anomalous_TTS browse?recursive=1, interface 6). Weights pair
up by name with the latest epoch kept; clips and line files follow their
folder or file-name prefix; anything unclear waits in an unassigned
tray. The left column lists the characters with a status dot, the right
shows the selected one with one compact line per clip and a picker (or
drag handle) to move files between characters. Ready characters are
imported one after another, each on its own, so a failure leaves the
others alone.

Grouping rules live in tts_import_groups.js, the draft pane in
ui_tts_import_draft.js and OS drop reading in ui_tts_file_drop.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 16:26:16 +08:00
DemonGatanjieuandClaude Opus 5.5 a8d02f135b feat(audio): import form shows at a glance what is done
Each section title gets a mark (green check with counts, amber
"required" for what a new character still needs, red on problems) and
each row a coloured bar: green ready or merged, blue uploading, grey
skipped, red failed or refused. Clips scroll in their own box and the
Import button stays in view.

When adding to a character, inspect now sends the target (Anomalous_TTS
interface 5): files it already has show as skipped, annotation files as
merged, and a different file under the same name blocks the import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:55:34 +08:00
DemonGatanjieuandClaude Opus 5.5 248e137f23 feat(audio): richer audio sidebar, GPT-SoVITS settings as a dialog
The sidebar groups characters by language (GPT-SoVITS) or folder
(F5-TTS) in foldable groups; a character unfolds into its clips and a
click plays one through the studio row. Characters drag onto nodes from
the sidebar, a red dot marks characters that need a look, a search box
shows from six characters on, and files dropped on a GPT-SoVITS
character open "Add files" for it.

GPT-SoVITS settings move to a dialog opened from the sidebar footer,
which carries the reminder dot; "Import character" moves into the
studio toolbar. Canvas drag helpers move to audio_voice_drag.js so the
sidebar and the cards share them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 13:00:50 +08:00
DemonGatanjieuandClaude Opus 5.5 144c32929e feat(audio): quieter GPT-SoVITS setup, empty studio as a drop area
The setup card is one folded line; missing pretrained files (fetched on
first use anyway) only get a small dismissable dot once the studio's
characters need them, and the import form notes what the chosen
language will download. An empty GPT-SoVITS studio is a drop area with
the import button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:37:41 +08:00
DemonGatanjieuandClaude Opus 5.5 5dc570d880 feat(audio): one storage place for GPT-SoVITS characters
Uses Anomalous_TTS interface v4.

- Setup card: "where characters are kept" replaces the library list.
  "Change..." picks a folder on the ComfyUI machine and asks whether the
  current characters move along (move / don't move / cancel). A move shows
  its progress (characters, and bytes when copying across disks) and the
  studio redraws when it ends; an error says where it stopped.
- "Other places" lists earlier storage places that still hold characters
  and yaml folders; x on an earlier place stops listing it (files kept).
- Import form: no location picker; new characters go to the storage place
  and the form shows the exact path.
- The "add library" action is gone (no longer offered by the node).

Tested: node tests/tts_setup_api.mjs (storage calls, moving summary).
Isolated ComfyUI in the browser: change storage with move (both
characters moved, a non-character folder left, config updated), change
back without moving (old place listed under other places, stale move
message not shown), remove the earlier place (characters unlisted, files
kept), import form shows the storage path; a .list dropped on a clip's
text box fills it without being added as a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:28:20 +08:00
DemonGatanjieuandClaude Opus 5.5 c0a6beff04 feat(audio): more ways to find a clip's reference line on import
- Each clip shows where its line came from (same-name .txt / .lab,
  annotation file, or the file name, marked as a guess to check).
- Dropping a .txt / .lab / .list on a clip's line box fills it: a plain
  file is the line, an annotation file is searched for that clip (same
  rule as the node, including the name without the emotion part).
- .lab is an importable line file.

Tested: node tests/tts_setup_api.mjs (textFromFile added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:16:26 +08:00
DemonGatanjieuandClaude Opus 5.5 fd966a7ee3 feat(audio): simpler GPT-SoVITS import form
- Fixed sections for GPT weight, SoVITS weight, reference clips and line
  files; files dropped anywhere on the form go to their section. One GPT
  and one SoVITS: a new one replaces the old, and from a batch the latest
  epoch is kept (the others are named in a note).
- The save location is one line ("Saved to …/gpt_sovits/<name>") with
  "Change location"; the library list only shows when asked for.
- Name clashes are shown while typing; an exact match offers "add to that
  character instead", which switches the form to add mode in place.
- Unsupported SoVITS versions (v3/v4) are marked and block the import;
  clips outside 3-10 s cannot be the main voice.
- After an import the studio scrolls to the new card and highlights it.

Tested: node tests/tts_setup_api.mjs (epoch pick, name clash, version
block, clip range added). Isolated ComfyUI in the browser: name clash and
switch to add mode, three GPT epochs -> e15 kept, SoVITS replaced by a v4
file -> blocked, short clips not selectable as main, add-mode import of a
clip as {平静} with the card highlighted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:10:07 +08:00
DemonGatanjieuandClaude Opus 5.5 ed86a92aeb feat(audio): GPT-SoVITS setup card and character import
Uses Anomalous_TTS interface v3 so a new user can get going without
editing extra_model_paths.yaml or sorting model folders by hand.

- Setup card on the GPT-SoVITS studio: character libraries (add/remove),
  pretrained files (per-item or all download with progress, or use an
  existing GPT-SoVITS package) and missing Python packages with the
  install command. Collapses to one line when ready.
- Import form: drop files, pick them in the browser, or pick them on the
  ComfyUI machine (server-side path picker, no upload). Name, SoVITS
  version, clip length, lines, main voice and language are filled in from
  the node's inspect; emotions per clip. Uploads go in 8 MB chunks.
- "Add files" on a card, or a drop onto it, adds clips and emotions to
  that character without replacing its main voice.
- Hidden for nodes without v3; disabled when the page is not opened on
  the ComfyUI machine.
- New modules: tts_setup_api.js (calls + pure form rules),
  ui_tts_setup.js, ui_tts_import.js, ui_tts_path_picker.js and
  styles/12-tts-setup.css. ui_audio_studio.js only wires them in.

Tested: node tests/tts_setup_api.mjs (new, local) plus the audio engine,
node target and script rule tests. Isolated ComfyUI with both plugins in
the browser: setup card for a fresh user, package as pretrained source,
library add, import from local paths (lines found in all.txt), file drop
onto a card (chunked upload, add mode), cancel discards the upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:37:42 +08:00
DemonGatanjieuandClaude Opus 5.5 ad8596ad48 perf(audio): stop the GPT-SoVITS page from refetching on every render
- Cache engine presence and voice groups (60 s) so domain switches, sidebar
  clicks and the studio + sidebar pair cost no requests; saves and added
  voices invalidate, Refresh also asks Anomalous_TTS to rescan (?refresh=1).
- Follow Anomalous_TTS interface v2: the list is a summary; the emotion
  editor opens at once and loads the character's audio list in the
  background (save waits for it, keeps unknown settings fields).
- Cards: show "角色/版本" as name + quiet suffix, one-line drag hint,
  header wraps instead of squeezing.
- Docs: ARCHITECTURE audio_engines paragraph, audio-studio guide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6FStZ5hiKELvNH3WKgBnx
2026-09-24 18:15:49 +00:00
DemonGatanjieu ccb2beb490 Merge main (material card layout fix) into feature/audio-engines 2026-09-24 16:00:18 +00:00
DemonGatanjieuandClaude Opus 5.5 9e761d0705 fix(materials): stop apply-mode card text from overlapping
The grid used one fixed row height, so the extra apply button squeezed the
badges, date and node types on top of each other. Cards in apply mode now get
a taller row (the list is marked is-apply-mode when it renders), the apply
button sits at the bottom, and only the tag row may be clipped when space
runs out. The normal row is a little taller so a tag row fits as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-24 16:00:18 +00:00
DemonGatanjieu cb26b763b8 Merge main (material apply button fix) into feature/audio-engines 2026-09-24 15:55:24 +00:00
DemonGatanjieuandClaude Opus 5.5 01fc925bd6 fix(materials): one apply button per card, without a doubled icon
In apply mode each card rendered a second apply button (a clone meant for
the list layout's hover actions) that showed as an extra row in the grid,
and both labels prefixed another icon to a locale string that already has
one. Cards now render a single button whose text comes straight from the
locale (textContent), in grid and list layouts alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-24 15:55:24 +00:00
DemonGatanjieuandClaude Opus 5.5 634b2384cf feat(audio): engine switch with runtime detection; GPT-SoVITS via Anomalous_TTS
- audio_engines.js: F5-TTS and GPT-SoVITS (the separate Anomalous_TTS node
  pack), detected through /object_info/<node class>. Both engines' voices are
  normalised into one voice-group shape so cards, sidebar and Script Director
  share one implementation.
- The audio page shows an engine switch; a missing engine stays listed as
  "not installed" with install steps (no popups). The F5-TTS library still
  works without the F5-TTS node; GPT-SoVITS is never scanned when missing.
- GPT-SoVITS cards come only from the node's HTTP contract: preview reference
  clips, drag a character onto AnomalousTTS_CharacterSpeech, and edit the
  emotion references ("Edit emotions"), saved through /anomalous_tts/settings
  with unknown fields preserved and the node's validation messages shown.
- Drop targets and the Script Director are per engine: voices of one engine
  are refused on the other engine's node with the reason.
- Audio gallery reads the character of GPT-SoVITS generations.
- Usage guide (in-app and docs/guides/audio-studio.md) covers both engines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-24 13:13:49 +00:00
DemonGatanjieuandClaude Opus 5.5 9f45fef85b feat(audio): explicit drop targets, drag hints and an in-app usage guide
- audio_node_targets.js is the only list of nodes the audio studio writes
  into: F5-TTS (Audio / Advanced / FromModel) takes a whole character, so the
  card header sets its main voice; ComfyUI's Load Audio takes one exact clip,
  so a row sets that file. Unlisted nodes are refused instead of matched by
  widget name, and a single emotion clip is never written into F5-TTS's
  sample (it would break {emotion} tags).
- While dragging, the hint says what releasing will do, or why a hovered node
  is refused and what to do instead (shared bindMaterialDrag targetHint /
  rejectHint).
- Script Director uses the same table and refuses nodes whose sample_audio is
  linked.
- In the audio domain the header "!" opens a five-step usage guide;
  docs/guides/audio-studio.md is the full guide (docs/guides is now tracked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-24 11:43:31 +00:00
DemonGatanjieuandClaude Opus 5.5 2737c1b5b0 docs(changelog): Audio & Voice Studio under Unreleased
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 19:23:09 +00:00
DemonGatanjieuandClaude Opus 5.5 98992063be Merge feature/audio-voice-studio into main
Audio & Voice Studio: F5-TTS voice library and uploader, Script Director,
audio gallery, plus the recipe/material regression fixes made on the branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 19:22:53 +00:00
DemonGatanjieu f7873ce106 Merge branch 'main' into feature/audio-voice-studio 2026-09-23 22:58:55 +08:00
DemonGatanjieu e788306291 docs(changelog): clean up version entry description in update guide 2026-09-23 22:58:07 +08:00
DemonGatanjieu ca32fe09ed Merge main (version in update guide) into feature/audio-voice-studio 2026-09-23 14:53:50 +00:00
DemonGatanjieuandClaude Opus 5.5 eabff31c1c fix(ui): show the version in the update guide instead of the sidebar footer
The footer version sat right under the tool dock and invited misclicks.
The "!" update guide now opens with the current version and a
"Check for updates / switch version" button that closes the guide and
opens the version panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 14:53:49 +00:00
DemonGatanjieu 41e2e8805b Merge branch 'main' into feature/audio-voice-studio 2026-09-23 22:35:50 +08:00
DemonGatanjieu c79b88d464 docs(changelog): fold sidebar footer version chip into v1.57.1 Beta 2026-09-23 22:34:28 +08:00
DemonGatanjieu 7b79e2b7d9 Merge main (version number in sidebar footer) into feature/audio-voice-studio 2026-09-23 14:28:16 +00:00
DemonGatanjieuandClaude Opus 5.5 61b828c716 fix(ui): move the version number to the sidebar footer
In the header it pushed the dock and close buttons out of view when the
browser is docked or the display is scaled. It now sits under the tool
dock and hides with the collapsed sidebar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 14:28:16 +00:00
DemonGatanjieu 2a874be1bd Merge branch 'main' into feature/audio-voice-studio 2026-09-23 22:04:22 +08:00
DemonGatanjieu 58e49cc635 docs: align version references to v1.57.1-beta 2026-09-23 22:04:10 +08:00
DemonGatanjieu efae0b84df Merge branch 'main' into feature/audio-voice-studio 2026-09-23 21:58:20 +08:00
DemonGatanjieu 585a745683 docs: update Changelog badge to v1.58_Beta 2026-09-23 21:58:14 +08:00
DemonGatanjieuandClaude Opus 5.5 49514025c2 Merge main (v1.58 version panel, docs) into feature/audio-voice-studio
Conflicts:
- api/__init__.py, web/styles.css: keep audio routes/styles and add the
  version manager.
- README.md, CHANGELOG.md: take main's released text; this branch's
  0922b27 was an earlier alignment of the same v1.57 notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 13:50:55 +00:00
DemonGatanjieuandClaude Opus 5.5 0fd50b1a18 docs: changelog and README for the v1.58 version panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 13:49:27 +00:00
DemonGatanjieuandClaude Opus 5.5 e622850349 feat: version panel with manual update check, switching and rollback
- api/version_manager.py: installed version (local only), published
  releases on request (GitHub API, git tag fallback), switch to a
  published tag, return to the default branch (fast-forward only), undo
  the last switch; refuses to overwrite modified tracked files
- header version chip and "Version & updates" panel; nothing goes online
  until "Check for updates" is clicked; restart via ComfyUI Manager
- zh/en strings, styles, architecture notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeQEub5
2026-09-23 13:42:58 +00:00
DemonGatanjieu 0922b27b56 docs: align README and CHANGELOG with cloud release v1.57.0 Beta 2026-09-23 19:30:20 +08:00
DemonGatanjieuandClaude Opus 5.5 c5eaca1dc4 Merge main (gallery search, model hash index fix) into feature/audio-voice-studio
Conflicts resolved:
- entry trigger files, web/main.js, api/__init__.py: main carries the squashed
  entry fix (09b06d1), identical to this branch before its later cleanup, so
  this branch's versions are kept
- ARCHITECTURE.md, docs/architecture/backend.md, web/modules/ui_sidebar.js:
  both sides' additions kept

Verified on the merged tree: 107 Python tests, all JS tests except the orphaned
material_parameter_cross_node.mjs, jsdom walkthroughs of the audio studio,
domain switching and the gallery search blocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 10:08:10 +00:00
DemonGatanjieuandClaude Opus 5.5 b319fa1027 feat(audio): show each file's speech in the audio gallery and rescue unsaved previews
The gallery listed file names only, and audio from Preview Audio nodes (the
template used one) lives in ComfyUI's temp folder, so the gallery stayed empty.

- api/audio_metadata.py reads ComfyUI's `prompt` comment from FLAC and
  Ogg/Opus files (read-only) and extracts the TTS node's speech, sample, seed
- gallery rows show the speech (emotion chips, expandable per-segment script),
  voice and seed, with copy-speech; results are cached by mtime/size
- search (?q=) matches speech text and file names
- "unsaved previews" section lists temp audio with "save to gallery", which
  copies into output/audio/ (O_EXCL, never overwrites; the preview is untouched)
- audio_stream serves input/output/temp only; .opus is recognized
- parseTaggedSpeech in audio_script.js reads speech back the way F5-TTS splits it

Also outside the repo: ComfyUI/arona_multivoice_workflow.json now ends in a
Save Audio node (prefix audio/F5-TTS/Arona) instead of Preview Audio.

Verified: 100 Python tests (5 new gallery tests with synthetic FLAC/Opus),
tests/audio_script_rules.mjs, jsdom walkthrough (speech rows, preview save,
search, delete, paging), and a read-only run on the two real temp previews.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 09:24:05 +00:00
DemonGatanjieuandClaude Opus 5.5 f04c5d8f8c fix(audio): stop the Script Director from splitting off punctuation-only cards
Sentence splitting cut after every '…', so "嗯……" produced a card holding only
"…"; stacked marks ("?!") and closing quotes ("!」") were split off the same way.

- split only at 。!?!? and a lone '.' before whitespace; ellipses never split
- stacked end marks and trailing quotes/brackets stay with their sentence
- punctuation-only pieces (either split mode) are attached to a neighbour
- new ✂ button splits a card at the text cursor, complementing merge

Verified: tests/audio_script_rules.mjs (ellipsis, quotes, stacked marks,
punctuation-only lines, caret split) and a jsdom walkthrough of split/merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 09:11:51 +00:00
DemonGatanjieuandClaude Opus 5.5 a8e81beeb0 feat(audio): let Script Director users control segments (split mode, merge, add)
One card is one segment spoken with one emotion, so users need to decide where
segments start and end instead of relying only on punctuation splitting.

- split mode: by sentence (default) or one card per line
- merge a card with the next one (keeps this card's emotion); Latin words get a
  space, CJK text is joined directly
- "+ add segment" appends an empty card with the previous card's emotion and
  focuses it
- counts now read "segments"

Verified: tests/audio_script_rules.mjs (line mode, joinSegments), jsdom
walkthrough of merge/add/delete/line-mode re-parse/drag; not yet checked in a
live ComfyUI session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 09:05:54 +00:00
DemonGatanjieuandClaude Opus 5.5 d69507724e feat(audio): rework Script Director around per-line emotion cards and a draggable bundle
Assigning a voice used to mean selecting a line card and then clicking a voice
row in the studio behind the drawer, which was not discoverable.

- pick one character at the top (F5-TTS needs one main voice per script); the
  hint lists the emotions that character can voice
- after parsing, each line is a card with editable text, a preview button,
  delete, and a row of emotion chips (main / happy / ...); a choice the current
  character cannot voice stays visible and blocks the bundle instead of being
  silently reset
- the footer shows the bundled speech and offers copy, push to the selected or
  only F5TTSAudio node, and a handle to drag the bundle onto a node
- voice rows and the bundle both use the shared bindMaterialDrag (the browser
  hides while dragging, the target node is highlighted); the studio's own
  window drop listener and the row-click assignment path are removed
- bundling is pure (buildScriptPackage/usableEmotions in audio_script.js)

Verified: tests/audio_script_rules.mjs, jsdom walkthrough of parse -> chips ->
push -> character switch -> drag onto a node, domain navigation walkthrough;
other JS tests unchanged. Not yet checked in a live ComfyUI session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 08:55:37 +00:00
DemonGatanjieuandClaude Opus 5.5 fa0482393f chore(entry,docs): tidy trigger module and bring architecture docs back to contracts
- entry: one explicit legacy-coordinate cleanup instead of three module-load
  side effects, no ?v= import, shared size estimate and DEFAULT_MIN_SAFE_X
  instead of literals, drop unused exports; drag behavior unchanged
- explain the !important topbar-entry rules as AGENTS.md requires
- docs: restore the model_identity.py entry, describe the F5-TTS voice layout,
  ingestion guarantees, the no-cache/no-?v= module rule, the shared all-in-one
  adapter table and the no-cross-role prompt rule; remove pixel values and
  promotional wording, fix the contradictory trigger position text

Verified: tests/floating_trigger_position.mjs (9 pass), jsdom setup of the real
browser entry plus an audio-domain navigation walkthrough (outside the repo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 08:11:34 +00:00
DemonGatanjieuandClaude Opus 5.5 40195aa842 fix(recipes,materials): keep all-in-one support without guessing or crossing roles
Keeps the planned All-in-One loader and cross-node prompt features but removes
the regressions they introduced:

- model references: one adapter table for verified layouts (easy a1111Loader,
  easy fullLoader, Efficient Loader) shared by recipe_identity.js and
  recipe_schema.py; native loaders keep their stored labels; no more guessing
  categories from file names (a "...vae.safetensors" checkpoint was a VAE);
  "None"/"Baked VAE" are never references
- recipe summary sync follows the extraction rules: only checkpoint/UNet
  loaders or ckpt_name/unet_name set the base model (not an upscaler's
  model_name), only sampler-like nodes set seed/steps/cfg, and LoRA edits
  update the entry of the edited loader instead of falling back to loras[0]
- one LoRA entry per loader again (deduplication shifted entries)
- prompt injection never writes negative text into a positive slot or the
  reverse; the block chooser follows the same rule
- material cards only accept cross-node drops that carry prompts; parameter
  blocks stay same-type
- drop unused imports and the unused modelSpecs alias

Verified: regression cases added to tests/recipe_all_in_one_compatibility.mjs
and tests/material_prompt_cross_node.mjs (both fail on the previous commit),
new tests/test_recipe_model_references.py; all JS tests except the orphaned
material_parameter_cross_node.mjs and 95 Python tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 08:07:14 +00:00
DemonGatanjieuandClaude Opus 5.5 66979f698a fix(audio): align voices with ComfyUI-F5-TTS multi-voice layout and repair studio UI
ComfyUI-F5-TTS resolves {happy} to <sample stem>.happy.wav beside the selected
sample, so `Arona_happy.wav` + `{Arona_happy}` could never run. Voices now use
Character.wav (main, {main}) and Character.<emotion>.wav ({emotion}); the
catalog groups a main voice with its variants and marks tags that F5-TTS
cannot resolve (missing main, missing .txt, different format).

- script director: one character per script, untagged lines pinned to {main},
  pushes sample (main voice, node's own combo spelling) and speech together,
  confirms before overwriting and re-checks the node after the dialog
- pure rules (splitting, composing, planning, combo spelling) in audio_script.js
- one module instance per file: drop ?v= imports that duplicated
  ui_audio_gallery/ui_audio_studio state, so studio playback stops the gallery
- all user/file-derived text goes through textContent (no innerHTML)
- studio/sidebar/gallery renders ignore stale responses; errors are shown
- gallery: count badge no longer overwrites the title, paging via "load more",
  deletion errors surfaced, timeupdate instead of a 100ms timer
- sidebar: filter selection updates in place instead of refetching; the header
  tabs, sidebar and domain switch share browser.switchAudioTab
- uploader: createViewScope lifecycle (Esc/overlay/close release listeners and
  object URLs, Esc no longer reaches the parent), 409 -> confirm -> overwrite,
  sends the pre-romanization text as original_text, no hard-coded character
- workflow template: confirm before replacing the canvas, clear message when
  the template file is missing, align sample paths to the node's combo values
- static styles moved from inline JS into 11-audio-studio.css; strings localized

Verified: tests/audio_script_rules.mjs, tests/test_audio_catalog.py (15), a
jsdom walkthrough of studio/gallery/director/uploader with a stubbed app
(outside the repo), JS syntax and import/export link check. Not yet checked in
a live ComfyUI session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 08:02:33 +00:00
DemonGatanjieuandClaude Opus 5.5 9038637967 fix(audio): harden voice ingestion, streaming and romanization backend
- upload: validate target folder with resolve_within, refuse to overwrite an
  existing voice unless overwrite=1, stage audio/transcripts and swap them in
  together so conversion or disk failures keep the previous voice, drop stale
  .txt/.orig.txt and other-format siblings, write .orig.txt for the native script
- ffmpeg is resolved explicitly (PATH, then portable python dir) with a timeout
- voice/gallery scans run in a worker thread; stems are unique across scan
  folders; audio URLs are percent-encoded and versioned by mtime
- stream and delete only accept audio extensions; paging and template names
  are validated; errors are JSON
- romanizer no longer reads Han-only (Chinese) text as Japanese and reports
  missing pykakasi / hangul-romanize instead of silently succeeding
- extension static files use Cache-Control: no-cache (revalidate) instead of
  no-store; document the deliberate PATH exception in __init__.py

Verified: new local tests/test_audio_catalog.py (14 tests) plus the existing
Python suite (78 tests) pass in a Linux venv; not yet exercised in ComfyUI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 07:45:49 +00:00
DemonGatanjieuandClaude Opus 5.5 3ec4b1de61 chore(checkpoint): save uncommitted Script Director work from previous session
未完成/验证待完成:剧本台(ui_script_director.js、13-script-director.css)、
音频面板返回状态与 locales 文案来自上一轮会话,尚未验证。仅作为恢复点,
后续修复另行提交。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W411Z69FC3em9CzpeqEub5
2026-09-23 07:42:41 +00:00
DemonGatanjieu e6758759f2 fix(audio): eliminate browser media caching with timestamp cache-buster and HTTP no-cache headers 2026-09-23 00:10:40 +08:00
DemonGatanjieu 68ef786450 feat(audio): support companion .orig.txt for native script display in audio cards 2026-09-22 23:56:00 +08:00
DemonGatanjieu e486c8c94f fix(audio): inject PATH for ffmpeg/ffprobe, deduplicate audio stems, and convert uploads to PCM WAV 2026-09-22 23:37:28 +08:00
DemonGatanjieu 2f70ef71b6 feat(audio): add intelligent multi-lingual romanization tool (Japanese & Korean -> Latin) to voice asset uploader 2026-09-22 21:27:45 +08:00
DemonGatanjieu 28bf192b6b feat(audio): implement voice asset ingestion modal with live audio preview and automatic F5-TTS companion transcript persistence 2026-09-22 11:49:26 +08:00
DemonGatanjieu da7439af8e docs: update ARCHITECTURE.md with audio workflow loading and canvas injection 2026-09-21 11:05:52 +08:00
DemonGatanjieu c0f7320977 feat(audio): add 1-click Arona workflow loader and canvas node drop injection 2026-09-21 11:05:34 +08:00
DemonGatanjieu 53c9f1b17c fix(entry): eliminate trigger button FOUC flicker on page refresh via pre-mount positioning and smooth opacity reveal 2026-09-21 01:51:46 +08:00
DemonGatanjieu 4573e1295d fix(entry): set default trigger position to top-left safe zone (80, 80) 2026-09-21 01:39:00 +08:00
DemonGatanjieu 9c2db0f1e9 fix(entry): resolve drag persistence collapse, dock entrapment, and regular refresh cache desync 2026-09-21 01:03:15 +08:00
DemonGatanjieu 364c5aff97 fix(entry): resolve ES module caching, mount-first lifecycle, and versioned coordinate storage 2026-09-21 00:47:07 +08:00
DemonGatanjieu 5b2e59c855 fix(entry): restore missing normalization imports and flush legacy dock coords to top-right 2026-09-20 23:52:49 +08:00
DemonGatanjieu a9aa88b439 fix(entry): restore unadulterated drag persistence and position trigger in top-right corner 2026-09-20 23:47:11 +08:00
DemonGatanjieu 91ec1eb4ce fix(entry): pull trigger outside sidebar frame to safe canvas and sanitize dirty coords 2026-09-20 23:04:34 +08:00
DemonGatanjieu 6e0988d01e fix(entry): relocate floating trigger to top-right safe canvas area and harden PointerEvents persistence 2026-09-20 22:58:54 +08:00
DemonGatanjieu fecd5e6ba7 Revert "feat(materials): implement Cross-Node Parameter Protocol (CNPP)"
This reverts commit fd34bbf246.
2026-09-20 22:46:09 +08:00
DemonGatanjieu 6f8fd99e11 fix(entry): relocate floating trigger to top-left safe canvas area and auto-heal sidebar occlusion 2026-09-20 21:01:29 +08:00
DemonGatanjieu 6c66e67742 fix(entry): fix floating trigger position collapse to top-left and harden boundary clamping 2026-09-20 20:53:04 +08:00
DemonGatanjieu fd34bbf246 feat(materials): implement Cross-Node Parameter Protocol (CNPP) 2026-09-20 20:33:24 +08:00
DemonGatanjieu a9640b9e79 refactor(materials): unify prompt extraction pipeline and enforce SSOT 2026-09-20 18:17:11 +08:00
DemonGatanjieu 5381b1326c fix(materials): strictly filter out model file paths and loader blocks from prompt envelope 2026-09-20 17:25:16 +08:00
DemonGatanjieu e4112083e3 feat(materials): implement cross-node prompt injection protocol (CNPIP) for EasyUse and third-party loaders 2026-09-20 17:15:19 +08:00
DemonGatanjieu e4dcbfab28 feat(recipe): implement universal semantic compatibility for All-in-One and third-party loaders 2026-09-20 15:46:23 +08:00
DemonGatanjieu 28a24da0c1 feat(audio): complete audio sidebar, audio vault gallery, and domain navigation 2026-09-20 12:35:13 +08:00
DemonGatanjieu 531a25d578 style(audio): refine domain switcher to sleek 26px SVG button and polish Audio Studio dark aesthetic 2026-09-20 12:25:28 +08:00
DemonGatanjieu 3bb144655a feat(audio): add dual-domain mode switcher and Audio Voice Studio workspace 2026-09-20 11:54:38 +08:00
627 changed files with 104732 additions and 34674 deletions
+24
View File
@@ -0,0 +1,24 @@
name: 报告问题 / Bug report
description: 哪里坏了、报错了,或者和你想的不一样 / Something broke or did not do what you expected
title: "[Bug] "
labels: ["bug"]
body:
- type: textarea
id: what
attributes:
label: 遇到了什么问题? / What went wrong?
description: 一两句话就行,中文英文都可以。 / A sentence or two is enough, in any language.
placeholder: 比如:在模型页点扫描,进度停在 30% 就不动了。 / e.g. The scan on the Models page stops at 30%.
validations:
required: true
- type: textarea
id: more
attributes:
label: 截图或报错(可选) / Screenshots or errors (optional)
description: 截图可以直接拖进来。 / Drop screenshots here.
- type: textarea
id: environment
attributes:
label: 环境信息(可选) / Environment (optional)
description: 在插件里点 设置 → 帮助与反馈 → 复制诊断信息,粘贴到这里。 / In the plugin, Settings → Help and feedback → Copy diagnostics, then paste here.
render: text
+2
View File
@@ -0,0 +1,2 @@
# A blank issue stays possible: the plugin's feedback window opens one with what was written.
blank_issues_enabled: true
@@ -0,0 +1,13 @@
name: 提建议 / Suggestion
description: 想要的功能、哪里不顺手、希望往什么方向发展 / A feature you want, something awkward, where the plugin should go
title: "[Idea] "
labels: ["enhancement"]
body:
- type: textarea
id: idea
attributes:
label: 你希望有什么? / What would you like?
description: 随便写,一两句话就行,中文英文都可以。 / Anything goes; a sentence or two is enough, in any language.
placeholder: 比如:希望素材库能按标签筛选。 / e.g. Filter the material library by tag.
validations:
required: true
+15 -6
View File
@@ -1,5 +1,6 @@
# Local configuration and credentials
config.json
allow_remote_changes
.env
.env.*
!.env.example
@@ -56,22 +57,30 @@ api/notebooks/
# AI Dev Scripts & Temporary text dumps
scripts/
doctor_code.txt
# Only the Markdown of these doc folders is tracked: screenshots, logs and raw test
# data stay local (put them under .agents/).
docs/*
!docs/architecture/
docs/architecture/*
!docs/architecture/*.md
!docs/decisions/
docs/decisions/*
!docs/decisions/*.md
!docs/audits/
!docs/audits/*.md
!docs/guides/
docs/guides/*
!docs/guides/*.md
# The README's pictures (small WebP from the isolated demo ComfyUI).
!docs/images/
docs/images/*
!docs/images/*.webp
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
+64 -13
View File
@@ -3,11 +3,33 @@
适用于本插件的所有修改,包括 Gemini、Codex 和人工维护。目标是让 UX 持续改善,同时让代码职责、状态来源和验证方式保持清楚。
用户明确的当前要求优先;确需偏离本规范时,采用最小例外,在交付说明中写明原因、影响和验证。常规实现选择自行处理,不因本规范增加无必要的确认步骤。
## 0. 每次提交的硬性要求
以下几条每次提交都要做到,没有“这次改动小”的例外;其余各节是怎么做好的指引。
1. **结构和文档同步。** 按下表更新文档,与代码放在同一次提交里。新增、删除、改名、拆分源码文件一律算改了模块职责:职责清单同时是文件目录,每个源码文件都要有一行。
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. **测试产物不进仓库。** 测试代码放 `tests/`;截图、录屏、日志、原始测试数据、审计附件放在 `.agents/`(已忽略,只留本机),不用 `git add -f` 绕过忽略规则。提交的文件里不写本机路径、用户名或 `file:///` 链接,文档之间用相对链接。
6. **交付说明**写清:改了什么和为什么、验证结果和没验证到的地方、遗留问题、提交号;用了规范例外就写原因和影响。
| 改了…… | 更新 |
| --- | --- |
| 新增 / 删除 / 改名 / 拆分源码文件,模块职责 | `ARCHITECTURE.md` 职责清单,或最窄的 `docs/architecture/` 专题 |
| 数据流、接口、持久化格式、安全边界、跨系统规则 | 对应的 `docs/architecture/` 专题;系统图或阅读地图变了才改 `ARCHITECTURE.md` 入口 |
| 用户能感觉到的行为 | `CHANGELOG.md`;使用方法变了同步 `README.md` 或 `docs/guides/` |
| 长期有效的产品或架构取舍 | `docs/decisions/` |
| 反复出现的坑、严重故障 | `docs/architecture/lessons.md` |
| 开发规范本身 | 只改本文件 |
普通修复、样式微调不写进架构文档;没有变化的边界不写“本次未改变”。
## 1. 修改前先定位责任
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 请求默认允许调整布局、交互和文案,不代表允许改变文件存储位置、接口协议、工作流语义、模型识别依据或用户数据。
@@ -37,7 +59,12 @@ UX 请求默认允许调整布局、交互和文案,不代表允许改变文
- 通用文件不能成为杂物箱。不要为了省一个文件,把新领域逻辑塞进 `utils`、素材详情渲染器或浏览器主对象。
- 不引入没有当前调用方的管理器、适配层、配置项、事件总线或依赖。一次性的简单逻辑可以留在其责任模块内。
**规模审查阈值:** 普通逻辑文件超过约 600 行,或单函数超过约 100 行时,新增功能前必须检查能否提取独立职责。继续扩展时,在交付说明中解释为什么仍属于同一职责。已有超大模块的小修复不要求顺手重写全文件;语言目录、数据表和样式表不按这个行数阈值拆分。不能通过空壳包装、压缩代码或碎片化文件绕过审查。
**规模阈值(只减不增):** 普通逻辑文件以 600 行为线,单函数以约 100 行为线。
- 新文件不超过 600 行;写不下说明有两种以上职责,先拆。
- 已经超过 600 行的老文件,每次改动后只能变短或持平,不能变长。要在里面加功能,先把一块独立职责搬出去,腾出行数再加。
- 不为了达标专门大拆;改到哪个文件,就顺手从它身上拆走一块,让超线文件随日常修改逐步变少。
- 语言目录、数据表和样式表不按这个行数计算。不能通过空壳包装、压缩代码、删注释或碎片化文件绕过。
- `tools/check_structure.mjs` 按上次提交比对,新文件超线或老文件变长都会失败。
## 3. 状态、关闭与异步
@@ -72,9 +99,36 @@ UX 请求默认允许调整布局、交互和文案,不代表允许改变文
- 路径与文件名在后端使用现有边界校验;不能只检查 `..`。前端校验不能替代后端校验。
- 修改模型、封面、附属文件或导入包前先完整校验和暂存。失败时保留旧数据,避免先删除旧文件再尝试保存新文件。
- 修改多文件发布流程要覆盖中途失败和冲突。单个 JSON 的原子写入不能冒充整组文件的事务。
- 大目录遍历、哈希和大量磁盘 I/O 使用已有工作线程/进程路径,不阻塞 aiohttp 事件循环。
- UI 适配不能偷偷迁移用户数据、修改全局配置或改变现有路由与存储格式。确需改协议时同步调用方、兼容策略和测试。
### 安全底线
ComfyUI-Manager 收录审核检查的就是这几类口子(2026-10 审 Anomalous_TTS 时逐条指出过)。没有“先这样、以后再补”的例外:
1. **不执行下载来的或用户文件里的代码。** 数据不用 `pickle`、`eval`、`exec`、动态导入读;配置文件当数据解析。模型文件只读 safetensors / GGUF 的文件头,`.ckpt` / `.pt` / `.pth` / `.bin` 不打开,界面提醒它们可能带代码。
2. **下载要能核对。** 只从允许的 https 站点下载,先写 `.part`,核对 SHA-256 后才改名放入;没有可核对的指纹时在界面上说明。下载的东西只当数据用。
3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。模型文件本身可以是链接(云服务器上模型文件夹里全是指向平台模型盘的链接):文件夹部分照样用 `resolve_within`,文件用 `model_file_within` / `model_reference_within` 按名字取,不跟着链接走出去,封面、信息和改动都在链接旁边。
4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机)。用 `--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. **读用户文件要有上限。** 限制读取的大小和条数;读不了或超大的文件跳过并在结果里说明,不让一个坏文件拖垮整个请求。读到的内容进页面只当纯文本(本节第二条)。
### 资源占用
ComfyUI 的内存、显存和 CPU 是留给出图的。插件闲着的时候应该几乎不占资源:
1. **用户动了才做事。** 不在后台轮询、定时扫描或预加载;确需定时的(扫描进度、跟随选中的节点)只在对应界面打开期间进行,关掉就停。
2. **耗时的活放后台。** 大目录遍历、哈希和大量磁盘 I/O 使用已有工作线程/进程路径,不阻塞 aiohttp 事件循环。
3. **数据跟着界面走。** 界面打开时才读,关掉就释放;跨次使用的缓存要小、有上限,写清楚何时失效。长列表只渲染看得到的或前几十条。
4. **图片用缩略图。** 网格和列表用缓存的缩略图,原图只在看大图或拖到画布时读取;浏览器里不常驻大量原图。
### 功能原则
几个功能对用户的承诺。改这些功能时不能破坏;确需例外,先问维护者:
1. **模型检查(模型医生)从不自己改工作流。** 检查只读;只有用户按了“换上”或“下载”才动节点,而且只换成同一个文件(SHA-256 指纹一致)。只是同名、没法核对的候选,标明“按文件名”由用户决定。下载的来源(C 站按指纹、工作流带的链接、ComfyUI-Manager 模型清单)在界面上写明。完整的判断规则(三档可信度、基础组件只认指纹、下载来源与核对)见 `docs/guides/model-check-rules.md`,改模型检查前先读、改了规则同时改它。
2. **搭配只记住,从不修改参数数值。** 存的时候是什么,放回画布就是什么:强度、CLIP 层数、提示词等一律原样,不用节点默认值顶替,也不替用户“调”。想要别的数值,在画布上调好再存一个新搭配。放上画布只新放一组节点,从不改画布上已有的节点。(理由见 `docs/decisions/README.md` AD-021;模型检查的见 AD-005、AD-006、AD-020。)
## 6. 清理旧实现
- 替换一条路径时搜索它的导入、导出、调用、字段、事件和选择器;确认无用后一起移除。
@@ -90,11 +144,10 @@ UX 请求默认允许调整布局、交互和文案,不代表允许改变文
| 修改范围 | 至少验证 |
| --- | --- |
| 导入、导出、模块拆分 | 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 差异 |
@@ -103,15 +156,13 @@ UX 请求默认允许调整布局、交互和文案,不代表允许改变文
- 修复故障时,优先补充能复现原故障的行为测试。不能只通过源码字符串“存在某段代码”来证明修复。
- UX 明确替换旧交互时,更新测试到新的用户流程,并保留原本要保护的行为;不要仅删除失败断言或重新塞回废弃功能。
- 新增应随仓库交付的回归测试要检查 `.gitignore`,确认确实进入版本控制。
- 历史本地 Python 测试可能不在 Git 中。使用合适环境运行相关测试,例如 `python -m unittest discover -s tests -p 'test_*.py'`,核对实际发现的数量;零项测试不算通过。
- Python 测试要在 ComfyUI 的 Python 环境里跑(`node tools/run_tests.mjs` 会自己找),核对实际发现的数量;零项测试不算通过。
- 无法运行真实宿主、网络服务或完整测试时,准确说明缺口;不要将模拟画布、语法检查或已有通过数量称为本轮宿主验证。
## 8. 文档与交付
- 架构文档只记录职责、数据流、契约和关键约束。颜色、像素值及审美描述以 CSS 为准,不每次 UX 更新都往架构入口追加长篇设计说明。
- 改动架构时更新最窄的相关专题。修改规范时以本文件为唯一正文,`GEMINI.md` 只保留阅读入口,避免两套要求漂移。
- 该更新哪份文档见第 0 节的表。规范以本文件为唯一正文,避免几份要求各自漂移。
- 完成前审查差异、死代码、临时文件和空白错误;保留现有文件编码及换行风格。
- 验证后按仓库约定形成一个连贯的本地提交,只包含本任务文件;没有明确授权不推送 GitHub。
- 交付说明包含:改了什么与原因、验证结果与限制、剩余问题、本地提交。仅在触发规模阈值或规则例外时额外解释取舍。
**交付自查:** 新职责放对位置了吗?是否只有一个状态来源?每条关闭路径会释放资源吗?迟到请求会写错对象吗?旧代码和旧 CSS 清掉了吗?新文案已本地化吗?用户文件失败时还在吗?测试验证了实际行为吗?
**交付自查:** 新职责放对位置了吗?是否只有一个状态来源?每条关闭路径会释放资源吗?迟到请求会写错对象吗?旧代码和旧 CSS 清掉了吗?新文案已本地化吗?用户文件失败时还在吗?新接口会动文件或联网吗,只接受本机请求了吗?接口收了路径吗,读用户文件有上限吗?插件闲着时还占着 CPU、内存或显存吗?测试验证了实际行为吗?
+1000 -101
View File
File diff suppressed because it is too large Load Diff
+988 -173
View File
File diff suppressed because it is too large Load Diff
-11
View File
@@ -1,11 +0,0 @@
# Gemini 工作入口
在本插件中修改任何文件前,先阅读并遵守根目录的 [AGENTS.md](AGENTS.md)。它是本仓库开发与维护规范的唯一正文,适用于 UX 调整、修复和重构。
随后阅读 [ARCHITECTURE.md](ARCHITECTURE.md),按其中的阅读地图加载与当前任务有关的专题。
不要只凭当前任务描述直接追加实现。先确认现有责任模块、状态来源、关闭清理和样式规则;完成后按规范验证并报告结果。
如果当前工具没有加载本文件,任务发起者应明确要求 Gemini 阅读 `GEMINI.md` 和 `AGENTS.md`。文件的存在不代表工具已实际读取,更不代表检查已自动执行。
前端设计任务另读 [前端设计与协作方案](docs/plans/frontend-design-brief.md);具体功能计划按该文链接进入,不使用历史视觉建议作为当前任务单。
+177 -132
View File
@@ -2,20 +2,25 @@
# 🚀 Anomalous Model Browser
**A Comprehensive Creative Workspace & Model Manager for ComfyUI**
*零依赖 C 站元数据抓取 · 智能模型医生 · 可视化节点助手 · 工作流配方与参数笔记*
**A model browser and creative workspace for ComfyUI**
*模型库 · 出图图库 · 工作流 · 搭配 · 提示词 · 角色配音 · 模型检查 · 操作记录*
<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.57_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.62.3-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) | [**中文说明**](#-中文)
[**English**](#english) | [**中文说明**](#中文)
<br/>
<img src="docs/images/en-models.webp" width="900" alt="Models with their Civitai covers, base model and real names">
</div>
@@ -23,89 +28,101 @@
<h2 id="english">🇬🇧 English</h2>
> **Anomalous Model Browser** is a comprehensive creative workspace and model management suite for ComfyUI. It integrates hash-based metadata indexing, workflow healing (Model Doctor), canvas visual model swapping (Node Assistant), a unified Material Library with polymorphic canvas drag-and-drop, and immutable Workflow Recipes.
> **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.
### 🎬 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/)
### 🎬 Videos and discussion
* 📺 **Bilibili** (Chinese): [Video demo](https://www.bilibili.com/video/BV1boHx61Enc/) · [Character voices demo](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
* 💬 **Reddit**: [The post on r/comfyui](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
### 🌟 Core Capabilities
### 💡 What it helps with
| 🚀 Primary View | 💡 Description |
| :--- | :--- |
| **Model Browser (📦 Models)** | Browse models categorized by folder (checkpoints, unet, loras). Displays Civitai metadata, triggers, architectures, and high-res covers. Supports visual swapping, renaming, and notes. |
| **History Gallery (🖼️ Gallery)** | Native viewer for the ComfyUI `output` directory. Features multi-dimensional search (prompt, model, seed, hash) with removable filter blocks, mouse-wheel zoom, and drag-to-canvas workflow reconstruction. |
| **Workflow Recipes (🪡 Workflows)** | Save full workflows or partial subgraphs. Displays live model readiness status (e.g. all models ready). Open recipes in a new canvas or drag cards directly onto the canvas to load. |
| **Toolbox & Bottom Dock (🧰)** | Lower-left navigation bar featuring the Toolbox as the first icon. Directly hosts 4 frequent tools (Scan, Doctor, Assistant, Material Library) plus Settings. Clicking the Toolbox opens a panel containing 5 secondary utilities (Workflow Transfer, Prompt Studio, Prompt Translator, Model Source Hub, Prompt Notes). |
| **Workflow Repair (Model Doctor 🩺)** | Automatically detects broken nodes in external workflows and reconnects them to local models by comparing SHA-256 hashes and file sizes. |
| **Node Assistant & Presets (🤖)** | Canvas-linked sidebar. Select any node on the canvas to inspect covers/triggers, visually swap models, or apply recipe parameters transactionally. |
| **Material Library & Polymorphic Drag (✨)** | Save curated bundles from output PNGs (workflow, provenance, node parameters). Drop onto canvas to open workflow, drop prompts to auto-create native CLIP nodes, or drop onto existing nodes to inject parameters. |
**1. "Which LoRA was that again?"** A file called `download (3).safetensors` tells you nothing. Press **Scan** once: every model is looked up on Civitai by its file hash and gets its real name, cover, trigger words and base model. From then on you pick models by their covers.
### 📦 Quick Installation
<p align="center"><img src="docs/images/en-scan.webp" width="520" alt="Scan results: each file matched on Civitai"></p>
1. Open your terminal in the ComfyUI `custom_nodes` directory:
**2. Someone else's workflow opens with red nodes.** Model Check says which models are missing. When the workflow carries model fingerprints (the plugin writes them into the workflows and images of everyone who has it, unless they turn it off), the ones you already have under another name or in another folder go back in with one press, and the rest can be downloaded from Civitai, Hugging Face or ComfyUI-Manager's model list, each download checked against its fingerprint (a Manager entry that only shares the file name is marked so, for you to decide). A workflow without fingerprints can only be matched by file name: a file of the same name elsewhere on your computer is offered for you to confirm.
<p align="center"><img src="docs/images/en-model-check.webp" width="760" alt="Model Check puts back three missing models with one press"></p>
**3. "How did I make this picture?"** Search your outputs by prompt words, model or LoRA, then drag the picture onto the canvas: its workflow comes back.
<p align="center"><img src="docs/images/en-gallery.webp" width="760" alt="Gallery search, then drag an image onto the canvas"></p>
**4. The same model, LoRAs and prompt, again and again.** Pick those nodes on the canvas, right-click → **AMB** → **Save as combo**, and they are kept as a card beside the canvas. Next time press **Put on canvas** (or drag the card) and the wired group comes back where you click; nothing already on the canvas changes. Models and prompts in a combo can be changed later.
<p align="center"><img src="docs/images/en-combo.webp" width="760" alt="Picked nodes saved as a combo, then put back on the canvas as wired nodes"></p>
**5. Long prompts are hard to edit.** Select a prompt node and Prompt Studio shows 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/en-prompt-studio.webp" width="760" alt="Prompt Studio editing a prompt node as tags"></p>
**6. Characters that talk.** With the companion node pack [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS): GPT-SoVITS characters with a reference clip per emotion, and a script page to voice them.
<p align="center"><img src="docs/images/en-voices.webp" width="760" alt="Character voices page"></p>
**Changed your mind?** Everything the plugin changes is listed in **Activity**, with **Undo** and **Find on canvas**.
<p align="center"><img src="docs/images/en-activity.webp" width="620" alt="Activity log with an undo button"></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**.
### 📦 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`)*
2. Press **Shift + B** to open it. On **Home**, **Scan model folders** fills in covers and trigger words, and **Tour the interface** points at each part of the window.
3. Optional: for character voices, also install [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) (search `Anomalous TTS` in ComfyUI Manager).
Open the browser via **Ctrl + Shift + M**, the floating canvas orb (**📦**), or **Extensions → Anomalous Model Browser** in ComfyUI's menu. **ComfyUI Settings → Anomalous Model Browser → Interface** lets you customize the shortcut, configure the UI language, and select an entry mode (floating button, action-bar button, or Extensions menu only).
<br/>
Scanning needs to reach Civitai. Without it, scan offline: base models are read from the files, and a later online scan fills in the rest (see **Network and backups** below).
<details>
<summary><b>📖 Click to Expand: Step-by-Step Operating Guide</b></summary>
<summary><b>📖 Every page and tool, in detail</b></summary>
<br/>
Every page has an icon on the rail at the left of the window.
#### 1. Top Navigation Views (三大顶栏视图)
The header provides 3 primary workspaces:
* **Models (📦)**: Main model catalog. The left panel shows folder trees (`checkpoints`, `unet`, `loras`), while the center grid displays model cards with previews, trigger tags, and metadata.
* **Gallery (🖼️)**: Historical output browser. The top search bar filters outputs by prompt, model name, seed, filename, or model hash; each search keyword forms an independent block with a `×` button for instant removal. Dragging an image onto the canvas reconstructs its embedded workflow.
* **Workflows (🪡)**: Workflow Recipe studio. Filter by `[All]`, `[Full Workflows]`, or `[Subgraphs]`. Click `Save Current Workflow` to save the active canvas, or drag any recipe card directly onto the canvas to load it.
| 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, workflows, 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** | Saved workflows: 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. A card's export button makes a **recipe package** (.zip) to give to someone; dropped into their ⇅, it becomes a workflow card. |
| **🧩 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** | Prompt Studio, a page of the browser; **Beside the canvas** moves it next to the canvas to see both. 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. |
#### 2. Bottom Navigation & Toolbox Catalog (🧰 第一个图标:实用工具箱)
* **Dock Layout**: The lower-left navigation bar hosts 6 permanent buttons in a fixed layout:
1. **Toolbox (🧰)**: First anchor on the left. Opens the Toolbox panel for secondary tools.
2. **Scan Wizard (🔄)**: Direct shortcut to launch model library scanning.
3. **Model Doctor (🩺)**: Direct shortcut to diagnose and repair missing workflow nodes.
4. **Node Assistant (🤖)**: Direct shortcut to inspect canvas node models and inject parameter presets.
5. **Material Library (✨)**: Direct shortcut to manage curated bundles and drag assets to the canvas.
6. **Settings (⚙️)**: Fixed anchor on the far right for global preferences.
* **Toolbox Catalog**: Clicking the first icon (**🧰 实用工具箱**) opens a dedicated popup panel containing 5 secondary utilities:
1. **Workflow Transfer Center (⇄ 导入导出)**: Lossless AMB format workflow share code import and export. Allows importing workflows from text codes or exporting the current canvas with embedded hashes.
2. **Prompt Studio (🎛️ 提示词工坊)**: Modular prompt mixer deck. Extract prompt cards from history or materials, reorder segments, and assemble positive/negative prompts.
3. **Prompt Translator (🌐 翻译助手)**: Built-in bilingual prompt translation between Chinese and English without leaving the interface.
4. **Model Source Hub (🔗 模型来源)**: Dual-scope (workflow and library) download URL inspector. Navigates to Civitai, HuggingFace, Liblib, or ModelScope, and generates non-intrusive canvas Note nodes.
5. **Prompt Notes (📑 提示词笔记)**: Lightweight notebook for drafting, editing, and managing reusable prompt snippets saved locally in the user directory (`workflows/anomalous_notebooks`).
Tools on the rail:
#### 3. Scan Wizard (🔄 扫描向导)
* **Location**: Direct shortcut on the bottom dock (2nd icon).
* **Operation**: Scan configured model directories to compute file hashes, download Civitai covers, tags, and architectures, and build the local offline database.
* **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 the fingerprints (hash and file size) the plugin records in workflows and images (**Settings → Record model fingerprints in workflows**, on by default); without them, a file of the same name is offered for you to confirm. 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**: share and import on Workflows (⇅: a short share code of the canvas workflow, in Chinese characters or letters; paste a code, or drop a workflow file, an image or a backup .zip), 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.
#### 4. Model Doctor (🩺 模型医生)
* **Location**: Direct shortcut on the bottom dock (3rd icon).
* **Operation**: When loading an external workflow with missing red nodes, Model Doctor inspects embedded provenance hashes and file sizes to reconnect local matches automatically. The "View Hash" button opens a side-by-side SHA256 comparison modal.
**AI apps (MCP)**: Claude, Cursor, Cherry Studio and other apps that support MCP can use your library through `http://127.0.0.1:8188/anomalous/mcp`: answer questions about your models, outputs and saved prompts, and change the open workflow for you (prompts, models, LoRAs, combos, missing models), each change one Ctrl+Z step. Local only; setup in [docs/guides/mcp.md](docs/guides/mcp.md).
#### 5. Node Assistant & Parameter Presets (🤖 节点助手)
* **Location**: Direct shortcut on the bottom dock (4th icon).
* **Actions**: Select a model node on the canvas to view high-res previews and trigger words, or swap models visually. Switch to **Parameter Presets** to inject recipe-saved node values (e.g., KSampler steps, CFG, denoise) into matching canvas nodes with safe seed preservation.
</details>
#### 6. Material Library & Polymorphic Canvas Drag (✨ 素材库与多态拖拽)
* **Location**: Direct shortcut on the bottom dock (5th icon).
* **Capture**: In Gallery or Workflows, open an image's parameter inspector to save its image, workflow, and node blocks as a curated material bundle (`workflows/anomalous_materials`).
* **Polymorphic Canvas Drag**:
- Dragging a workflow material to an empty canvas area opens the full workflow.
- Dragging a prompt-only material to an empty canvas auto-creates native `CLIPTextEncode` nodes with distinct negative (`#532323`) and positive (`#235327`) styling.
- Dragging materials onto existing canvas nodes injects matching parameter values directly.
<details>
<summary><b>⚙️ Opening, shortcuts and updating</b></summary>
#### 7. Global Settings (⚙️ 设置)
* **Location**: Direct shortcut on the far right of the lower-left navigation bar (6th icon).
* **Options**: Interface language, font scale, thumbnail rendering, video cover hover behavior, and model folder blacklist.
Open the browser with **Shift + B** (again to close), 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).
> [!WARNING]
> **Beta Data Protection:** Workflow Recipes, Material Library, and Parameter Presets are currently in active preview. Please back up `workflows/anomalous_recipes`, `workflows/anomalous_materials`, and `workflows/anomalous_parameters` inside your ComfyUI user directory before updating.
**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.
</details>
<details>
<summary><b>🌐 Network and backups</b></summary>
**On a cloud server (AutoDL, RunPod…):** fully supported on overseas servers; on servers in mainland China only in part (Civitai is mostly out of reach there: lookups are unreliable, covers rarely download). Not fully tested yet (one AutoDL server so far). The guide has a section for AI assistants to walk you through it. Opened from another computer, the plugin is view only by default. Open ComfyUI through an SSH tunnel, or see [docs/guides/cloud-server.md](docs/guides/cloud-server.md).
**Network:** Scan looks models up on Civitai and prompt translation uses Google Translate, so both need to reach those sites (from mainland China usually through a proxy; when civitai.com cannot be reached the plugin tries Civitai's other address, civitai.red, which some networks reach, a cloud server in mainland China among them: names and trigger words come, covers usually not). Without them, pick **Offline** on the scan page (an online scan does the same by itself when it cannot connect): base models are read from the files, without covers or trigger words, and a later online scan fills in the rest. Downloading missing models needs the network too; for Hugging Face, **Settings → Model downloads** can use the hf-mirror.com mirror (Civitai has none). Browsing models, outputs, workflows and combos needs no network.
**Keep a copy of your own data:** **Settings → Backup → Export** packs workflows, combos, saved prompts and node values, parameter presets, and the names, notes and covers you gave your models into one .zip; keep it on a cloud drive or a USB stick, and **Import** puts it back, also on another computer. (They live in `workflows/anomalous_recipes`, `workflows/anomalous_notebooks`, `workflows/anomalous_materials` and `workflows/anomalous_parameters` in your ComfyUI user folder.)
</details>
@@ -113,96 +130,124 @@ The header provides 3 primary workspaces:
<h2 id="中文">🇨🇳 中文说明</h2>
> **Anomalous Model Browser** 是为 ComfyUI 设计的综合创作工作台与模型管理套件。系统集成了基于文件哈希的元数据提取、工作流缺失模型自愈(模型医生)、画布节点可视化换模与 LoRA 插入(节点助手)、支持多态画布拖拽的统一素材库,以及工作流配方体系。
> **Anomalous Model Browser** 在一个窗口里帮你打理 ComfyUI 周边的杂事:
> - 带 C 站封面和触发词浏览模型;
> - 翻看以前的出图;
> - 保存调好的工作流,存下常用的模型搭配和提示词;
> - 别人的工作流缺模型时自动找回;
> - 配合配套节点包 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 给角色配音。
>
> 插件改动过什么,都能在“操作记录”里查到。
### 🎬 视频演示与教程
* 📺 **哔哩哔哩 (Bilibili)**:[在 B 站观看快速上手与使用演示](https://www.bilibili.com/video/BV1a1bv68EuA/)
* 📺 **YouTube**:[在 YouTube 观看视频演示](https://youtu.be/hAvsj7uiaCw)
### 🎬 视频与讨论
* 📺 **哔哩哔哩 (Bilibili)**:[功能演示](https://www.bilibili.com/video/BV1boHx61Enc/) · [角色配音演示](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
* 💬 **Reddit**:[r/comfyui 上的介绍帖](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
### 🌟 核心特性速览
### 💡 它能帮你解决什么
| 🚀 核心视图与工具 | 💡 详细说明 |
| :--- | :--- |
| **模型浏览 (📦 模型)** | 本地模型管理网格。左侧树状目录分类(checkpoints、unet、loras),卡片展示 C 站元数据、触发词、基础架构与高清封面,支持看图换模与自定义备注。 |
| **出图图库 (🖼️ 图库)** | 原生读取本地 `output` 文件夹,支持滚轮缩放与安全删除;顶部搜索栏支持按提示词、模型名、LoRA、seed、文件名或模型哈希多维检索,生成可点击独立移除的搜索标签块;拖拽图片原地还原工作流。 |
| **工作流配方 (🪡 工作流)** | 工作流配方工坊。支持保存完整工作流或局部子图,实时显示模型就绪状态(如“全部模型就绪”);支持一键在新画布打开或直接拖拽卡片到画布空白处载入。 |
| **实用工具箱与底栏 (🧰)** | 侧边栏左下角第一个图标为工具箱,底栏直达 4 大常用工具(扫描向导、模型医生、节点助手、素材库)与全局设置;点击工具箱弹出浮窗面板,提供 5 大扩展工具(导入导出、提示词工坊、翻译助手、模型来源、提示词笔记)。 |
| **节点智能修复 (模型医生 🩺)** | 导入他人工作流发生节点爆红时,自动比对本地模型 SHA256 哈希与字节大小,一键替换为有效路径,支持哈希透视比对。 |
| **节点助手与参数预设 (🤖)** | 画布选中节点即可可视化选图换模型、向兼容链路插入 LoRA,或一键应用工作流配方中沉淀的节点参数(自动跳过易变种子)。 |
| **统一素材库与多态拖拽 (✨)** | 原生 PNG 资产一键打包保存(完整工作流、模型血缘与节点参数);多态拖拽:拖至空白画布还原工作流或生成原生红绿提示词节点,拖至已有节点智能注入参数。 |
**1. 模型太多,认不出谁是谁。** 文件叫 `下载 (3).safetensors`,根本看不出是什么。点一次 **扫描**,每个模型都按文件哈希去 C 站认一遍,显示出它真正的名字、封面、触发词和底模。以后看封面挑模型就行。
### 📦 快速安装
<p align="center"><img src="docs/images/scan.webp" width="520" alt="扫描结果:每个文件都在 C 站认出来了"></p>
1. 在 ComfyUI 的 `custom_nodes` 目录下打开终端执行:
**2. 打开别人的工作流,一片红框。** 模型检查会告诉你缺哪些模型。如果工作流里带着模型指纹(装了本插件的人出图、保存工作流时会自动记下,除非他在设置里关掉),你电脑里其实有、只是改了名或换了文件夹的,点一下就换上;真没有的可以从 C 站、Hugging Face 或 ComfyUI-Manager 的模型清单下载,下载完按指纹核对过才用(Manager 清单里只是同名、没法核对的,会标出来让你决定)。没有指纹的工作流只能按文件名找:别处有同名文件,会列出来让你确认。
<p align="center"><img src="docs/images/model-check.webp" width="760" alt="模型检查一键换上 3 个缺失的模型"></p>
**3. 这张图当时是怎么出的?** 在图库里按提示词、模型或 LoRA 搜,再把图拖到画布上,当时的工作流就回来了。
<p align="center"><img src="docs/images/gallery.webp" width="760" alt="图库搜索后把图拖到画布"></p>
**4. 每次都要重新配一遍模型、LoRA 和提示词。** 在画布上选中这几个节点,右键 → **AMB** → **存为搭配**,它们就成了画布旁的一张卡片。下次点 **放到画布**(或者直接拖卡片),点哪里,那组连好线的节点就出现在哪里,画布上原有的东西不动。搭配里的模型和提示词以后还能换。
<p align="center"><img src="docs/images/combo.webp" width="760" alt="选中的节点存为搭配,再放回画布变成连好线的节点"></p>
**5. 提示词太长,改起来麻烦。** 选中提示词节点,提示词工坊把它拆成词块:点一下调权重,拖动换位置,输入就加(中文会自动译成英文,比如输入“蓝宝石”加进去的是 sapphire)。
<p align="center"><img src="docs/images/prompt-studio.webp" width="760" alt="提示词工坊按词块编辑提示词节点"></p>
**6. 想让角色开口说话。** 配合配套节点包 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS):导入 GPT-SoVITS 角色,每种情绪一段参考音频,在配音页写台词直接生成。
<p align="center"><img src="docs/images/voices.webp" width="760" alt="角色语音页面"></p>
**改错了也不怕。** 插件做的每一处改动都列在“操作记录”里,带 **撤销** 和 **在画布上找到**。
<p align="center"><img src="docs/images/activity.webp" width="560" alt="操作记录和撤销按钮"></p>
**不占你的资源**:
- 插件本身不加载任何模型,不和生图抢显存。
- 浏览时显示的是小缩略图,不加载原图;关闭窗口一分半后,图片也会释放掉。
- 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` 点击安装*)
2. 按 **Shift + B** 打开。在 **主页** 点 **扫描模型文件夹** 补全封面和触发词;点 **界面导览** 会逐一指给你看窗口的各个部分。
3. 想用角色配音的话,再装 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)(在 ComfyUI Manager 搜索 `Anomalous TTS`)。不装也不影响其他功能。
可以按 **Ctrl + Shift + M**、点击画布上的悬浮 **📦**,或从 ComfyUI 顶部菜单 **扩展 → Anomalous Model Browser** 打开。**ComfyUI 设置 → Anomalous Model Browser → 界面** 会显示当前快捷键,并可直接打开 ComfyUI 原生录入窗口修改,因此冲突与保留按键检查仍然只有一套。该页面还可以让插件语言跟随 ComfyUI 或单独固定为中文/English,并严格三选一显示悬浮入口、运行按钮旁的原生顶部入口或仅使用扩展菜单。只有选择悬浮入口时才显示大小和样式选项。顶部栏重绘不会擅自改变所选模式,扩展菜单命令则始终保留,便于恢复设置。
<br/>
扫描要连 C 站。连不上也能用:选离线扫描,只从模型文件推断底模,以后能连上时再扫一次会自动补上(见下面的“网络和备份”)。
<details>
<summary><b>📖 点击展开:标准操作指南(图文步骤)</b></summary>
<summary><b>📖 每个页面和工具的详细说明</b></summary>
<br/>
窗口左侧的图标栏,每个页面一个图标。
#### 1. 顶部主工作区导航 (三大顶栏视图)
插件顶部居中提供 3 个核心工作区入口:
* **模型 (📦)**:主模型浏览器。左侧为模型文件夹分类树(`checkpoints`、`unet`、`loras` 等),中间展示模型卡片网格、封面图预览与触发词。
* **图库 (🖼️)**:出图历史浏览器。原生读取本地 `output` 目录。顶部搜索栏支持输入提示词、模型名、LoRA、seed、文件名或模型哈希,回车即可生成独立的搜索标签块,点击标签块上的 `×` 可快速移除该过滤项;直接将图片拖拽至 ComfyUI 画布可原地恢复内嵌工作流。
* **工作流 (🪡)**:工作流配方工坊。提供 `[全部]`、`[完整工作流]`、`[局部子图]` 子过滤器与标签筛选;点击右上角 `保存当前工作流` 即可将当前画布打包为配方;直接按住配方卡片拖拽到 ComfyUI 画布空白处释放即可立即载入。
| 页面 | 在这里做什么 |
| :--- | :--- |
| **🏠 主页** | 想做什么就点哪张卡片。还有上手第一步(扫描模型文件夹、界面导览)和最近的操作记录。 |
| **🕘 记录** | 按天列出插件做过的每一处改动。<br>画布上:哪个节点的设置从什么改成了什么、加了或删了哪些节点、打开了哪个工作流。<br>文件上:模型、封面、工作流、素材、笔记、图片、音频。<br>**在画布上找到** 可以直接跳到那个节点;画布上的改动在没被再改过之前可以点 **撤销**,其余只记录。你自己在画布上的修改不会记进来。 |
| **📦 模型** | 模型带封面、触发词和底模信息。<br>顶部标签切换类型(Checkpoint、LoRA、VAE…),连同子文件夹一起列出;左侧文件夹列表可以只看某个文件夹。<br>**+** 一键在画布上创建加载节点;也可以改名、写备注、换封面,或只扫描这一个模型。 |
| **🖼️ 图库** | 读取 ComfyUI 的 `output` 文件夹。<br>可以按提示词、模型、LoRA、seed、文件名或模型哈希搜索。<br>点开图片能看到生成参数;拖到画布上可以还原当时的工作流。<br>觉得好就点 **☆**:存成整个工作流、搭配(模型 + LoRA + 提示词),或者只存提示词。 |
| **🪡 工作流** | 存下的工作流:把完整工作流或其中一段,连同模型、封面、备注和参数一起存下来。<br>卡片会显示这台电脑上模型是否齐全;拖到画布上即可载入。<br>可以比较、恢复历史版本。<br>卡片上的导出按钮能打成**配方包**(压缩包)发给别人,对方拖进 ⇅ 就多一张工作流卡片。 |
| **🧩 搭配** | 把常用的主模型、几个 LoRA 和一段提示词存成一个搭配。<br>按 **放到画布**(或把卡片拖到画布上),就放出一组连好线的节点;画布上原有的东西不动。 |
| **✍️ 提示词** | 提示词工坊,浏览器里的一个页面;点“停到画布旁”可以移到画布旁边,边看画布边改。选中一个提示词节点,它的提示词框就以词块的样子出现在工坊里,直接改:点一下调权重,双击改字,拖动换位置,输入就加(中文、日文等会自动译成英文),还能在每个词下面显示它的中文、日文、韩文等语言的意思。<br>点词卡(几张常用的,加上你存的)加进去,或者直接拖到画布的提示词框里(正面绿框、负面红框),拖到空白处就新建一个提示词节点。没选中提示词节点时写的是草稿。 |
| **🎙️ 角色语音 / 🎧 音频库** | 装了 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 后可用:<br>导入 GPT-SoVITS 角色,给每种情绪选参考音频,修正读音;切到“配音”写台词并直接生成。<br>生成的音频都在音频库里。没装时,页面会说明怎么安装,其他功能不受影响。 |
#### 2. 底部导航与实用工具箱 (🧰 第一个图标:实用工具箱)
* **布局结构**:侧边栏左下角常驻 6 个固定图标按钮:
1. **实用工具箱 (🧰)**:左侧首个图标,点击展开工具箱浮窗面板。
2. **扫描向导 (🔄)**:直达本地模型扫描与哈希/元数据建立。
3. **模型医生 (🩺)**:直达工作流爆红诊断与缺失模型自动自愈。
4. **节点助手 (🤖)**:直达画布选中节点模型信息查看、可视化换模与参数预设注入。
5. **素材库 (✨)**:直达素材资产管理与多态画布拖拽。
6. **全局设置 (⚙️)**:最右侧齿轮图标,直达全局偏好设置。
* **实用工具箱浮窗**:点击首个图标(**🧰 实用工具箱**)打开工具箱窗口,集中收纳了未在底栏常驻的 5 大扩展工具:
1. **导入导出 (⇄)**:无损 AMB 格式工作流分享码导入与导出,支持从文本分享码原地解析还原画布,或将当前画布打包生成带校验哈希的分享码。
2. **提示词工坊 (🎛️)**:模块化提示词积木组装台,支持从出图历史或素材中抽取词卡并分块混音、正负词排序与组合。
3. **翻译助手 (🌐)**:内置中英双向提示词互译工具,无需离开 ComfyUI 即可完成提示词中英翻译。
4. **模型来源 (🔗)**:检视当前工作流或模型库中模型的在线下载来源(Civitai、HuggingFace、Liblib、ModelScope),支持一键在画布生成包含模型来源信息的 Note 说明节点。
5. **提示词笔记 (📑)**:保存在 ComfyUI 用户目录(`workflows/anomalous_notebooks`)下的轻量提示词草稿本,用于随时记录、整理与复用常用提示词片段。
图标栏上的工具:
#### 3. 扫描向导 (🔄 扫描向导)
* **入口位置**:底部快捷栏第 2 个图标。
* **操作步骤**:首次使用或添加新模型后打开,选择扫描范围(全部路径、仅缺失元数据或自定义目录)并执行,系统将自动建立本地模型库并拉取 C 站封面、标签与架构信息。
* **扫描**:读取模型文件夹,按文件哈希从 C 站获取封面、触发词和底模。不需要额外安装 Python 包。
* **模型检查**(原“模型医生”):打开工作流时自动检查,缺模型就在画布上方提示;本地改过名、换过文件夹的同一个文件一键换上,靠的是插件记在工作流和图片里的模型指纹(哈希和文件大小;**设置 → 在工作流里记下模型指纹**,默认打开);没有指纹时,同名文件会列出来让你确认。其余的在检查页里自己挑或去 Civitai 找。
* **当前节点**(原“节点助手”):选中画布上的节点,查看它用的模型。可以看图换模型、插入 LoRA;存过的同类节点参数列在一起,每条写明会改哪几项,一键套用;存下的参数也在这里删除。
* **工具回到各自的页面**:分享与导入在“工作流”页(⇅:把画布上的工作流变成一段短短的分享码,有汉字码和字母码;也能粘贴分享码,或拖入工作流文件、图片、备份压缩包),模型来源(每个模型去哪下载)在“模型”页和模型检查里,翻译在“当前节点”的提示词框上。
* **设置**(最下面):语言、字号、缩略图、视频封面、显示哪些模型文件夹,以及提交反馈。主页和设置里的 **报告问题** 会打开一个已经填好版本和显卡信息的 GitHub 页面。
#### 4. 拯救爆红 (🩺 模型医生)
* **入口位置**:底部快捷栏第 3 个图标。
* **操作步骤**:载入他人工作流或图片出现红框缺失报错时,点击模型医生即可自动比对本地模型哈希与字节大小,一键批量映射为本地正确路径。卡片上的“查看哈希”可展开内嵌指纹与磁盘文件的 SHA256 逐项比对面板。
**接入 AI 软件(MCP)**:Claude、Cursor、Cherry Studio 等支持 MCP 的 AI 软件,可以通过 `http://127.0.0.1:8188/anomalous/mcp` 查你的模型、出图和存下的提示词,回答相关问题;也能帮你改当前工作流(提示词、模型、LoRA、搭配、缺失的模型),每次改动都能用 Ctrl+Z 撤销。只限本机。设置方法见 [docs/guides/mcp.md](docs/guides/mcp.md)。
#### 5. 选中交互与预设 (🤖 节点助手)
* **入口位置**:底部快捷栏第 4 个图标。
* **动作功能**:在画布选中模型节点后,可在“动作”页查看高清预览图与触发词,支持看图一键替换模型或在兼容链路前后插入 LoRA。
* **参数预设**:切换至“参数预设”页,可读取工作流配方中同类型节点的保存参数并一键注入画布(自动保留种子等易变数值)。
</details>
#### 6. 统一素材库与多态画布拖拽 (✨ 素材库与多态拖拽)
* **入口位置**:底部快捷栏第 5 个图标。
* **保存素材**:在出图图库或配方详情中打开任意生成 PNG 的参数面板,可一键将图片、完整工作流、模型依赖与可复用节点参数打包收藏。
* **多态画布拖拽**:
- 将工作流素材直接拖拽至空白画布,即可原地恢复并打开完整工作流。
- 将纯提示词素材拖拽至空白画布,会自动创建带有正负区分色(深红 `#532323` 为负向 / 墨绿 `#235327` 为正向)的原生 `CLIPTextEncode` 节点。
- 将素材拖拽至画布已有节点上时,会自动通过语义探针将匹配参数注入目标节点中。
* **本地存储**:素材存储于 ComfyUI 用户目录下的 `workflows/anomalous_materials`,支持原子写入。
<details>
<summary><b>⚙️ 打开方式、快捷键和更新</b></summary>
#### 7. 全局设置面板 (⚙️ 设置)
* **入口位置**:侧边栏左下角最右侧第 6 个图标(**齿轮图标 ⚙️**)。
* **个性调节**:支持中英文界面切换、字体大小缩放、视频封面悬停/常开播放、缩略图优化与目录黑名单管理。
打开方式有三种:按 **Shift + B**(再按一次关闭)、点击画布上的悬浮按钮,或者从 ComfyUI 菜单 **扩展 → Anomalous Model Browser** 打开。在 **ComfyUI 设置 → Anomalous Model Browser → 界面** 里,可以修改快捷键、插件语言和打开方式(悬浮按钮、运行按钮旁的按钮,或只用扩展菜单)。
> [!WARNING]
> **测试功能数据安全提醒:** 工作流配方、素材库与参数预设目前属于活跃测试阶段,更新插件前建议备份 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters` 文件夹。
**更新插件**:
- 点窗口右上角的 **!**,可以看到当前版本;
- 点 **检查更新 / 切换版本** 打开版本面板,可以更新到最新版、回退到以前的版本,或回到最新版;
- 换完版本后重启 ComfyUI。插件不会自己联网检查更新。
</details>
<details>
<summary><b>🌐 网络和备份</b></summary>
**在云服务器上(AutoDL 等):** 海外的云服务器完整支持;国内的云服务器只能部分支持(多半连不上 Civitai,查模型不稳定,封面基本下不来)。目前还没有完整测过(只实测过一台 AutoDL 服务器),用的时候请留意。说明里有一节写给 AI 看的步骤,把链接丢给 AI 就能让它带你做。从别的电脑打开时插件默认只能浏览。用 SSH 隧道打开就能照常用,或者见 [docs/guides/cloud-server.md](docs/guides/cloud-server.md)。
**关于网络:** 扫描模型要连 Civitai(C 站)查封面和触发词,提示词翻译默认用谷歌翻译,这两样在国内一般要开网络代理才能用。不开代理也行:扫描页选 **离线**(联网扫描连不上时也会自动改成这样),只从模型文件推断底模,没有封面和触发词,以后能连上时再扫一次会自动补上。下载缺失的模型也要联网:Hugging Face 的模型可以在 **设置 → 模型下载** 里打开“Hugging Face 用国内镜像”,不用代理;C 站的没有镜像。(civitai.com 连不上时插件会自动试 Civitai 的另一个地址 civitai.red,有的网络能连上,比如国内的云服务器,这时能认出模型名字和触发词,封面多半还是下不来。)浏览模型、图库、工作流和搭配都不用联网。
**备份自己的数据:** **设置 → 备份 → 导出**,把工作流、搭配、存下的提示词和节点参数、参数预设,以及你给模型改的名字、备注和封面打成一个压缩包,放到云端硬盘或 U 盘里;换电脑或重装后用 **导入** 恢复。(它们存在 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_notebooks`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters`。)
</details>
---
### 🧪 Tests (测试)
The plugin's tests are in `tests/`; run them all 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 许可证开源,可自由使用、修改与分发。
+5 -1
View File
@@ -1,11 +1,15 @@
import os
from .api import setup_routes
from .api.usage_ledger import note_prompt
from server import PromptServer
WEB_DIRECTORY = "./web"
# 注册 API 路由
setup_routes(PromptServer.instance.app)
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 = {}
+153 -99
View File
@@ -1,99 +1,153 @@
from . import (
folder_types, gallery_routes, materials, media_routes, model_catalog,
model_media, model_metadata, model_resolution, recipe_packages, recipes,
translation_routes,
)
from .scanner import *
from .config import *
from .notebooks import *
from .parameters import *
from aiohttp import web
@web.middleware
async def no_cache_extension_middleware(request, handler):
response = await handler(request)
if request.path.startswith('/extensions/Anomalous_Model_Browser'):
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
def setup_routes(app):
if hasattr(app, 'middlewares') and no_cache_extension_middleware not in app.middlewares:
app.middlewares.append(no_cache_extension_middleware)
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/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_post('/anomalous/scan', api_scan_folder)
app.router.add_get('/anomalous/scan_status', api_scan_status)
app.router.add_get('/anomalous/find_model', model_catalog.api_find_model)
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/clean_civitai_info', api_clean_civitai_info)
app.router.add_get('/anomalous/compatible_models', model_catalog.api_compatible_models)
app.router.add_get('/anomalous/notebooks', api_get_notebooks)
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_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)
# Recipe Routes
app.router.add_get('/anomalous/recipes', recipes.api_get_recipes)
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)
app.router.add_get('/anomalous/recipe_parameter_gallery', recipes.api_get_recipe_parameter_gallery)
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/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)
app.router.add_get('/anomalous/recipe_version', recipes.api_get_recipe_version)
app.router.add_post('/anomalous/restore_recipe_version', recipes.api_restore_recipe_version)
app.router.add_post('/anomalous/refresh_recipe_identity', recipes.api_refresh_recipe_identity)
app.router.add_post('/anomalous/export_recipe_package', recipe_packages.api_export_recipe_package)
app.router.add_post('/anomalous/import_recipe_package_inspect', recipe_packages.api_import_recipe_package_inspect)
app.router.add_post('/anomalous/import_recipe_package_commit', recipe_packages.api_import_recipe_package_commit)
# Curated Material Library Routes
app.router.add_get('/anomalous/materials', materials.api_get_materials)
app.router.add_get('/anomalous/material_full', materials.api_get_material_full)
app.router.add_get('/anomalous/material_asset', materials.api_get_material_asset)
app.router.add_get('/anomalous/materials/by_node_type', materials.api_get_materials_by_node_type)
app.router.add_post('/anomalous/inspect_image_material', materials.api_inspect_image_material)
app.router.add_post('/anomalous/save_image_material', materials.api_save_image_material)
app.router.add_post('/anomalous/save_parameter_material', materials.api_save_parameter_material)
app.router.add_post('/anomalous/save_prompt_note_material', materials.api_save_prompt_note_material)
app.router.add_post('/anomalous/save_prompt_plan', materials.api_save_prompt_plan)
app.router.add_post('/anomalous/delete_material', materials.api_delete_material)
app.router.add_post('/anomalous/update_material', materials.api_update_material)
app.router.add_post('/anomalous/translate', translation_routes.api_translate)
app.router.add_get('/anomalous/base_models', model_catalog.api_base_models)
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/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)
app.router.add_post('/anomalous/scan_all', api_scan_all)
app.router.add_get('/anomalous/global_scan_status', api_global_scan_status)
app.router.add_get('/anomalous/scan_missing_models_status', api_scan_missing_models_status)
app.router.add_post('/anomalous/clear_cache', media_routes.api_clear_cache)
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_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_post('/anomalous/scan_missing_models', api_scan_missing_models)
from . import (
activity_log, audio_catalog, folder_types, gallery_routes, materials, media_routes, model_catalog,
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,
)
from .scanner import *
from .config import *
from .notebooks import *
from .parameters import *
import os
from aiohttp import web
# 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
async def no_cache_extension_middleware(request, handler):
"""Make browsers revalidate this plugin's JS/CSS so a normal refresh picks up updates.
`no-cache` still allows 304 responses via ETag/Last-Modified. This replaces
per-import `?v=` query strings, which load a second module instance whenever
two files import the same module with different URLs.
"""
response = await handler(request)
if request.path.startswith(EXTENSION_STATIC_PREFIX):
response.headers['Cache-Control'] = 'no-cache'
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)
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/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)
app.router.add_post('/anomalous/scan', api_scan_folder)
app.router.add_get('/anomalous/scan_status', api_scan_status)
app.router.add_get('/anomalous/find_model', model_catalog.api_find_model)
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_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/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)
app.router.add_get('/anomalous/recipe_parameter_gallery', recipes.api_get_recipe_parameter_gallery)
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)
app.router.add_get('/anomalous/recipe_version', recipes.api_get_recipe_version)
app.router.add_post('/anomalous/restore_recipe_version', recipes.api_restore_recipe_version)
app.router.add_post('/anomalous/refresh_recipe_identity', recipes.api_refresh_recipe_identity)
app.router.add_post('/anomalous/export_recipe_package', recipe_packages.api_export_recipe_package)
app.router.add_post('/anomalous/import_recipe_package_inspect', recipe_packages.api_import_recipe_package_inspect)
app.router.add_post('/anomalous/import_recipe_package_commit', recipe_packages.api_import_recipe_package_commit)
# Curated Material Library Routes
app.router.add_get('/anomalous/materials', materials.api_get_materials)
app.router.add_get('/anomalous/material_full', materials.api_get_material_full)
app.router.add_get('/anomalous/material_asset', materials.api_get_material_asset)
app.router.add_get('/anomalous/materials/by_node_type', materials.api_get_materials_by_node_type)
app.router.add_post('/anomalous/inspect_image_material', materials.api_inspect_image_material)
app.router.add_post('/anomalous/save_image_material', materials.api_save_image_material)
app.router.add_post('/anomalous/save_prompt_plan', materials.api_save_prompt_plan)
app.router.add_post('/anomalous/save_node_material', node_material.api_save_node_material)
app.router.add_post('/anomalous/delete_material', materials.api_delete_material)
app.router.add_post('/anomalous/update_material', materials.api_update_material)
app.router.add_post('/anomalous/mark_material_moved', materials.api_mark_material_moved)
app.router.add_post('/anomalous/translate', translation_routes.api_translate)
app.router.add_get('/anomalous/base_models', model_catalog.api_base_models)
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/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)
app.router.add_post('/anomalous/scan_all', api_scan_all)
app.router.add_get('/anomalous/global_scan_status', api_global_scan_status)
app.router.add_post('/anomalous/clear_cache', media_routes.api_clear_cache)
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)
app.router.add_get('/anomalous/audio_gallery', audio_catalog.api_get_audio_gallery)
app.router.add_post('/anomalous/delete_audio_gallery', audio_catalog.api_delete_audio_gallery)
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", "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
+304
View File
@@ -0,0 +1,304 @@
"""Activity log: what Anomalous changed, newest first, kept in the ComfyUI user folder.
Two sources feed it. The middleware records this plugin's own write requests
(and Anomalous_TTS's) after they succeed: files such as models, covers, recipes,
materials, notes, presets, images and audio. The browser posts the canvas changes
it made (`POST /anomalous/activity`). Only the newest MAX_ENTRIES are kept and long
values are shortened, so the file stays small. Recording never fails a request:
the log is a convenience, not part of the action.
"""
import asyncio
import json
import logging
import os
import threading
import time
import uuid
from aiohttp import web
import folder_paths
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
# Write routes worth a line in the log → the action id the browser has words for. Scans are
# recorded when they end, with what they found (api/scan_report.py), not here.
ROUTE_ACTIONS = {
'/anomalous/delete_model': 'model_delete',
'/anomalous/update_metadata': 'model_edit',
'/anomalous/set_custom_cover': 'model_cover',
'/anomalous/upload_custom_cover': 'model_cover',
'/anomalous/save_recipe': 'recipe_save',
'/anomalous/update_recipe': 'recipe_edit',
'/anomalous/delete_recipe': 'recipe_delete',
'/anomalous/restore_recipe_version': 'recipe_restore',
'/anomalous/set_recipe_gallery_cover': 'recipe_cover',
'/anomalous/import_recipe_package_commit': 'recipe_import',
'/anomalous/save_image_material': 'material_save',
'/anomalous/save_prompt_plan': 'material_save',
'/anomalous/update_material': 'material_edit',
'/anomalous/delete_material': 'material_delete',
'/anomalous/save_notebook': 'note_save',
'/anomalous/delete_notebook': 'note_delete',
'/anomalous/save_parameter': 'preset_save',
'/anomalous/rename_parameter': 'preset_rename',
'/anomalous/delete_parameter': 'preset_delete',
'/anomalous/delete_gallery_image': 'image_delete',
'/anomalous/delete_audio_gallery': 'audio_delete',
'/anomalous/save_audio_preview': 'audio_save',
'/anomalous/clear_cache': 'cache_clear',
'/anomalous_tts/import/commit': 'voice_import',
'/anomalous_tts/settings': 'voice_settings',
}
# Request fields that name what was changed, in order of preference.
TARGET_FIELDS = ('name', 'character', 'filename', 'target_filename', 'recipe_filename',
'notebook_filename', 'new_name', 'path', 'file', 'target')
# What a handler attaches to its request for the log: request[ACTIVITY_DETAIL] = {fields, files}.
# web.RequestKey is newer than the aiohttp some ComfyUI installs have; a plain key works there.
ACTIVITY_DETAIL = web.RequestKey("activity_detail", dict) if hasattr(web, "RequestKey") else "activity_detail"
_lock = threading.RLock()
_entries = None
_listeners = [] # called with each new entry once it is saved (ai_tasks.note_activity)
def log_path():
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')
os.makedirs(directory, exist_ok=True)
return os.path.join(directory, 'activity_log.json')
def _load():
global _entries
if _entries is None:
try:
with open(log_path(), encoding='utf-8') as source:
data = json.load(source)
_entries = [entry for entry in data if isinstance(entry, dict)] if isinstance(data, list) else []
except (OSError, ValueError):
_entries = []
return _entries
def _encoded_size(entries):
return len(json.dumps(entries, ensure_ascii=False, separators=(',', ':')).encode('utf-8'))
def _trim(entries):
"""Drop the oldest entries until the list fits MAX_ENTRIES and MAX_LOG_BYTES."""
del entries[MAX_ENTRIES:]
while len(entries) > 1 and _encoded_size(entries) > MAX_LOG_BYTES:
del entries[max(1, len(entries) * 9 // 10):]
def _text(value):
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
def _short(value, limit=MAX_VALUE_CHARS):
text = _text(value)
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], 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_CHANGE_CHARS and len(new) <= MAX_CHANGE_CHARS:
return out
same = 0
while same < min(len(old), len(new)) and old[same] == new[same]:
same += 1
start = max(0, same - DIFF_CONTEXT_CHARS)
def cut(text):
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
def add_entry(source, action, target='', detail=None):
"""Append one entry (newest first) and save. Returns the entry."""
entry = {
'id': uuid.uuid4().hex[:12],
'time': time.time(),
'source': source,
'action': action,
'target': _short(target or ''),
}
if detail:
entry['detail'] = detail
with _lock:
entries = _load()
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()
picked = [entry for entry in entries if not source or entry.get('source') == source]
return picked[:limit]
def clear():
global _entries
with _lock:
_entries = []
atomic_write_json(log_path(), [])
def _target_of(body):
if not isinstance(body, dict):
return ''
for field in TARGET_FIELDS:
value = body.get(field)
if isinstance(value, str) and value.strip():
return os.path.basename(value.strip().rstrip('/\\')) or value.strip()
return ''
def _failed(response):
"""True when a response reports failure, including the 200 + {"status": "error"} style."""
if response.status >= 300:
return True
if getattr(response, 'content_type', '') != 'application/json' or not getattr(response, 'body', None):
return False
try:
data = json.loads(response.body)
except (TypeError, ValueError):
return False
return isinstance(data, dict) and (data.get('success') is False or data.get('ok') is False
or data.get('status') in ('error', 'failed'))
async def _request_body(request):
"""The body the handler already read (aiohttp caches it); {} when it was not JSON or a form."""
try:
if request.content_type == 'application/json':
return await request.json()
if request.content_type in ('multipart/form-data', 'application/x-www-form-urlencoded'):
return dict(await request.post())
except (ValueError, UnicodeDecodeError, RuntimeError):
pass
return {}
def _clean_detail(detail):
"""What a handler attached for the log (request[ACTIVITY_DETAIL]): the fields it changed
({field, before, after}) and the files it moved, values shortened."""
if not isinstance(detail, dict):
return None
cleaned = {}
if isinstance(detail.get('fields'), list):
cleaned['fields'] = [_short_values(item, ('field', 'before', 'after'))
for item in detail['fields'][:MAX_CHANGES] if isinstance(item, dict)]
if isinstance(detail.get('files'), list):
cleaned['files'] = [_short(name) for name in detail['files'][:MAX_CHANGES]]
return {key: value for key, value in cleaned.items() if value} or None
@web.middleware
async def activity_middleware(request, handler):
response = await handler(request)
action = ROUTE_ACTIONS.get(request.path) if request.method == 'POST' else None
if action and not _failed(response):
try:
target = _target_of(await _request_body(request))
detail = _clean_detail(request.get(ACTIVITY_DETAIL))
await asyncio.get_running_loop().run_in_executor(None, add_entry, 'file', action, target, detail)
except Exception: # noqa: BLE001 - the log must never fail the action it records
logging.getLogger(__name__).warning('Anomalous activity log: could not record %s', request.path, exc_info=True)
return response
def _clean_changes(changes):
cleaned = []
for change in changes[:MAX_CHANGES]:
if not isinstance(change, dict):
continue
item = _short_values(change, ('kind', 'node', 'type', 'widget', 'before', 'after'))
if item.get('kind') in ('changed', 'wired', 'added', 'removed', 'opened'):
cleaned.append(item)
return cleaned
async def api_get_activity(request):
try:
limit = max(1, min(MAX_ENTRIES, int(request.query.get('limit', '100'))))
except ValueError:
limit = 100
source = request.query.get('source') or None
entries = await asyncio.get_running_loop().run_in_executor(None, list_entries, limit, source)
return web.json_response({'entries': entries})
async def api_post_activity(request):
"""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:
return web.json_response({'error': 'Invalid JSON body'}, status=400)
if not isinstance(body, dict) or not isinstance(body.get('changes'), list):
return web.json_response({'error': 'changes must be a list'}, status=400)
changes = _clean_changes(body['changes'])
if not changes:
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})
async def api_clear_activity(request):
await asyncio.get_running_loop().run_in_executor(None, clear)
return web.json_response({'ok': True})
def register_routes(app):
if hasattr(app, 'middlewares') and activity_middleware not in app.middlewares:
app.middlewares.append(activity_middleware)
app.router.add_get('/anomalous/activity', api_get_activity)
app.router.add_post('/anomalous/activity', api_post_activity)
app.router.add_post('/anomalous/activity/clear', api_clear_activity)
+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 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 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 [] if option.get("recommended")]
if block.get("type") != "choices" 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
+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 ""
+101
View File
@@ -0,0 +1,101 @@
"""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_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"))
else:
words.append("The user stopped a run")
return words
+223
View File
@@ -0,0 +1,223 @@
"""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).
"""
import json
import os
import threading
import time
from . import action_catalog, ai_tasks
from .path_utils import atomic_write_json
POLICIES = ("auto", "ask", "never")
RUN_WAITS = ("call", "wait")
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 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"
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).")
now = time.time() if now is None else now
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_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
+197
View File
@@ -0,0 +1,197 @@
"""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
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)}}
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:
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
+598
View File
@@ -0,0 +1,598 @@
"""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),
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 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 os
import re
import threading
import time
import uuid
import folder_paths
from . import ai_marks, ai_tasks
from .mcp_tools import tool_text
MAX_BLOCKS = 30
MAX_ITEMS = 12
MAX_ROWS = 30
MAX_COLUMNS = 8
MAX_WAIT = 55
TONES = ("normal", "muted", "tip", "warning")
LAYOUTS = ("row", "grid", "compare")
INTERACTIVE = ("choices", "input", "picks")
INPAINT_NOTE = (
"inpaint_image is the picture with the area to repaint: LoadImage(image=inpaint_image) gives the picture and, as MASK, that area. "
"inpaint_image is already in ComfyUI's input folder, ready: do not check it with a command; "
"get_image_info(image=inpaint_image, folder='input', include_image=true) shows it and the area, zoom_image a part "
"of it larger. Repaint with a workflow; do not paint the picture with code of your own. "
"Use the picture's own model and prompt (get_image_info on image) and build it with add_node and its wiring "
"(in the picture's workflow, LoadImage -> VAEEncode(pixels, vae) -> SetLatentNoiseMask(samples, mask) in place "
"of the empty latent), e.g. new_workflow: CheckpointLoaderSimple, "
"LoadImage, CLIPTextEncode x2, VAEEncode(pixels, vae) -> SetLatentNoiseMask(samples, mask) -> KSampler "
"(denoise 0.5-0.8 keeps the look; 1.0 with VAEEncodeForInpaint(grow_mask_by 8) replaces it) -> VAEDecode -> "
"SaveImage; GrowMask softens the edge. Say in report what you set and why, ask_user the strength if unsure, "
"and show the old and new picture with a slider panel."
)
COLORS = ("red", "green", "blue", "yellow", "purple")
MAX_MARKS = 8
MAX_NODES = 24
MAX_EDGES = 40
_answered = threading.Condition(ai_tasks._lock)
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
found = ai_tasks._output_media(str(image or ""))
if not found:
raise PanelError(f"{where}: {image!r} is not a picture in ComfyUI's output folder (subfolder/name.png, as search_images 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 _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")}
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"))
elif kind == "picks":
items, ids = [], set()
raw = _list(block.get("items"), MAX_ITEMS, f"{where}.items")
if len(raw) < 2:
raise PanelError(f"{where}.items needs 2 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)
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 {}),
**({"label_key": option["label_key"]} if re.fullmatch(r"ai[A-Z]\w{1,40}", str(option.get("label_key"))) 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 == "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, 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 show(client, title, blocks, now=None, session="", task_id=None, tool="show_panel", extra=None):
title = _text(title, 120, "title", True)
panel = {"id": uuid.uuid4().hex[:10], "title": title, "blocks": _block_ids(clean_blocks(blocks)), "closed": False}
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"] 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])
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") 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 and models 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
+158
View File
@@ -0,0 +1,158 @@
"""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 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 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}
+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
+55
View File
@@ -0,0 +1,55 @@
"""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."""
import time
import uuid
from . import ai_task_runs, ai_tasks
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}
+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
+221
View File
@@ -0,0 +1,221 @@
"""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.
COMFORT_MP_12GB = 4.2
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
comfort = COMFORT_MP_12GB * max(0.5, min(3.0, (vram_total_mb or 12288) / 12288))
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 > comfort * 2:
warnings.append({"kind": "big_decode", "node": str(node_id), "title": title,
"width": round(found["width"]), "height": round(found["height"])})
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_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."
+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
+62
View File
@@ -0,0 +1,62 @@
"""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."""
import time
import uuid
from . import action_catalog, activity_log, 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):
"""What the user changed, in words for the AI."""
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')}: {change.get('before')} -> {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
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(_change_text(entry), 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
+55
View File
@@ -0,0 +1,55 @@
"""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."""
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 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."}
+442
View File
@@ -0,0 +1,442 @@
"""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_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
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 _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, 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]
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 = previews[-MAX_PREVIEWS:]
if (done < len(prompts) and results == step["results"] and step.get("prompts") == len(prompts)
and previews == step.get("previews", []) 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
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)
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)):
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"]
return f"Your run finished in {round(run.get('ms', 0) / 1000)} s with {len(pictures)} picture(s): {', '.join(pictures[:8])}."
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("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": ("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."),
"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
+592
View File
@@ -0,0 +1,592 @@
"""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":
task["title"] = _short(title, MAX_TITLE)
task["title_from"] = "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") 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):
"""An output-folder file the AI names ("subfolder/name.png"), as an image result, or None."""
root = os.path.realpath(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": "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"]
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(images), "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
+291
View File
@@ -0,0 +1,291 @@
"""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 (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_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["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, {"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_tasks.remove_task(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 "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 | skip: true} - the user's answer on a panel."""
from . import ai_panels, ai_repaint
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:
# A repaint area picked: its picture is made first (outside the lock), so the AI gets it with the answer.
extra = await asyncio.to_thread(ai_repaint.prepare, task_id, step_id, body.get("choice"), body.get("text"), skip)
except ai_repaint.RepaintError 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)
return web.json_response({"error": str(error)}, status=400)
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 {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."""
def settings():
return {"policies": ai_gates.policies(), "canvas_tab": ai_task_canvas.default_mode(),
"runs": ai_gates.run_wait(), "answer_wait": ai_gates.answer_wait(), "no_answer": ai_gates.no_answer()}
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 "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"])
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_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_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)
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}/runs/{step_id}/stop", api_stop)
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_gates", api_gates)
app.router.add_post("/anomalous/ai_gates", api_gates)
+155
View File
@@ -0,0 +1,155 @@
"""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). 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")
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}")
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."""
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)"}
for kind in KINDS:
if box.get(kind):
parts.append(f"{labels[kind]}: {', '.join(box[kind])}")
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 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)."""
box = effective(ai_tasks.get_task(task_id)) if task_id else None
if not box or box.get("mode") != "only":
return []
return sorted({name for name in types if name in info 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()
+86
View File
@@ -0,0 +1,86 @@
"""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,
by the names and ids the AI's tools use. 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
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"]})
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}, 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,
"prompts": prompt_entries(combos, workflows), "models": _models()})
return _cache["data"]
+256
View File
@@ -0,0 +1,256 @@
"""Generated audio: the audio gallery, Preview Audio results, and streaming them."""
import asyncio
import os
import shutil
import time
from urllib.parse import quote
import folder_paths
from aiohttp import web
from .audio_metadata import generation_info
from .path_utils import resolve_within
from .trash import move_to_trash, trash_failure
AUDIO_EXTENSIONS = {'.wav', '.mp3', '.flac', '.ogg', '.opus', '.m4a'}
AUDIO_CONTENT_TYPES = {
'.wav': 'audio/wav',
'.mp3': 'audio/mpeg',
'.flac': 'audio/flac',
'.ogg': 'audio/ogg',
'.opus': 'audio/ogg',
'.m4a': 'audio/mp4',
}
AUDIO_ROOTS = {
"output": lambda: folder_paths.get_output_directory(),
"temp": lambda: folder_paths.get_temp_directory(),
}
PREVIEW_LIMIT = 30
SAVED_PREVIEW_SUBFOLDER = "audio"
# Revalidate instead of forbidding caches: overwritten files get a new ?v=mtime URL.
AUDIO_CACHE_HEADERS = {"Cache-Control": "no-cache"}
def _audio_url(rel_path, dir_type, version):
return f"/anomalous/audio_stream?path={quote(rel_path, safe='/')}&type={dir_type}&v={int(version)}"
async def api_serve_audio(request):
"""GET /anomalous/audio_stream?path=...&type=output|temp - Stream an audio file."""
rel_path = request.query.get("path", "").strip("/\\")
dir_type = request.query.get("type", "output")
if not rel_path:
return web.Response(status=400, text="Missing audio path")
if dir_type not in AUDIO_ROOTS:
return web.Response(status=400, text="Unknown audio folder")
base_dir = AUDIO_ROOTS[dir_type]()
try:
resolved_path = resolve_within(base_dir, rel_path)
except ValueError:
return web.Response(status=403, text="Forbidden path")
ext = os.path.splitext(resolved_path)[1].lower()
if ext not in AUDIO_EXTENSIONS:
return web.Response(status=415, text="Not an audio file")
if not os.path.isfile(resolved_path):
return web.Response(status=404, text="Audio file not found")
return web.FileResponse(resolved_path, headers={"Content-Type": AUDIO_CONTENT_TYPES[ext], **AUDIO_CACHE_HEADERS})
def _collect_audio_files(root_dir, dir_type):
"""Scan a folder tree for audio files, newest first."""
audios = []
if not os.path.isdir(root_dir):
return audios
for root, _, files in os.walk(root_dir):
for name in files:
if os.path.splitext(name)[1].lower() not in AUDIO_EXTENSIONS:
continue
try:
stat = os.stat(os.path.join(root, name))
except OSError:
continue
subfolder = os.path.relpath(root, root_dir)
clean_sub = "" if subfolder == "." else subfolder.replace(os.sep, '/')
rel_path = "/".join(part for part in (clean_sub, name) if part)
audios.append({
"filename": name,
"subfolder": clean_sub,
"size_bytes": stat.st_size,
"mtime": stat.st_mtime,
"audio_url": _audio_url(rel_path, dir_type, stat.st_mtime),
})
audios.sort(key=lambda a: a["mtime"], reverse=True)
return audios
_info_cache = {}
def _cached_generation_info(path, mtime, size):
"""Generation info keyed by (mtime, size) so unchanged files are parsed once."""
cached = _info_cache.get(path)
if cached and cached[0] == (mtime, size):
return cached[1]
info = generation_info(path)
if len(_info_cache) > 5000:
_info_cache.clear()
_info_cache[path] = ((mtime, size), info)
return info
def _attach_generation_info(root_dir, items):
for item in items:
path = os.path.join(root_dir, item["subfolder"], item["filename"])
item["generation"] = _cached_generation_info(path, item["mtime"], item["size_bytes"])
return items
def _search_text(item):
speech = (item.get("generation") or {}).get("speech") or ""
return f"{item['subfolder']}/{item['filename']}\n{speech}".lower()
def _gallery_page(output_dir, page, limit, query):
all_audios = _collect_audio_files(output_dir, "output")
if query:
# Searching reads every file's comments once; the cache keeps later searches cheap.
_attach_generation_info(output_dir, all_audios)
all_audios = [item for item in all_audios if query in _search_text(item)]
total = len(all_audios)
start = (page - 1) * limit
items = all_audios[start:start + limit]
if not query:
_attach_generation_info(output_dir, items)
return items, total, start + limit < total
def _positive_int(value, default, maximum=None):
try:
number = int(value)
except (TypeError, ValueError):
return default
number = max(1, number)
return min(maximum, number) if maximum else number
async def api_get_audio_gallery(request):
"""GET /anomalous/audio_gallery?page=1&limit=50&q=text - Generated audio in output, with the speech that produced it."""
page = _positive_int(request.query.get("page"), 1)
limit = _positive_int(request.query.get("limit"), 40, 100)
query = request.query.get("q", "").strip().lower()
items, total, has_more = await asyncio.to_thread(_gallery_page, folder_paths.get_output_directory(), page, limit, query)
return web.json_response({
"success": True,
"audios": items,
"total": total,
"page": page,
"has_more": has_more,
})
def _list_previews(temp_dir):
items = _collect_audio_files(temp_dir, "temp")[:PREVIEW_LIMIT]
return _attach_generation_info(temp_dir, items)
async def api_get_audio_previews(request):
"""GET /anomalous/audio_previews - Unsaved preview audio in ComfyUI's temp folder (cleared on restart)."""
items = await asyncio.to_thread(_list_previews, folder_paths.get_temp_directory())
return web.json_response({"success": True, "audios": items})
def _save_preview(temp_dir, output_dir, subfolder, filename):
source = resolve_within(temp_dir, subfolder, filename)
ext = os.path.splitext(filename)[1].lower()
if ext not in AUDIO_EXTENSIONS:
raise ValueError("Only audio files can be saved")
if not os.path.isfile(source):
raise FileNotFoundError(filename)
dest_dir = resolve_within(output_dir, SAVED_PREVIEW_SUBFOLDER)
os.makedirs(dest_dir, exist_ok=True)
stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(os.path.getmtime(source)))
for counter in range(1, 1000):
name = f"Preview_{stamp}_{counter:02}{ext}"
target = os.path.join(dest_dir, name)
try:
# O_EXCL: never overwrite an existing file in the library.
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0))
except FileExistsError:
continue
try:
with os.fdopen(fd, "wb") as out, open(source, "rb") as src:
shutil.copyfileobj(src, out)
except Exception:
try:
os.remove(target)
except OSError:
pass
raise
stat = os.stat(target)
rel_path = f"{SAVED_PREVIEW_SUBFOLDER}/{name}"
item = {
"filename": name,
"subfolder": SAVED_PREVIEW_SUBFOLDER,
"size_bytes": stat.st_size,
"mtime": stat.st_mtime,
"audio_url": _audio_url(rel_path, "output", stat.st_mtime),
}
return _attach_generation_info(output_dir, [item])[0]
raise FileExistsError("No free file name")
async def api_save_audio_preview(request):
"""POST /anomalous/save_audio_preview {filename, subfolder} - Copy a temp preview into output/audio."""
try:
data = await request.json()
except Exception:
return web.json_response({"success": False, "error": "Invalid JSON payload"}, status=400)
filename = str(data.get("filename", "")).strip()
subfolder = str(data.get("subfolder", "")).strip().strip("/\\")
if not filename or os.path.basename(filename) != filename:
return web.json_response({"success": False, "error": "Invalid filename"}, status=400)
try:
item = await asyncio.to_thread(
_save_preview, folder_paths.get_temp_directory(), folder_paths.get_output_directory(), subfolder, filename)
except ValueError as e:
return web.json_response({"success": False, "error": str(e)}, status=403)
except FileNotFoundError:
return web.json_response({"success": False, "code": "missing", "error": "Preview no longer exists"}, status=404)
except OSError as e:
return web.json_response({"success": False, "error": f"Failed to save: {e}"}, status=500)
return web.json_response({"success": True, "audio": item})
async def api_delete_audio_gallery(request):
"""POST /anomalous/delete_audio_gallery - Delete one generated audio file."""
try:
data = await request.json()
except Exception:
return web.json_response({"success": False, "error": "Invalid JSON payload"}, status=400)
filename = str(data.get("filename", "")).strip()
subfolder = str(data.get("subfolder", "")).strip().strip("/\\")
if not filename or os.path.basename(filename) != filename:
return web.json_response({"success": False, "error": "Invalid filename"}, status=400)
if os.path.splitext(filename)[1].lower() not in AUDIO_EXTENSIONS:
return web.json_response({"success": False, "error": "Only audio files can be deleted here"}, status=415)
try:
target = resolve_within(folder_paths.get_output_directory(), subfolder, filename)
except ValueError:
return web.json_response({"success": False, "error": "Forbidden path"}, status=403)
if not os.path.isfile(target):
return web.json_response({"success": False, "error": "File not found"}, status=404)
try:
await asyncio.to_thread(move_to_trash, target)
except OSError as e:
return web.json_response({"success": False, "error": trash_failure(e)}, status=409)
return web.json_response({"success": True})
+136
View File
@@ -0,0 +1,136 @@
"""Read ComfyUI generation info embedded in saved audio (FLAC and Ogg/Opus comments).
ComfyUI's audio save/preview nodes store the API prompt as a `prompt` comment.
Only the fields the audio gallery shows are extracted; nothing is written.
"""
import json
import os
MAX_TAG_BYTES = 16 * 1024 * 1024
MAX_OGG_SCAN_BYTES = 32 * 1024 * 1024
SPEECH_INPUTS = ("speech", "text", "gen_text")
# "character": Anomalous_TTS (GPT-SoVITS) names the voice by character instead of a file.
SAMPLE_INPUTS = ("sample", "ref_audio", "audio_prompt", "character")
def _parse_vorbis_comment(data, offset=0):
"""Parse a Vorbis comment block into {lowercase key: value}."""
tags = {}
try:
vendor_len = int.from_bytes(data[offset:offset + 4], "little")
pos = offset + 4 + vendor_len
count = int.from_bytes(data[pos:pos + 4], "little")
pos += 4
for _ in range(min(count, 1024)):
length = int.from_bytes(data[pos:pos + 4], "little")
pos += 4
entry = data[pos:pos + length].decode("utf-8", "replace")
pos += length
key, sep, value = entry.partition("=")
if sep:
tags.setdefault(key.lower(), value)
except (IndexError, ValueError):
pass
return tags
def _flac_tags(handle):
if handle.read(4) != b"fLaC":
return {}
while True:
header = handle.read(4)
if len(header) < 4:
return {}
is_last = header[0] & 0x80
block_type = header[0] & 0x7F
length = int.from_bytes(header[1:4], "big")
if block_type == 4:
if length > MAX_TAG_BYTES:
return {}
return _parse_vorbis_comment(handle.read(length))
handle.seek(length, os.SEEK_CUR)
if is_last:
return {}
def _ogg_packets(handle, wanted=2):
"""Yield the first `wanted` logical packets of an Ogg stream."""
packet = b""
scanned = 0
found = 0
while found < wanted and scanned < MAX_OGG_SCAN_BYTES:
header = handle.read(27)
if len(header) < 27 or header[:4] != b"OggS":
return
segments = header[26]
lacing = handle.read(segments)
scanned += 27 + segments
for size in lacing:
packet += handle.read(size)
scanned += size
if size < 255:
yield packet
packet = b""
found += 1
if found >= wanted:
return
if len(packet) > MAX_TAG_BYTES:
return
def _ogg_tags(handle):
packets = list(_ogg_packets(handle, wanted=2))
if len(packets) < 2:
return {}
comment = packets[1]
if comment.startswith(b"OpusTags"):
return _parse_vorbis_comment(comment, 8)
if comment.startswith(b"\x03vorbis"):
return _parse_vorbis_comment(comment, 7)
return {}
def read_audio_tags(path):
"""Return {lowercase key: value} comments for FLAC/Ogg/Opus files, else {}."""
ext = os.path.splitext(path)[1].lower()
try:
with open(path, "rb") as handle:
if ext == ".flac":
return _flac_tags(handle)
if ext in (".ogg", ".opus"):
return _ogg_tags(handle)
except OSError:
pass
return {}
def generation_info(path):
"""Speech text, sample voice and seed of the TTS node that produced this file, if recorded."""
raw = read_audio_tags(path).get("prompt")
if not raw:
return None
try:
prompt = json.loads(raw)
except ValueError:
return None
if not isinstance(prompt, dict):
return None
nodes = [node for node in prompt.values() if isinstance(node, dict) and isinstance(node.get("inputs"), dict)]
# Prefer TTS nodes; otherwise any node with a speech-like text input.
nodes.sort(key=lambda node: "tts" not in str(node.get("class_type", "")).lower())
for node in nodes:
inputs = node["inputs"]
speech = next((inputs[key] for key in SPEECH_INPUTS if isinstance(inputs.get(key), str) and inputs[key].strip()), None)
if speech is None:
continue
sample = next((inputs[key] for key in SAMPLE_INPUTS if isinstance(inputs.get(key), str)), None)
seed = inputs.get("seed")
return {
"node_type": str(node.get("class_type") or ""),
"speech": speech[:4000],
"sample": sample.replace("\\", "/") if sample else None,
"seed": seed if isinstance(seed, (int, float)) else None,
}
return None
+591
View File
@@ -0,0 +1,591 @@
"""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 .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", "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(),
"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)
+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:
+373
View File
@@ -0,0 +1,373 @@
"""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
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"}
+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):
+86 -8
View File
@@ -9,11 +9,22 @@ from aiohttp import web
import folder_paths
from .image_search import filter_images
from .media_routes import (
CARD_POSTER_VIDEO_EXTENSIONS,
CARD_THUMBNAIL_STATIC_EXTENSIONS,
_build_card_thumbnail,
_build_video_poster,
)
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")
_gallery_snapshot_lock = threading.Lock()
_gallery_snapshot = {"root": None, "expires": 0, "images": []}
@@ -22,7 +33,8 @@ 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
try:
mtime = os.path.getmtime(os.path.join(root, filename))
@@ -32,7 +44,9 @@ def _collect_gallery_images(output_dir):
images.append({
"filename": filename,
"subfolder": "" if subfolder == "." else subfolder.replace(os.sep, '/'),
"type": "output", "mtime": mtime,
"type": "output",
"mtime": mtime,
"media_type": "video" if ext in GALLERY_VIDEO_EXTENSIONS else "image",
})
images.sort(key=lambda item: (item['mtime'], item['subfolder'], item['filename']), reverse=True)
return images
@@ -56,33 +70,94 @@ 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/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'))
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):
return web.json_response({"status": "error", "message": "File not found"}, status=404)
served = file_path
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)
elif ext in CARD_POSTER_VIDEO_EXTENSIONS:
poster = await asyncio.to_thread(_build_video_poster, file_path)
if poster:
served = poster
# 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_delete_gallery_image(request):
try:
data = await request.json()
except ValueError:
data = None
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
try:
filename = data.get("filename")
subfolder = data.get("subfolder", "")
@@ -95,7 +170,10 @@ async def api_delete_gallery_image(request):
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
if os.path.exists(file_path):
os.remove(file_path)
try:
await asyncio.to_thread(move_to_trash, file_path)
except OSError as e:
return web.json_response({"status": "error", "message": trash_failure(e)}, status=409)
_invalidate_gallery_snapshot()
return web.json_response({"status": "success"})
else:
+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)}
+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):
+48
View File
@@ -0,0 +1,48 @@
"""What each output image was kept as (GET /anomalous/kept_images), for the gallery's star:
the Workflow Recipe whose source image it is, the combo made from it (a notebook file with
`source_image`), the saved prompt taken from it (a prompt plan material whose source names
the image). Each is {filename, name}, or null when the image was not kept as that."""
import asyncio
from aiohttp import web
from .material_store import _list_materials, get_materials_dir
from .notebooks import _list_notebooks
from .recipe_store import _list_recipes, get_recipes_dir
KINDS = ("recipe", "combo", "prompt")
def _kept_images():
kept = {}
def mark(image, kind, filename, name):
if not isinstance(image, dict) or not isinstance(image.get("filename"), str) or not image["filename"]:
return
subfolder = image.get("subfolder") if isinstance(image.get("subfolder"), str) else ""
entry = kept.setdefault((subfolder, image["filename"]), dict.fromkeys(KINDS))
if entry[kind] is None:
entry[kind] = {"filename": filename, "name": str(name or "")}
for recipe in _list_recipes(get_recipes_dir()):
mark(recipe["data"].get("source_image"), "recipe", recipe["filename"], recipe["data"].get("name"))
for note in _list_notebooks():
mark(note["data"].get("source_image"), "combo", note["filename"], note["name"])
for material in _list_materials(get_materials_dir()):
if material.get("kind") == "prompt_plan":
mark(material.get("source_image"), "prompt", material["filename"], material.get("name"))
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)
except (OSError, ValueError):
return web.json_response({"status": "error", "message": "Could not list kept images"}, status=500)
return web.json_response({"status": "success", "images": images})
+117
View File
@@ -0,0 +1,117 @@
"""What only this computer may do. ComfyUI started with --listen lets other devices on the
network open the page: they may look (lists, searches, pictures, covers), but every change
under /anomalous/ (writing, moving and deleting files, downloads, scans, translations, backups,
version switches, saved settings) answers only requests from the computer ComfyUI runs on
(AGENTS.md 安全底线, rule 4). One middleware decides it for all routes: a request other than
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).
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
PREFIX = "/anomalous/"
PROXY_HEADERS = ("Forwarded", "X-Forwarded-For", "X-Real-IP", "X-Forwarded-Host")
LOCAL_NAMES = ("localhost",)
# Reads sent as POST (their lists are long): open to other devices too. They write nothing.
OPEN_POSTS = frozenset({
"/anomalous/resolve_paths_to_previews",
"/anomalous/resolve_hash_batch",
"/anomalous/inspect_image_material",
})
# GETs that are more than a read: a backup of all your data, a call to GitHub, your folders.
LOCAL_GETS = frozenset({
"/anomalous/backup/file",
"/anomalous/version/releases",
"/anomalous/tts_version/releases",
"/anomalous/import/folders",
"/anomalous/civitai_check",
"/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")
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.")
def loopback_host(name):
if not name:
return False
name = name.strip("[]").lower()
if name in LOCAL_NAMES:
return True
try:
return ipaddress.ip_address(name).is_loopback
except ValueError:
return False
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
if any(header in request.headers for header in PROXY_HEADERS):
return False
return loopback_host(urllib.parse.urlsplit("//" + request.headers.get("Host", "")).hostname)
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):
"""Whether a request to `path` with `method` must come from this computer."""
if not path.startswith(PREFIX) or path in OWN_CHECK:
return False
if method.upper() in READS:
return path in LOCAL_GETS
return path not in OPEN_POSTS
def refusal():
return web.json_response({"status": "error", "success": False, "error": "local_only", "code": "local_only",
"message": MESSAGE}, status=403)
@web.middleware
async def local_only_middleware(request, handler):
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 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
+2
View File
@@ -25,6 +25,8 @@ def _material_assets_dir(materials_dir, filename, create=False):
def _inspect_source_image(source_image):
source = _normalise_source_image(source_image)
if source is None:
raise ValueError("Missing source image")
if not source["filename"].lower().endswith(".png"):
raise ValueError("Only PNG output images can contain reusable workflow metadata")
source_path = _output_source_path(source)
+10
View File
@@ -371,6 +371,14 @@ def _recipe_link_fingerprint(recipe):
return hashlib.sha256(payload).hexdigest()
def _summary_source_image(source):
"""The output image a material was taken from, as {filename, subfolder}, or None."""
image = source.get("image")
if not isinstance(image, dict) or not isinstance(image.get("filename"), str) or not image["filename"]:
return None
return {"filename": image["filename"], "subfolder": image.get("subfolder") if isinstance(image.get("subfolder"), str) else ""}
def _material_summary(filename, material):
image = material.get("image") or {}
source = material.get("source") if isinstance(material.get("source"), dict) else {}
@@ -398,6 +406,8 @@ def _material_summary(filename, material):
"label": str(source.get("parameter_name") or source.get("recipe_name") or source.get("notebook_name") or "").strip(),
},
"source_fingerprint": str(source.get("parameter_signature") or image.get("source_sha256") or ""),
"moved_to_recipe": str(material.get("moved_to_recipe") or ""),
"source_image": _summary_source_image(source),
}
if source.get("recipe_filename"):
summary["source_recipe"] = {
+17 -50
View File
@@ -19,11 +19,10 @@ from .material_schema import (
_normalise_prompt_plan,
_recipe_link_fingerprint,
)
from .parameters import get_parameters_dir
from .recipe_constants import MAX_RECIPE_BYTES
from .recipe_store import _read_recipe, get_recipes_dir
from .trash import move_to_trash
from .utils import atomic_write_json as _atomic_write_json, require_filename, resolve_within
from .workflow_schema import _parameter_signature, _validate_workflow
_material_write_lock = threading.RLock()
@@ -58,47 +57,6 @@ def _read_material(path):
return value
def _read_parameter_source(recipe_filename, parameter_filename=None):
recipe_filename = require_filename(recipe_filename)
if not recipe_filename.endswith(".json") or recipe_filename.startswith("."):
raise ValueError("Invalid recipe filename")
recipe_path = resolve_within(get_recipes_dir(), recipe_filename)
if os.path.getsize(recipe_path) > MAX_RECIPE_BYTES:
raise ValueError("Recipe source is too large")
recipe = _read_recipe(recipe_path)
source = recipe
if parameter_filename:
parameter_filename = require_filename(parameter_filename)
if not parameter_filename.endswith(".json") or parameter_filename.startswith("."):
raise ValueError("Invalid parameter filename")
parameter_path = resolve_within(get_parameters_dir(), parameter_filename)
if os.path.getsize(parameter_path) > MAX_RECIPE_BYTES:
raise ValueError("Parameter notebook is too large")
with open(parameter_path, "r", encoding="utf-8") as parameter_file:
source = json.load(parameter_file)
if not isinstance(source, dict) or source.get("recipe_filename") != recipe_filename:
raise ValueError("Parameter notebook does not belong to recipe")
workflow = source.get("workflow") if isinstance(source, dict) else None
if not isinstance(workflow, dict):
raise ValueError("Parameter source has no workflow")
_validate_workflow(workflow)
return recipe_filename, parameter_filename, recipe, source, workflow
def _parameter_source_record(recipe_filename, parameter_filename, recipe, source, workflow):
record = {
"type": "recipe_parameter_notebook" if parameter_filename else "workflow_recipe",
**_snapshot_recipe_source(recipe_filename),
"parameter_signature": (_parameter_signature(workflow) or {}).get("value") or "",
}
if parameter_filename:
record["parameter_filename"] = parameter_filename
record["parameter_name"] = str(source.get("name") or "").strip()[:200]
elif isinstance(recipe, dict):
record["parameter_name"] = str(recipe.get("name") or "").strip()[:200]
return record
def _snapshot_recipe_source(recipe_filename):
if not recipe_filename:
return {}
@@ -253,14 +211,17 @@ def _query_materials(materials_dir, query):
if len(node_type) > 200:
raise ValueError("Invalid node type")
if len(search) > 200 or len(tag) > 60 or kind not in (
"", "image_workflow_snapshot", "image_node_selection", "recipe_parameter_selection", "prompt_note_bundle", "prompt_text", "prompt_plan"
"", "image_workflow_snapshot", "image_node_selection", "recipe_parameter_selection", "node_parameter_selection",
"prompt_note_bundle", "prompt_text", "prompt_plan"
):
raise ValueError("Invalid material filter")
# Whole workflows live with the recipes: "all" is the pieces, and one moved there is not listed.
materials = [material for material in materials
if (not search or search in " ".join([material["name"], *material["node_types"], *material.get("tags", [])]).casefold())
if not material.get("moved_to_recipe")
and (not search or search in " ".join([material["name"], *material["node_types"], *material.get("tags", [])]).casefold())
and (not tag or tag in [value.casefold() for value in material.get("tags", [])])
and (not kind or material["kind"] == kind)
and (category == "all" or _material_category(material) == category)
and (_material_category(material) == category if category != "all" else _material_category(material) != "workflow")
and (not node_type or node_type in material["node_types"])]
total = len(materials)
limit = min(100, max(1, int(query.get("limit", 48))))
@@ -288,10 +249,16 @@ def _update_material_details(materials_dir, filename, name, tags, prompt_role_ov
return _with_live_recipe_source(_material_summary(filename, material))
def _mark_material_moved(materials_dir, filename, recipe_filename):
"""Notes that a kept workflow now lives as recipe `recipe_filename`; the material file stays."""
with _material_write_lock:
path = resolve_within(materials_dir, filename)
material = _read_material(path)
material["moved_to_recipe"] = recipe_filename
_atomic_write_json(path, material)
def _delete_material(materials_dir, filename, path):
with _material_write_lock:
_read_material(path)
os.remove(path)
assets_dir = _material_assets_dir(materials_dir, filename)
if os.path.isdir(assets_dir):
shutil.rmtree(assets_dir)
move_to_trash(path, _material_assets_dir(materials_dir, filename))
+33 -148
View File
@@ -25,16 +25,13 @@ from .material_schema import (
_normalise_material_tags,
_normalise_prompt_role_overrides,
_normalise_selected_node_ids,
_parameter_signature,
_prompt_excerpt,
_prompt_groups_from_roles,
_prompt_roles_for_workflow,
_workflow_hashes_for_blocks,
)
from .notebooks import MAX_NOTEBOOK_BYTES
from .parameters import get_parameters_dir
from .recipe_constants import MAX_RECIPE_BYTES
from .recipe_schema import _build_model_references
from .recipe_schema import _build_model_references, _normalise_source_image
from .recipe_store import get_recipes_dir
from .utils import atomic_write_json as _atomic_write_json, require_filename, resolve_within
@@ -43,8 +40,6 @@ MATERIAL_SCHEMA_VERSION = 1
_store_get_materials_dir = _store.get_materials_dir
_store_read_material = _store._read_material
_store_read_parameter_source = _store._read_parameter_source
_store_parameter_source_record = _store._parameter_source_record
_store_snapshot_recipe_source = _store._snapshot_recipe_source
_store_live_recipe_source = _store._live_recipe_source
_store_with_live_recipe_source = _store._with_live_recipe_source
@@ -89,16 +84,6 @@ def _read_material(path):
return _store_read_material(path)
def _read_parameter_source(recipe_filename, parameter_filename=None):
_store.get_recipes_dir = get_recipes_dir
_store.get_parameters_dir = get_parameters_dir
return _store_read_parameter_source(recipe_filename, parameter_filename)
def _parameter_source_record(recipe_filename, parameter_filename, recipe, source, workflow):
return _store_parameter_source_record(recipe_filename, parameter_filename, recipe, source, workflow)
def _snapshot_recipe_source(filename):
_store.get_recipes_dir = get_recipes_dir
return _store_snapshot_recipe_source(filename)
@@ -263,136 +248,6 @@ async def api_save_image_material(request):
})
async def api_save_parameter_material(request):
try:
payload = await request.json()
recipe_filename, parameter_filename, recipe, source, workflow = await asyncio.to_thread(
_read_parameter_source,
payload.get("recipe_filename", ""),
payload.get("parameter_filename"),
)
source_name = str(source.get("name") or recipe.get("name") or "参数素材").strip()
name = payload.get("name") or source_name
if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_MATERIAL_NAME_LENGTH:
raise ValueError("Invalid material name")
tags = _normalise_material_tags(payload.get("tags", recipe.get("tags") or []))
allow_duplicate = payload.get("allow_duplicate", False)
if not isinstance(allow_duplicate, bool):
raise ValueError("Invalid duplicate preference")
reusable_ids = [
node.get("id") for node in workflow.get("nodes", [])
if isinstance(node, dict) and node.get("id") is not None
and isinstance(node.get("widgets_values"), list) and node.get("widgets_values")
]
raw_selection = payload.get("selected_node_ids")
selected_node_ids = _normalise_selected_node_ids(
workflow,
reusable_ids if raw_selection is None else raw_selection,
)
reusable_keys = {str(value) for value in reusable_ids}
if any(str(value) not in reusable_keys for value in selected_node_ids):
raise ValueError("Selected node has no reusable parameters")
selected_keys = {str(value) for value in selected_node_ids}
blocks = [
block for block in _node_blocks(workflow, include_values=False)
if str(block.get("node_id")) in selected_keys
]
references = [
reference for reference in _build_model_references(source, verify_identities=False)
if str(reference.get("node_id")) in selected_keys
]
source_record = _parameter_source_record(
recipe_filename, parameter_filename, recipe, source, workflow
)
material_id = uuid.uuid4().hex
filename = f"material_{int(time.time())}_{material_id}.json"
material = {
"schema_version": MATERIAL_SCHEMA_VERSION,
"id": material_id,
"kind": "recipe_parameter_selection",
"name": name,
"tags": tags,
"timestamp": int(time.time() * 1000),
"source": source_record,
"workflow": workflow,
"model_references": references,
"capabilities": ["apply_node_parameters"],
"selection": {"scope": "nodes", "node_ids": selected_node_ids},
}
params = source.get("params") if isinstance(source.get("params"), dict) else {}
prompt_role_overrides = _normalise_prompt_role_overrides(params.get("promptRoleOverrides"))
if prompt_role_overrides:
material["promptRoleOverrides"] = prompt_role_overrides
materials_dir = get_materials_dir()
duplicate = await asyncio.to_thread(
_persist_parameter_material, materials_dir, filename, material, allow_duplicate
)
if duplicate:
return web.json_response({
"status": "duplicate",
"filename": duplicate["filename"],
"name": duplicate["name"],
}, status=409)
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Could not save parameter material"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Parameter source not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not save parameter material"}, status=500)
return web.json_response({
"status": "success",
"filename": filename,
"material": _with_live_recipe_source(_material_summary(filename, material)),
"node_blocks": blocks,
})
async def api_save_prompt_note_material(request):
try:
payload = await request.json()
scope = payload.get("scope", "note")
if scope not in ("note", "prompt"):
raise ValueError("Invalid prompt scope")
source_filename = require_filename(payload.get("notebook_filename", ""))
if not source_filename.endswith(".json") or source_filename.startswith("."):
raise ValueError("Invalid notebook filename")
name = payload.get("name", "")
if not isinstance(name, str) or not name.strip() or len(name.strip()) > MAX_MATERIAL_NAME_LENGTH:
raise ValueError("Invalid material name")
note = _normalise_prompt_note(payload.get("note"), scope == "prompt")
if scope == "prompt" and not note["promptEn"].strip():
raise ValueError("Prompt is empty")
tags = _normalise_material_tags(payload.get("tags", []))
allow_duplicate = payload.get("allow_duplicate", False)
if not isinstance(allow_duplicate, bool):
raise ValueError("Invalid duplicate preference")
signature = hashlib.sha256(json.dumps(note, ensure_ascii=False, sort_keys=True, allow_nan=False).encode("utf-8")).hexdigest()
material_id = uuid.uuid4().hex
filename = f"material_{int(time.time())}_{material_id}.json"
material = {
"schema_version": MATERIAL_SCHEMA_VERSION, "id": material_id,
"kind": "prompt_text" if scope == "prompt" else "prompt_note_bundle",
"name": name.strip(), "tags": tags, "timestamp": int(time.time() * 1000),
"source": {"type": "prompt_note", "notebook_filename": source_filename,
"notebook_name": name.strip(), "parameter_signature": signature},
"note": note, "selection": {"scope": scope},
"capabilities": ["copy_prompt", "restore_prompt_note"],
}
duplicate = await asyncio.to_thread(
_persist_parameter_material, get_materials_dir(), filename, material, allow_duplicate
)
if duplicate:
return web.json_response({"status": "duplicate", "filename": duplicate["filename"],
"name": duplicate["name"]}, status=409)
return web.json_response({"status": "success", "filename": filename,
"material": _material_summary(filename, material)})
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid prompt note material"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not save prompt note material"}, status=500)
async def api_save_prompt_plan(request):
try:
payload = await request.json()
@@ -404,13 +259,16 @@ async def api_save_prompt_plan(request):
allow_duplicate = payload.get("allow_duplicate", False)
if not isinstance(allow_duplicate, bool):
raise ValueError("Invalid duplicate preference")
# The output image the prompts were taken from, so the gallery can show it was kept.
source_image = _normalise_source_image(payload.get("source_image"))
signature = hashlib.sha256(json.dumps(plan, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
material_id = uuid.uuid4().hex
filename = f"material_{int(time.time())}_{material_id}.json"
material = {"schema_version": MATERIAL_SCHEMA_VERSION, "id": material_id,
"kind": "prompt_plan", "name": name.strip(), "tags": tags,
"timestamp": int(time.time() * 1000), "plan": plan,
"source": {"type": "prompt_plan", "parameter_signature": signature},
"source": {"type": "prompt_plan", "parameter_signature": signature,
**({"image": source_image} if source_image else {})},
"capabilities": ["compose_prompt"], "selection": {"scope": "prompt_plan"}}
duplicate = await asyncio.to_thread(_persist_parameter_material, get_materials_dir(), filename, material, allow_duplicate)
if duplicate:
@@ -432,6 +290,24 @@ async def api_get_materials(request):
return web.json_response({"status": "error", "message": "Could not list materials"}, status=500)
async def api_mark_material_moved(request):
"""POST {filename, recipe}: the kept workflow `filename` was saved as recipe `recipe`."""
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
recipe = require_filename(payload.get("recipe", ""))
if not filename.endswith(".json") or not recipe.endswith(".json"):
raise ValueError("Invalid material")
await asyncio.to_thread(_store._mark_material_moved, get_materials_dir(), filename, recipe)
return web.json_response({"status": "success"})
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid material"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Material not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not update material"}, status=500)
async def api_update_material(request):
try:
payload = await request.json()
@@ -514,7 +390,8 @@ async def api_get_materials_by_node_type(request):
def collect():
matches = []
for summary in _list_materials(materials_dir):
if node_type not in summary["node_types"]:
# A whole workflow moved to a recipe is offered through the recipe's own sets.
if node_type not in summary["node_types"] or summary.get("moved_to_recipe"):
continue
try:
material = _read_material(resolve_within(materials_dir, summary["filename"]))
@@ -522,6 +399,10 @@ 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:
# 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"],
@@ -529,6 +410,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
+359
View File
@@ -0,0 +1,359 @@
"""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_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."}
def run_workflow(ctx, batch=1, wait_seconds=55, workflow=None, **slots):
from .ai_run_tracking import checked_wait
checked_wait(wait_seconds) # Reject a wait that is no number before anything can be submitted.
batch = max(1, min(8, int(batch)))
filled = mcp_fill.fill(ctx, workflow, **slots) # a saved workflow and its blanks first (mcp_fill.py)
try:
answer = ask_page("run_workflow", {"batch": batch}, required_capability="prompt_ids")
except (PageUnavailable, PageRefused) as error:
raise ToolError(str(error))
ids = answer.get("prompt_ids") if isinstance(answer, dict) else None
page = answer if isinstance(answer, dict) else {}
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)):
raise ToolError((run_refusal.say(answer.get("refusal")) or answer.get("submission_error") if isinstance(answer, dict) else None)
or "No valid submission receipts. Check ComfyUI's queue before retrying; do not repeat automatically.")
answer = {"prompt_ids": ids, "queued": len(ids), "requested": batch,
"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."}
if filled:
answer["filled"] = filled
# Some prompts may have been accepted before validation or a transport failure stopped the batch.
if len(ids) < batch:
answer["submission_error"] = f"Only {len(ids)} of {batch} prompts were accepted."
told = run_refusal.say(page.get("refusal"))
if told:
answer["submission_error"] += f" {told}"
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."}
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": {"type": "string"}, "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). In the same call: workflow (a saved one) opens in the task's tab "
"once and is reused; prompt, negative, seed (every sampler), width/height (the start latent), image "
"(its LoadImage) and values (any setting by name: {\"steps\": 30}, or {\"12\": {\"denoise\": 0.5}} when "
"several nodes have it) fill it in; the rest stays. Without workflow they fill the open canvas. Then "
"run_status, never the output folder.",
run_workflow, {"batch": {"type": "integer", "minimum": 1, "maximum": 8, "default": 1},
"wait_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 55,
"description": "Seconds to wait for it to end."},
"workflow": {"type": "string", "description": "A name from list_workflows."},
"prompt": {"type": "string"}, "negative": {"type": "string"},
"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, when there are several."},
"values": {"type": "object", "description": "Other settings by name, any node's."}}),
_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"]),
])
+213
View File
@@ -0,0 +1,213 @@
<!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.', '还没有任务可看。'],
};
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);
}
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>
+146
View File
@@ -0,0 +1,146 @@
"""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).
"""
import os
from . import ai_panels, 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["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"]
pictures = [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)),
"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]}."
+90
View File
@@ -0,0 +1,90 @@
"""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), 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 uuid
TIMEOUT_SECONDS = 30
MAX_WAITING = 20
_lock = threading.Lock()
_waiting = {} # id -> {"event": Event, "claimed": bool, "outcome": dict | None}
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}
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):
"""True for the first page that asks to do request `request_id`."""
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
entry["claimed"] = True
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
+314
View File
@@ -0,0 +1,314 @@
"""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 threading
import time
from . import ai_tasks, ai_toolbox, mcp_fill
from .mcp_actions import _action, _local, _page
from .mcp_tools import ToolError
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, before the page is asked."""
if not ctx.get("task"):
return
info = object_info(ctx)
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 add_nodes(ctx, nodes, near_node_id=None, replace=False):
if not isinstance(replace, bool):
raise ToolError("replace must be true or false.")
_api_nodes(nodes, canvas_refs=True)
_allowed(ctx, [spec["class_type"] for spec in nodes.values()])
return _next(_page("add_nodes", {"nodes": nodes, "replace": replace,
"near_node_id": None if near_node_id is None else _id(near_node_id, "near_node_id")}, timeout=60))
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):
filename, name = mcp_fill.saved_workflow(workflow)
return _page("open_workflow", {"filename": filename, "name": name}, 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.",
add_nodes, {"nodes": {"type": "object"}, "near_node_id": _NODE_ID, "replace": {"type": "boolean"}}, ["nodes"]),
_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 one of the user's saved workflows (list_workflows names them) in a new tab, leaving the open one "
"as it was, to run it or build on it with add_node, connect_nodes and set_values.",
open_workflow, {"workflow": {"type": "string", "description": "A workflow name from list_workflows."}}, ["workflow"]),
])
+114
View File
@@ -0,0 +1,114 @@
"""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
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):
"""{heading: "tool (title), ..."} for the tools of `specs` {name: spec}."""
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})")
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
retext(result, {**data, "directory": directory(specs)})
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
+102
View File
@@ -0,0 +1,102 @@
"""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) 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); 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.
"""
from . import ai_task_canvas, ai_tasks
from .mcp_bridge import PageRefused, PageUnavailable, ask_page
from .mcp_tools import ToolError, _resolve_folder_image, list_workflows
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) of the user's saved workflow called `name`."""
from .recipe_store import _list_recipes, get_recipes_dir
name = str(name or "").strip()
if not name or not any(item["name"] == name for item in list_workflows(name, 50)["workflows"]):
raise ToolError(f"No saved workflow called {name}; list_workflows names them.")
filename = next((recipe["filename"] for recipe in _list_recipes(get_recipes_dir())
if (recipe["data"].get("name") or recipe["filename"]) == name), None)
return filename, name
def _picture(image):
"""The value a LoadImage takes for picture `image`: in the input folder as it is, from the
output folder with ComfyUI's "[output]" mark (it reads it from there)."""
for folder in ("input", "output"):
try:
rel, _path = _resolve_folder_image(image, folder)
except ToolError:
continue
return rel if folder == "input" else f"{rel} [output]"
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"])
filename = name = None
if workflow:
filename, name = saved_workflow(workflow)
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": filename, **slots})
if workflow and (filled is None or filled.get("needs_open")):
_page("open_workflow", {"filename": filename, "name": name}, timeout=60)
filled = _page("fill_slots", {"filename": filename, **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 {})}
+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"]),
])
+170
View File
@@ -0,0 +1,170 @@
"""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_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
_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 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)
return 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)}}]}
+315
View File
@@ -0,0 +1,315 @@
"""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_panels, ai_run_tracking, ai_task_canvas, ai_task_runs, ai_tasks, mcp_apps, mcp_bridge, mcp_directory, 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
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, 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
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, 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"])
# 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)
return ai_outside.tell(result, outside)
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:
return web.json_response({"granted": mcp_bridge.claim(body["id"], body.get("capabilities"))})
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)
+98
View File
@@ -0,0 +1,98 @@
"""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
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
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):
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 refused the last run: fix the nodes in canvas.errors (set_values, connect_nodes), 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."
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)
# 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},
}},
}
+423
View File
@@ -0,0 +1,423 @@
"""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
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; a run queued another "
"way needs track_run(prompt_ids) with your own IDs. 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. Unsure where things stand: get_situation. 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).")
box = ai_toolbox.effective(ai_tasks.get_task(task["id"]))
if box:
answer["toolbox"] = ai_toolbox.words(box)
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 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):
try:
panel = ai_panels.show(
ctx.get("client") or "AI", title, blocks,
session=ctx.get("session") or "", task_id=task or ctx.get("task"),
)
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 ask_user(ctx, question, options=None, multiple=False, other=True, wait_seconds=ai_panels.MAX_WAIT, task=None):
"""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."""
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")
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):
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 "")
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("models"):
answer["models"] = [ai_message_refs.lookup("models", model_id) or {"model_id": model_id, "missing": True} for model_id in box["models"]]
return answer
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, "
"each with 'take this one'; the user can also put a picture's workflow back on the canvas); "
"diagram {nodes: [{id, label, detail, node_id, color}], edges: [{from, to, label}], direction: right|down} (boxes and "
"arrows Anomalous lays out; node_id from describe_canvas makes a box find that node); "
"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: they never move by themselves, so keep them current with update_panel; a run's real progress shows "
"in the workbench already, draw none for it); 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. 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},
"task": _TASK_PROP},
["question"]),
_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. Build the repaint yourself. 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, whole custom node packs, combos, saved workflows, prompts and models the user picked in "
"the AI workbench for this task, in full (inputs and outputs of the nodes, each pack's node types, the "
"combos' models and prompts…), and how strictly: prefer (use them first) or only (add_node and "
"new_workflow accept only those node types and the packs' besides ComfyUI's built-in ones). "
"begin_task says when there are any.",
get_toolbox, {"task": _TASK_PROP}),
_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}),
])
+577
View File
@@ -0,0 +1,577 @@
"""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 PIL import Image
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 .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 .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
MAX_LIMIT = 50
MAX_SEARCH_PICTURES = 6 # search_images(with_pictures): the first few attached
MAX_TEXT = 1500
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 _input_facts(path):
"""Size of an input or preview picture and, when it has an alpha, the area LoadImage gives as MASK (1 - alpha)."""
try:
with Image.open(path) as picture:
facts = {"width": picture.width, "height": picture.height}
if "A" not in picture.getbands():
facts["mask"] = "none: no alpha, so LoadImage's MASK is empty"
return facts
area = picture.getchannel("A").point(lambda value: 255 if value < 128 else 0)
except (OSError, ValueError, Image.DecompressionBombError):
return {"note": "The picture cannot be read."}
box = area.getbbox()
if not box:
facts["mask"] = "none: fully opaque, so LoadImage's MASK is empty"
return facts
share = area.histogram()[255] / (facts["width"] * facts["height"])
left, top, right, bottom = (round(100 * value / size) for value, size in zip(box, (facts["width"], facts["height"]) * 2))
facts["mask"] = {"share": f"{round(100 * share, 1)}%", "area": f"x {left}-{right}%, y {top}-{bottom}% of the picture",
"meaning": "LoadImage gives this area as MASK: what to repaint"}
return facts
def _image_content(path):
"""An MCP image item: the 512 px thumbnail the gallery uses, or None when too big or unreadable."""
try:
thumb = _build_card_thumbnail(path)
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)
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 list_workflows(query="", limit=20, *, exact_name=False):
terms, found = _terms(query), []
for recipe in _list_recipes(get_recipes_dir()):
data = recipe.get("data") or {}
params = json.dumps(data.get("params") or {}, ensure_ascii=False)
if exact_name and (data.get("name") or recipe["filename"]) != query:
continue
if not exact_name and not _matches(terms, data.get("name"), " ".join(data.get("tags") or []), data.get("notes"), params):
continue
found.append({"name": data.get("name") or recipe["filename"], "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] + "…"})
return {"total_matches": len(found), "workflows": found[:_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 with tags, notes and their main settings.", 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}
+197 -45
View File
@@ -1,11 +1,20 @@
"""Model cover, thumbnail-cache, and generated-image lookup routes."""
"""Model cover, thumbnail-cache, and generated-image lookup routes.
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.
"""
import asyncio
import collections
import hashlib
import json
import os
import struct
import tempfile
import threading
import time
import urllib.parse
from aiohttp import web
@@ -17,6 +26,8 @@ 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', '.avi', '.mkv'}
CARD_WARM_QUEUE_LIMIT = 512
_thumbnail_cleanup_lock = threading.Lock()
_thumbnail_generation_slots = threading.BoundedSemaphore(2)
_thumbnail_last_cleanup = 0.0
@@ -24,17 +35,53 @@ _thumbnail_created_since_cleanup = 0
def _thumbnail_cache_directory():
"""<user folder>/anomalous/cache/card_thumbnails; the system temp folder if there is none."""
try:
base_dir = folder_paths.get_temp_directory()
user_dir = folder_paths.get_user_directory()
except Exception:
base_dir = None
if not isinstance(base_dir, (str, os.PathLike)):
base_dir = tempfile.gettempdir()
cache_dir = os.path.join(base_dir, "anomalous_model_browser", "card_thumbnails")
user_dir = None
if isinstance(user_dir, (str, os.PathLike)):
cache_dir = os.path.join(user_dir, "anomalous", "cache", "card_thumbnails")
else:
cache_dir = os.path.join(tempfile.gettempdir(), "anomalous_model_browser", "card_thumbnails")
os.makedirs(cache_dir, exist_ok=True)
return cache_dir
def thumbnail_cache_usage():
"""(file count, bytes) of the card image cache, for the settings page."""
count = size = 0
try:
with os.scandir(_thumbnail_cache_directory()) as iterator:
for entry in iterator:
if entry.is_file() and entry.name.endswith('.webp'):
count += 1
try:
size += entry.stat().st_size
except OSError:
pass
except OSError:
pass
return count, size
def clear_thumbnail_cache():
"""Delete the derived card images (never a cover); they are made again when needed."""
removed = 0
try:
with os.scandir(_thumbnail_cache_directory()) as iterator:
for entry in iterator:
if entry.is_file() and entry.name.endswith('.webp'):
try:
os.remove(entry.path)
removed += 1
except OSError:
pass
except OSError:
pass
return removed
def _prune_thumbnail_cache(cache_dir, created=False):
"""Occasionally cap the derived thumbnail cache without touching source covers."""
global _thumbnail_last_cleanup, _thumbnail_created_since_cleanup
@@ -78,28 +125,61 @@ def _prune_thumbnail_cache(cache_dir, created=False):
break
def _cached_card_path(source_path, kind=''):
"""Where the card image of `source_path` is cached: keyed by the file's identity and edge."""
source_stat = os.stat(source_path)
cache_key = "\0".join((
os.path.realpath(source_path),
str(source_stat.st_size),
str(source_stat.st_mtime_ns),
str(getattr(source_stat, 'st_ctime_ns', 0)),
str(CARD_THUMBNAIL_EDGE),
*((kind,) if kind else ()),
))
digest = hashlib.sha256(cache_key.encode('utf-8', errors='surrogatepass')).hexdigest()
cache_dir = _thumbnail_cache_directory()
return cache_dir, os.path.join(cache_dir, f"{digest}.webp")
def _reuse_cached(cache_dir, cached_path):
if not os.path.isfile(cached_path):
return False
try:
os.utime(cached_path, None)
except OSError:
pass
_prune_thumbnail_cache(cache_dir)
return True
def _save_card_image(image, cache_dir, cached_path):
"""Shrink a PIL image to the card edge and store it as WebP (atomically)."""
from PIL import Image
resampling = getattr(Image, 'Resampling', Image).LANCZOS
image.thumbnail((CARD_THUMBNAIL_EDGE, CARD_THUMBNAIL_EDGE), resampling)
if image.mode not in ('RGB', 'RGBA'):
image = image.convert('RGBA' if 'transparency' in image.info else 'RGB')
temp_path = f"{cached_path}.{os.getpid()}.{threading.get_ident()}.tmp"
try:
image.save(temp_path, format='WEBP', quality=84, method=4)
os.replace(temp_path, cached_path)
finally:
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
_prune_thumbnail_cache(cache_dir, created=True)
def _build_card_thumbnail_impl(source_path):
"""Return a cached 512px WebP card image, or the original on any safe fallback."""
try:
from PIL import Image, ImageOps
source_stat = os.stat(source_path)
cache_key = "\0".join((
os.path.realpath(source_path),
str(source_stat.st_size),
str(source_stat.st_mtime_ns),
str(getattr(source_stat, 'st_ctime_ns', 0)),
str(CARD_THUMBNAIL_EDGE),
))
digest = hashlib.sha256(cache_key.encode('utf-8', errors='surrogatepass')).hexdigest()
cache_dir = _thumbnail_cache_directory()
cached_path = os.path.join(cache_dir, f"{digest}.webp")
if os.path.isfile(cached_path):
try:
os.utime(cached_path, None)
except OSError:
pass
_prune_thumbnail_cache(cache_dir)
cache_dir, cached_path = _cached_card_path(source_path)
if _reuse_cached(cache_dir, cached_path):
return cached_path
with Image.open(source_path) as image:
@@ -108,23 +188,7 @@ def _build_card_thumbnail_impl(source_path):
image = ImageOps.exif_transpose(image)
if max(image.size) <= CARD_THUMBNAIL_EDGE:
return source_path
resampling = getattr(Image, 'Resampling', Image).LANCZOS
image.thumbnail((CARD_THUMBNAIL_EDGE, CARD_THUMBNAIL_EDGE), resampling)
if image.mode not in ('RGB', 'RGBA'):
image = image.convert('RGBA' if 'transparency' in image.info else 'RGB')
temp_path = (
f"{cached_path}.{os.getpid()}.{threading.get_ident()}.tmp"
)
try:
image.save(temp_path, format='WEBP', quality=84, method=4)
os.replace(temp_path, cached_path)
finally:
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
_prune_thumbnail_cache(cache_dir, created=True)
_save_card_image(image, cache_dir, cached_path)
return cached_path
except Exception:
return source_path
@@ -136,6 +200,77 @@ def _build_card_thumbnail(source_path):
return _build_card_thumbnail_impl(source_path)
def _build_video_poster_impl(source_path):
"""A cached 512 px WebP of a video cover's first frame, or None (no decoder, bad file)."""
try:
cache_dir, cached_path = _cached_card_path(source_path, 'poster')
if _reuse_cached(cache_dir, cached_path):
return cached_path
import av # PyAV ships with ComfyUI for its video nodes
with av.open(source_path) as container:
stream = next((s for s in container.streams if s.type == 'video'), None)
if stream is None:
return None
stream.thread_type = 'AUTO'
frame = next(container.decode(stream), None)
if frame is None:
return None
image = frame.to_image()
_save_card_image(image, cache_dir, cached_path)
return cached_path
except Exception:
return None
def _build_video_poster(source_path):
with _thumbnail_generation_slots:
return _build_video_poster_impl(source_path)
# ---------- background warm-up of card images ----------
_warm_queue = collections.deque()
_warm_pending = set()
_warm_lock = threading.Lock()
_warm_thread = None
def _warm_worker():
"""One thread, one decode at a time, a short rest between: never more than half the slots."""
global _warm_thread
while True:
with _warm_lock:
if not _warm_queue:
_warm_thread = None
return
path = _warm_queue.popleft()
_warm_pending.discard(path)
ext = os.path.splitext(path)[1].lower()
if ext in CARD_POSTER_VIDEO_EXTENSIONS:
_build_video_poster(path)
else:
_build_card_thumbnail(path)
time.sleep(0.02)
def queue_card_images(paths):
"""Make the card images of these covers in the background (bounded, deduplicated)."""
global _warm_thread
with _warm_lock:
for path in paths:
ext = os.path.splitext(path)[1].lower()
if ext not in CARD_THUMBNAIL_STATIC_EXTENSIONS and ext not in CARD_POSTER_VIDEO_EXTENSIONS:
continue
if path in _warm_pending or len(_warm_queue) >= CARD_WARM_QUEUE_LIMIT:
continue
_warm_pending.add(path)
_warm_queue.append(path)
if _warm_queue and _warm_thread is None:
_warm_thread = threading.Thread(target=_warm_worker, name="anomalous-card-images", daemon=True)
_warm_thread.start()
async def api_serve_image(request):
"""Dedicated image serving endpoint for model preview images."""
folder_type = request.query.get('type', 'checkpoints')
@@ -167,20 +302,25 @@ 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:
return web.Response(status=415, text='Unsupported media type')
served_path = file_path
if (
request.query.get('variant') == 'card'
and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS
):
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 != file_path:
content_type = 'image/webp'
elif variant == 'poster':
# The card's still image of a video cover; nothing (404) when it cannot be read.
poster = await asyncio.to_thread(_build_video_poster, file_path) if ext in CARD_POSTER_VIDEO_EXTENSIONS else None
if not poster:
return web.Response(status=404, text='No poster')
served_path, content_type = poster, 'image/webp'
headers = {'Content-Type': content_type}
if request.query.get('t'):
@@ -188,6 +328,18 @@ async def api_serve_image(request):
return web.FileResponse(served_path, headers=headers)
async def api_card_cache(request):
"""GET /anomalous/card_cache - how many card images are cached and their size."""
count, size = await asyncio.to_thread(thumbnail_cache_usage)
return web.json_response({"count": count, "bytes": size, "limit": CARD_THUMBNAIL_CACHE_LIMIT})
async def api_clear_card_cache(request):
"""POST /anomalous/card_cache/clear - delete the derived card images (made again when shown)."""
removed = await asyncio.to_thread(clear_thumbnail_cache)
return web.json_response({"status": "ok", "removed": removed})
async def api_clear_cache(request):
try:
from .metadata import clear_metadata_cache
+239 -191
View File
@@ -4,198 +4,246 @@ import json
import urllib.parse
import subprocess
import threading
import asyncio
import copy
from functools import lru_cache
from aiohttp import web
import asyncio
import copy
from functools import lru_cache
from aiohttp import web
import folder_paths
import struct
try:
from ..model_identity import sidecar_file_hash
except ImportError:
from model_identity import sidecar_file_hash
def _select_info_file(data, file_path):
"""Select the Civitai file entry that actually describes ``file_path``.
A model version may contain a checkpoint, text encoder, and VAE in the same
``files`` array. Taking the first SHA256 silently assigns the dependency's
hash to every local sidecar copied from that version. Physical byte size is
the strongest cheap discriminator available here and remains valid after a
local rename. Filenames are intentionally excluded: local renames are the
reason provenance recovery exists and cannot be identity evidence.
"""
entries = []
for file_info in data.get("files", []):
if not isinstance(file_info, dict):
continue
hashes = file_info.get("hashes", {})
sha256 = hashes.get("SHA256") if isinstance(hashes, dict) else None
if not sha256:
continue
size_bytes = None
try:
if file_info.get("sizeKB") is not None:
size_bytes = int(round(float(file_info["sizeKB"]) * 1024))
except (TypeError, ValueError, OverflowError):
pass
entries.append((file_info, str(sha256), size_bytes))
if not entries:
return None
try:
physical_size = os.path.getsize(file_path)
except OSError:
physical_size = None
if physical_size is not None:
size_matches = [entry for entry in entries if entry[2] == physical_size]
if len(size_matches) == 1:
return size_matches[0][0]
if len(size_matches) > 1:
return None
# Offline-generated metadata commonly contains one injected hash without
# Civitai size/name data. It is safe only when there is a single candidate.
if len(entries) == 1:
return entries[0][0]
return None
def _select_info_hash(data, file_path):
selected = _select_info_file(data, file_path)
if not selected:
return ""
hashes = selected.get("hashes", {})
return str(hashes.get("SHA256", "")) if isinstance(hashes, dict) else ""
def _positive_civitai_id(value):
"""Return a real Civitai identifier, excluding offline sentinel values."""
if isinstance(value, bool):
return None
try:
parsed = int(value)
except (TypeError, ValueError, OverflowError):
return None
return parsed if parsed > 0 else None
def _read_metadata(file_path):
base_path = os.path.splitext(file_path)[0]
metadata = {
"name": os.path.basename(base_path),
"description": "",
"notes": "",
"trainedWords": [],
"baseModel": "",
"civitai_url": "",
"source_url": "",
"hash": "",
"hash_algorithm": "sha256",
"hash_scope": "file",
"hash_source": "",
"custom_name": "",
"custom_notes": ""
}
info_files = [f"{base_path}.info", f"{base_path}.civitai.info"]
for info_file in info_files:
if os.path.exists(info_file):
try:
with open(info_file, 'r', encoding='utf-8') as f:
data = json.load(f)
name = data.get("name", "")
if "model" in data and isinstance(data["model"], dict):
model_name = data["model"].get("name", "")
name = f"{model_name} - {name}".strip(' -')
description = data.get("description", "") or ""
if not description and "model" in data and isinstance(data["model"], dict):
description = data["model"].get("description", "")
notes = data.get("notes", "")
trained_words = data.get("trainedWords", [])
base_model = data.get("baseModel", "")
model_id = _positive_civitai_id(data.get("modelId"))
version_id = _positive_civitai_id(data.get("id"))
civitai_url = ""
if model_id is not None:
# Handle Civitai's new mature content policy
nsfw_level = data.get("nsfwLevel", 1)
is_nsfw = False
if "model" in data and isinstance(data["model"], dict):
is_nsfw = data["model"].get("nsfw", False)
domain = "civitai.red" if (nsfw_level > 1 or is_nsfw) else "civitai.com"
civitai_url = f"https://{domain}/models/{model_id}"
if version_id is not None:
civitai_url += f"?modelVersionId={version_id}"
selected_file = _select_info_file(data, file_path)
hash_val, hash_source = sidecar_file_hash(data, file_path, selected_file)
if hash_val:
metadata["hash_source"] = hash_source
if name: metadata["name"] = name
if description: metadata["description"] = description
if notes: metadata["notes"] = notes
if trained_words: metadata["trainedWords"] = trained_words
if base_model: metadata["baseModel"] = base_model
if civitai_url: metadata["civitai_url"] = civitai_url
if model_id is not None: metadata["model_id"] = model_id
if version_id is not None: metadata["version_id"] = version_id
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"]
if "anomalous_source_url" in data and data["anomalous_source_url"]: metadata["source_url"] = data["anomalous_source_url"]
except Exception:
pass
return metadata
def _file_signature(file_path):
try:
stat = os.stat(file_path)
return (stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns)
except OSError:
return None
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(f"{base_path}.civitai.info"),
)
@lru_cache(maxsize=4096)
def _get_metadata_cached(file_path, signature):
# ``signature`` is deliberately unused by the parser itself; it is part of
# the cache key and changes whenever the model or either metadata sidecar
# changes. The cache is bounded so removed/renamed models cannot grow it
# without limit.
return _read_metadata(file_path)
def get_metadata(file_path):
normalized_path = os.path.realpath(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))
def clear_metadata_cache():
_get_metadata_cached.cache_clear()
def get_metadata_cache_info():
return _get_metadata_cached.cache_info()
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"}
def _select_info_file(data, file_path):
"""Select the Civitai file entry that actually describes ``file_path``.
A model version may contain a checkpoint, text encoder, and VAE in the same
``files`` array. Taking the first SHA256 silently assigns the dependency's
hash to every local sidecar copied from that version. Physical byte size is
the strongest cheap discriminator available here and remains valid after a
local rename. Filenames are intentionally excluded: local renames are the
reason provenance recovery exists and cannot be identity evidence.
"""
entries = []
for file_info in data.get("files", []):
if not isinstance(file_info, dict):
continue
hashes = file_info.get("hashes", {})
sha256 = hashes.get("SHA256") if isinstance(hashes, dict) else None
if not sha256:
continue
size_bytes = None
try:
if file_info.get("sizeKB") is not None:
size_bytes = int(round(float(file_info["sizeKB"]) * 1024))
except (TypeError, ValueError, OverflowError):
pass
entries.append((file_info, str(sha256), size_bytes))
if not entries:
return None
try:
physical_size = os.path.getsize(file_path)
except OSError:
physical_size = None
if physical_size is not None:
size_matches = [entry for entry in entries if entry[2] == physical_size]
if len(size_matches) == 1:
return size_matches[0][0]
if len(size_matches) > 1:
return None
# Offline-generated metadata commonly contains one injected hash without
# Civitai size/name data. It is safe only when there is a single candidate.
if len(entries) == 1:
return entries[0][0]
return None
def _select_info_hash(data, file_path):
selected = _select_info_file(data, file_path)
if not selected:
return ""
hashes = selected.get("hashes", {})
return str(hashes.get("SHA256", "")) if isinstance(hashes, dict) else ""
def _positive_civitai_id(value):
"""Return a real Civitai identifier, excluding offline sentinel values."""
if isinstance(value, bool):
return None
try:
parsed = int(value)
except (TypeError, ValueError, OverflowError):
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": "",
"notes": "",
"trainedWords": [],
"baseModel": "",
"civitai_url": "",
"source_url": "",
"hash": "",
"hash_algorithm": "sha256",
"hash_scope": "file",
"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": "",
"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 = [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:
with open(info_file, 'r', encoding='utf-8') as f:
data = json.load(f)
name = data.get("name", "")
if "model" in data and isinstance(data["model"], dict):
model_name = data["model"].get("name", "")
# A model and version of one name (what the file alone tells) read once.
name = f"{model_name} - {name}" if model_name and name and model_name != name else model_name or name
description = data.get("description", "") or ""
if not description and "model" in data and isinstance(data["model"], dict):
description = data["model"].get("description", "")
notes = data.get("notes", "")
trained_words = data.get("trainedWords", [])
base_model = data.get("baseModel", "")
model_id = _positive_civitai_id(data.get("modelId"))
version_id = _positive_civitai_id(data.get("id"))
civitai_url = ""
if model_id is not None:
# Handle Civitai's new mature content policy
nsfw_level = data.get("nsfwLevel", 1)
is_nsfw = False
if "model" in data and isinstance(data["model"], dict):
is_nsfw = data["model"].get("nsfw", False)
domain = "civitai.red" if (nsfw_level > 1 or is_nsfw) else "civitai.com"
civitai_url = f"https://{domain}/models/{model_id}"
if version_id is not None:
civitai_url += f"?modelVersionId={version_id}"
selected_file = _select_info_file(data, file_path)
hash_val, hash_source = sidecar_file_hash(data, file_path, selected_file)
if hash_val:
metadata["hash_source"] = hash_source
if name: metadata["name"] = name
if description: metadata["description"] = description
if notes: metadata["notes"] = notes
if trained_words: metadata["trainedWords"] = trained_words
if base_model: metadata["baseModel"] = base_model
if civitai_url: metadata["civitai_url"] = civitai_url
if model_id is not None: metadata["model_id"] = model_id
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"]
if "anomalous_source_url" in data and data["anomalous_source_url"]: metadata["source_url"] = data["anomalous_source_url"]
except Exception:
pass
scan = sidecar_info(info_base)
if scan is not None:
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_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]
metadata["user_fields"].append(field)
return metadata
def _file_signature(file_path):
try:
stat = os.stat(file_path)
return (stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns)
except OSError:
return None
def _metadata_signature(file_path):
base_path = os.path.splitext(file_path)[0]
return (
_file_signature(file_path),
_file_signature(find(base_path, ".info") or f"{base_path}.info"),
_file_signature(f"{base_path}.civitai.info"),
_file_signature(find(base_path, USER_INFO_SUFFIX) or base_path + USER_INFO_SUFFIX),
)
@lru_cache(maxsize=4096)
def _get_metadata_cached(file_path, signature):
# ``signature`` is deliberately unused by the parser itself; it is part of
# the cache key and changes whenever the model or either metadata sidecar
# changes. The cache is bounded so removed/renamed models cannot grow it
# without limit.
return _read_metadata(file_path)
def get_metadata(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))
def clear_metadata_cache():
_get_metadata_cached.cache_clear()
def get_metadata_cache_info():
return _get_metadata_cached.cache_info()
+182
View File
@@ -0,0 +1,182 @@
"""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.
"""
import json
import os
from collections import Counter
import folder_paths
from . import metadata
# 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 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}
+120 -229
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
from .model_constants import MEDIA_EXTENSIONS, MODEL_EXTENSIONS, PREVIEW_SUFFIXES
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."""
@@ -32,7 +49,7 @@ def _collect_folder_models(target_dir, folder_type, path_idx, rel_subfolder, pag
return {"models": [], "total": 0, "page": page, "limit": limit}
valid_files = sorted(
(name for name in file_entries if name.endswith(('.safetensors', '.ckpt', '.pt'))),
(name for name in file_entries if is_model_file(name)),
key=str.lower,
)
total = len(valid_files)
@@ -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(f.endswith(('.safetensors', '.ckpt', '.pt')) 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 f.endswith(('.safetensors', '.ckpt', '.pt')))
}
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(f.endswith(('.safetensors', '.ckpt', '.pt')) 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 f.endswith(('.safetensors', '.ckpt', '.pt')))
}
tree = _folder_tree(base_dir)
try:
folder_basename = os.path.basename(os.path.normpath(base_dir))
@@ -253,7 +244,7 @@ def _iter_search_models():
continue
for root, _, files in os.walk(base_dir):
for filename in files:
if filename.lower().endswith(MODEL_EXTENSIONS + ('.sft',)):
if is_model_file(filename):
yield folder_type, path_idx, base_dir, os.path.join(root, filename)
@@ -289,116 +280,106 @@ async def api_find_model(request):
})
async def api_compatible_models(request):
base_model = request.query.get('base_model', '')
target_type = request.query.get('target_type', 'loras')
if not base_model:
return web.json_response({"models": []})
target_types = [t.strip() for t in target_type.split(',')]
compatible_models = []
# Prompt Notes ask for this base model to list models whose metadata names none.
UNLABELED_BASE_MODEL = "__unlabeled__"
def _normalised_base(value):
return str(value or "").strip().lower().replace(" ", "")
def _walk_model_files(target_types):
"""Yield (folder_type, path_idx, base_dir, root, filename, metadata) once per real file."""
seen_files = set()
for t in target_types:
for folder_type in target_types:
try:
paths = folder_paths.get_folder_paths(t)
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
if not paths:
continue
for path_idx, base_dir in enumerate(paths):
for path_idx, base_dir in enumerate(paths or []):
if not os.path.exists(base_dir):
continue
for root, _, files in os.walk(base_dir):
for f in files:
if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt'):
file_path = os.path.join(root, f)
real_path = os.path.realpath(file_path)
if real_path in seen_files:
continue
seen_files.add(real_path)
meta = get_metadata(file_path)
m_bm = str(meta.get("baseModel", "")).strip().lower().replace(" ", "")
req_bm = str(base_model).strip().lower().replace(" ", "")
if req_bm and m_bm and (req_bm in m_bm or m_bm in req_bm):
rel_subfolder = os.path.relpath(root, base_dir)
if rel_subfolder == '.':
rel_subfolder = '/'
else:
rel_subfolder = '/' + rel_subfolder.replace('\\', '/')
base_name = os.path.splitext(f)[0]
preview_file = None
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
if os.path.exists(os.path.join(root, base_name + ext)):
preview_file = base_name + ext
break
preview_url = ""
if preview_file:
q_type = urllib.parse.quote(t)
q_idx = str(path_idx)
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder != '/' else ""
q_file = urllib.parse.quote(preview_file)
preview_url = f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}"
try:
size_bytes = os.path.getsize(file_path)
size_mb = round(size_bytes / (1024 * 1024), 1)
except Exception:
size_mb = 0
size_bytes = 0
for filename in files:
if not is_model_file(filename):
continue
file_path = os.path.join(root, filename)
real_path = os.path.realpath(file_path)
if real_path in seen_files:
continue
seen_files.add(real_path)
yield folder_type, path_idx, base_dir, root, filename, get_metadata(file_path)
compatible_models.append({
"type": t,
"path_idx": path_idx,
"subfolder": rel_subfolder,
"filename": f,
"size_mb": size_mb,
"size_bytes": size_bytes,
"preview_url": preview_url,
"metadata": meta
})
return web.json_response({"models": compatible_models})
def _base_matches(requested, metadata):
model_base = _normalised_base(metadata.get("baseModel"))
if requested == UNLABELED_BASE_MODEL:
return not model_base
wanted = _normalised_base(requested)
return bool(wanted and model_base and (wanted in model_base or model_base in wanted))
def _compatible_models_sync(base_model, target_types):
compatible_models = []
for folder_type, path_idx, base_dir, root, filename, meta in _walk_model_files(target_types):
if not _base_matches(base_model, meta):
continue
rel_subfolder = os.path.relpath(root, base_dir)
rel_subfolder = '/' if rel_subfolder == '.' else '/' + rel_subfolder.replace('\\', '/')
base_name = os.path.splitext(filename)[0]
preview_file = None
for ext in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS:
if os.path.exists(os.path.join(root, base_name + ext)):
preview_file = base_name + ext
break
preview_url = ""
if preview_file:
q_sub = urllib.parse.quote(rel_subfolder.strip('/')) if rel_subfolder != '/' else ""
preview_url = (
f"/anomalous/image?type={urllib.parse.quote(folder_type)}&path_idx={path_idx}"
f"&subfolder={q_sub}&filename={urllib.parse.quote(preview_file)}"
)
try:
size_bytes = os.path.getsize(os.path.join(root, filename))
except OSError:
size_bytes = 0
compatible_models.append({
"type": folder_type,
"path_idx": path_idx,
"subfolder": rel_subfolder,
"filename": filename,
"size_mb": round(size_bytes / (1024 * 1024), 1),
"size_bytes": size_bytes,
"preview_url": preview_url,
"metadata": meta,
})
return compatible_models
async def api_compatible_models(request):
base_model = request.query.get('base_model', '')
if not base_model:
return web.json_response({"models": []})
target_types = [t.strip() for t in request.query.get('target_type', 'loras').split(',') if t.strip()]
models = await asyncio.to_thread(_compatible_models_sync, base_model, target_types)
return web.json_response({"models": models})
def _base_models_sync(target_types):
base_models = set()
unlabeled = False
for *_, meta in _walk_model_files(target_types):
label = str(meta.get("baseModel") or "").strip()
if label:
base_models.add(label)
else:
unlabeled = True
return sorted(base_models), unlabeled
async def api_base_models(request):
target_types = get_active_folder_types()
base_models = set()
seen_files = set()
for t in target_types:
try:
paths = folder_paths.get_folder_paths(t)
except Exception:
continue
if not paths: continue
for base_dir in paths:
if not os.path.exists(base_dir): continue
for root, _, files in os.walk(base_dir):
for f in files:
if f.endswith('.safetensors') or f.endswith('.ckpt') or f.endswith('.pt'):
file_path = os.path.join(root, f)
real_path = os.path.realpath(file_path)
if real_path in seen_files: continue
seen_files.add(real_path)
meta = get_metadata(file_path)
m_bm = meta.get("baseModel", "")
if m_bm and str(m_bm).strip():
# Remove typical generic strings that might pollute
clean_bm = str(m_bm).strip()
base_models.add(clean_bm)
return web.json_response({"base_models": sorted(list(base_models))})
base_models, unlabeled = await asyncio.to_thread(_base_models_sync, get_active_folder_types())
return web.json_response({"base_models": base_models, "unlabeled": unlabeled})
def _model_info_for_path(folder_type, path_idx, base_dir, file_path):
@@ -458,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 candidate.lower().endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
exact_results[original] = _model_info_for_path(folder_type, path_idx, base_dir, candidate)
if os.path.isfile(candidate) and is_model_file(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:
@@ -479,7 +459,7 @@ def _resolve_paths_to_model_info_sync(paths, folder_types=None, exact_only=False
for folder_type, path_idx, base_dir in roots:
for root, _, files in os.walk(base_dir):
for filename in files:
if not filename.lower().endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
if not is_model_file(filename):
continue
rel_path = os.path.relpath(os.path.join(root, filename), base_dir).replace(os.sep, '/').lower()
basename = filename.lower()
@@ -552,7 +532,6 @@ async def api_resolve_paths_to_previews(request):
def _collect_all_scan_models(page, limit):
source_library_extensions = {'.safetensors', '.ckpt', '.pt', '.pth', '.bin', '.sft', '.gguf'}
all_tuples = []
for model_root in get_active_model_roots():
t = model_root["type"]
@@ -560,7 +539,7 @@ def _collect_all_scan_models(page, limit):
base_dir = model_root["base_dir"]
for root, dirs, files in os.walk(base_dir):
for f in files:
if os.path.splitext(f)[1].lower() in source_library_extensions:
if is_model_file(f):
all_tuples.append((t, path_idx, root, base_dir, f))
all_tuples.sort(key=lambda x: (x[0], x[4].lower()))
@@ -612,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 f.endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
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 f.endswith(('.safetensors', '.ckpt', '.pt', '.bin', '.sft')):
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})
+10 -3
View File
@@ -1,16 +1,23 @@
"""Shared model catalog, media, and recovery constants."""
MEDIA_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.mp4', '.webm', '.mov', '.avi')
MODEL_EXTENSIONS = ('.safetensors', '.ckpt', '.pt', '.bin')
# Model files the browser lists, counts and looks up: GGUF and .pth too (quantized
# diffusion models, upscalers). Compared without case: see is_model_file().
MODEL_EXTENSIONS = ('.safetensors', '.ckpt', '.pt', '.pth', '.bin', '.sft', '.gguf')
PREVIEW_SUFFIXES = tuple(f'.preview{ext}' for ext in MEDIA_EXTENSIONS)
CIVITAI_BACKUP_SUFFIXES = tuple(f'.civitai_bak{ext}' for ext in MEDIA_EXTENSIONS)
SIDECAR_SUFFIXES = (
'.info', '.civitai.info', '.json', '.txt', '.yaml',
'.info', '.civitai.info', '.anomalous.json', '.json', '.txt', '.yaml',
*MEDIA_EXTENSIONS,
*PREVIEW_SUFFIXES,
*CIVITAI_BACKUP_SUFFIXES,
)
RESOLVABLE_MODEL_TYPES = (
'checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet',
'vae', 'vae_approx', 'clip', 'text_encoders', 'clip_vision',
'vae', 'vae_approx', 'clip', 'text_encoders', 'clip_vision', 'upscale_models',
)
def is_model_file(name):
"""Whether a file name is a model file (MODEL_EXTENSIONS, any case)."""
return name.lower().endswith(MODEL_EXTENSIONS)
+444
View File
@@ -0,0 +1,444 @@
"""Downloading the models the open workflow is missing (Model Check's "Download").
POST /anomalous/download/lookup {items: [{key, hash, url, value, types}]} -> where each can come
from (download_sources.find_source) and the model folders of its type: [{index, path, free,
subfolders}].
POST /anomalous/download/start {download_url, type, root, rel, sha256, size, ...} -> {id}
GET /anomalous/download/status -> {jobs}
POST /anomalous/download/cancel {id}
GET/POST /anomalous/download/settings -> where downloads go ({place, folder}), kept in
user/anomalous/download_settings.json; imported models (model_import.py) follow the same folder.
One download runs at a time, into "<file>.part" next to where the file goes; an interrupted one
continues from there. The finished file is checked against the SHA-256 the workflow or the site
gives, and only then gets its name; a file that is already there is never replaced. The Civitai
key goes only to Civitai (not along a redirect to its file storage).
"""
import asyncio
import hashlib
import json
import os
import shutil
import threading
import time
import urllib.error
import urllib.request
import uuid
from aiohttp import web
import folder_paths
from . import hf_card, manager_catalog
from .activity_log import add_entry, log_path
from .download_sources import (
SHA256, USER_AGENT, allowed_download_url, find_source, host_of, is_civitai, same_file, _load_api_key,
)
from .path_utils import atomic_write_json, resolve_within
try:
from ..net_proxy import urlopen
except ImportError: # loaded outside the package (tests)
from net_proxy import urlopen
MODEL_EXTENSIONS = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".sft", ".gguf")
CHUNK = 1024 * 1024
SPACE_MARGIN = 200 * 1024 * 1024
MAX_SUBFOLDERS = 300
KEEP_FINISHED = 50
# hf_mirror None: not chosen yet (the page takes the mirror for a Chinese interface).
DEFAULT_SETTINGS = {"place": "workflow", "folder": "{base}", "hf_mirror": None}
_lock = threading.Lock()
_jobs = {} # id -> job dict, in the order started
_queue = []
_worker = None
class DownloadError(Exception):
def __init__(self, code, message=""):
super().__init__(message or code)
self.code = code
async def _body(request):
"""The request's JSON object, or {} when it is not one (each route then refuses it)."""
try:
data = await request.json()
except ValueError:
return {}
return data if isinstance(data, dict) else {}
# --- settings ---------------------------------------------------------------------------
def settings_path():
return os.path.join(os.path.dirname(log_path()), "download_settings.json")
def read_settings():
settings = dict(DEFAULT_SETTINGS)
try:
with open(settings_path(), encoding="utf-8") as source:
saved = json.load(source)
if saved.get("place") in ("workflow", "folder"):
settings["place"] = saved["place"]
if isinstance(saved.get("folder"), str):
settings["folder"] = saved["folder"]
if isinstance(saved.get("hf_mirror"), bool):
settings["hf_mirror"] = saved["hf_mirror"]
except (OSError, ValueError, AttributeError):
pass
return settings
def clean_folder(folder):
"""A relative folder ('' = the type's own folder), '/' separated; ValueError if it climbs out."""
parts = [part.strip() for part in str(folder or "").replace("\\", "/").split("/") if part.strip()]
for part in parts:
if part in (".", "..") or ":" in part or any(char in part for char in '<>"|?*'):
raise ValueError("Invalid folder")
return "/".join(parts)
async def api_settings(request):
if request.method == "POST":
data = await _body(request)
settings = read_settings()
if data.get("place") in ("workflow", "folder"):
settings["place"] = data["place"]
if isinstance(data.get("hf_mirror"), bool):
settings["hf_mirror"] = data["hf_mirror"]
if "folder" in data:
try:
# {base} is filled with the model's base model when a download starts.
settings["folder"] = clean_folder(data.get("folder"))
except ValueError:
return web.json_response({"error": "Invalid folder"}, status=400)
atomic_write_json(settings_path(), settings)
return web.json_response(settings)
return web.json_response(read_settings())
# --- lookup -----------------------------------------------------------------------------
def folder_type_for(types):
known = getattr(folder_paths, "folder_names_and_paths", {})
for name in types or []:
if isinstance(name, str) and name in known:
return name
return ""
def _subfolders(root):
found = []
for current, dirs, _files in os.walk(root):
dirs[:] = sorted(name for name in dirs if not name.startswith("."))
depth = os.path.relpath(current, root).count(os.sep)
for name in dirs:
found.append(os.path.relpath(os.path.join(current, name), root).replace(os.sep, "/"))
if len(found) >= MAX_SUBFOLDERS:
return found
if depth >= 1:
dirs[:] = []
return found
def roots_of(folder_type):
"""The model folders of a type: [{index, path, exists, free, subfolders}]."""
roots = []
for index, path in enumerate(folder_paths.get_folder_paths(folder_type) or []):
exists = os.path.isdir(path)
probe = path if exists else os.path.dirname(path)
try:
free = shutil.disk_usage(probe).free if os.path.isdir(probe) else 0
except OSError:
free = 0
roots.append({"index": index, "path": path, "exists": exists, "free": free,
"subfolders": _subfolders(path) if exists else []})
return roots
def lookup_item(item, hf_mirror=False, down=None):
"""Where one item can come from; `down` (kept across one lookup) names the sites that
stopped answering, so the next items skip only those."""
file_hash = str(item.get("hash") or "").strip()
name = os.path.basename(str(item.get("value") or "").replace("\\", "/"))
found = find_source(file_hash, str(item.get("url") or "").strip(), name, hf_mirror,
folder_type_for(item.get("types")), down)
if found.get("found") and file_hash and found.get("sha256") and len(file_hash) >= 10 \
and all(c in "0123456789abcdefABCDEF" for c in file_hash) and not same_file(file_hash, found["sha256"]):
# The link names another file than the one the workflow was saved with.
return {"found": False, "reason": "different_file", "page": found.get("page", "")}
return found
async def api_lookup(request):
"""Items already looked up (`known`) only get their type and folders. Once a site does not
answer, the rest do not ask it (offline: no wait per model); the other sites still are."""
data = await _body(request)
items, hf_mirror = data.get("items"), data.get("hf_mirror") is True
if not isinstance(items, list):
return web.json_response({"error": "items must be a list"}, status=400)
results, types, down = [], {}, set()
for item in items[:100]:
if not isinstance(item, dict):
continue
folder_type = folder_type_for(item.get("types"))
if item.get("known"):
result = {"found": False, "reason": "known"}
else:
result = await asyncio.to_thread(lookup_item, item, hf_mirror, down)
result.update(key=item.get("key"), type=folder_type)
if folder_type and folder_type not in types:
types[folder_type] = await asyncio.to_thread(roots_of, folder_type)
results.append(result)
# Whether ComfyUI-Manager's model list is here: without it, Model Check says it is one more source.
manager_list = bool(await asyncio.to_thread(manager_catalog.list_path))
return web.json_response({"results": results, "roots": types, "settings": read_settings(), "manager_list": manager_list})
# --- downloads --------------------------------------------------------------------------
def _public(job):
public = {key: job[key] for key in ("id", "key", "state", "error", "received", "total", "rel", "type",
"root", "value", "file", "source", "verified")}
public["host"] = host_of(job["url"])
return public
def plan_job(data):
"""A queued job from a start request, or ValueError / DownloadError saying why not."""
url = str(data.get("download_url") or "")
if not allowed_download_url(url):
raise DownloadError("bad_source")
folder_type = folder_type_for([data.get("type")])
if not folder_type:
raise DownloadError("bad_type")
paths = folder_paths.get_folder_paths(folder_type) or []
root_index = data.get("root", 0)
if not isinstance(root_index, int) or not 0 <= root_index < len(paths):
raise DownloadError("bad_root")
rel = clean_folder(data.get("rel"))
if not rel.lower().endswith(MODEL_EXTENSIONS):
raise DownloadError("bad_name")
root = os.path.realpath(paths[root_index])
dest = resolve_within(root, *rel.split("/"))
if os.path.exists(dest):
raise DownloadError("exists")
size = int(data.get("size") or 0)
probe = root
while probe and not os.path.isdir(probe):
probe = os.path.dirname(probe)
if size and probe and shutil.disk_usage(probe).free < size + SPACE_MARGIN:
raise DownloadError("no_space")
sha256 = str(data.get("sha256") or "").lower()
with _lock:
if any(job["dest"] == dest and job["state"] in ("queued", "running", "verifying") for job in _jobs.values()):
raise DownloadError("already")
return {
"id": uuid.uuid4().hex[:12], "key": str(data.get("key") or ""), "state": "queued", "error": "",
"received": 0, "total": size, "rel": rel, "type": folder_type, "root": root_index,
"value": str(data.get("value") or ""), "file": os.path.basename(dest), "dest": dest, "url": url,
"sha256": sha256 if SHA256.match(sha256) else "", "expected": str(data.get("hash") or "").lower(),
"source": str(data.get("source") or ""), "page": str(data.get("page") or ""),
"verified": False, "cancel": False, "finished": 0,
}
def _open(job, start):
headers = {"User-Agent": USER_AGENT}
if start:
headers["Range"] = f"bytes={start}-"
request = urllib.request.Request(job["url"], headers=headers)
key = _load_api_key() if is_civitai(job["url"]) else None
if key: # not carried along the redirect to the file storage
request.add_unredirected_header("Authorization", f"Bearer {key}")
try:
return urlopen(request, timeout=60)
except urllib.error.HTTPError as error:
if error.code in (401, 403):
raise DownloadError("needs_key" if is_civitai(job["url"]) and not key else "forbidden") from error
if error.code == 404:
raise DownloadError("gone") from error
if error.code == 416 and start:
raise DownloadError("restart") from error
raise DownloadError("network", f"HTTP {error.code}") from error
except (urllib.error.URLError, OSError) as error:
raise DownloadError("network", str(error)) from error
def _hash_existing(path, digest):
with open(path, "rb") as source:
for chunk in iter(lambda: source.read(CHUNK), b""):
digest.update(chunk)
def _fetch(job):
part = job["dest"] + ".part"
os.makedirs(os.path.dirname(job["dest"]), exist_ok=True)
digest = hashlib.sha256()
start = os.path.getsize(part) if os.path.isfile(part) else 0
try:
response = _open(job, start)
except DownloadError as error:
if error.code != "restart":
raise
start, response = 0, _open(job, 0)
with response:
if start and response.status != 206:
start = 0 # the site sends the whole file again
if "text/html" in str(response.headers.get("Content-Type", "")).lower():
raise DownloadError("not_a_file") # a login or error page, not the model
if start:
_hash_existing(part, digest)
length = int(response.headers.get("Content-Length") or 0)
job["total"] = start + length if length else job["total"]
job["received"] = start
with open(part, "ab" if start else "wb") as target:
while True:
if job["cancel"]:
raise DownloadError("cancelled")
chunk = response.read(CHUNK)
if not chunk:
break
target.write(chunk)
digest.update(chunk)
job["received"] += len(chunk)
return part, digest.hexdigest()
def _finish(job, part, sha256):
job["state"] = "verifying"
if job["total"] and job["received"] != job["total"]:
raise DownloadError("network", "incomplete")
expected = job["sha256"] or (job["expected"] if len(job["expected"]) >= 10 else "")
if expected and not same_file(expected, sha256):
os.remove(part) # our own unfinished download, not a file of the user's
raise DownloadError("hash_mismatch")
job["verified"] = bool(expected)
if os.path.exists(job["dest"]):
raise DownloadError("exists")
os.replace(part, job["dest"])
if host_of(job["url"]) in ("huggingface.co", "hf-mirror.com"):
try: # the model card's example image as cover, its words as notes (never over the user's)
job["card"] = hf_card.enrich(job["dest"], job["url"])
except Exception as error:
print(f"[Anomalous Browser] Download: the Hugging Face model card was not read: {error}")
forget_file_lists()
def forget_file_lists():
"""ComfyUI's file lists and the browser's model metadata see a file that just arrived
(a download, an import)."""
for cache in (getattr(folder_paths, "filename_list_cache", None), getattr(folder_paths, "cache_helper", None)):
try:
cache.clear()
except Exception:
pass
try:
from .metadata import clear_metadata_cache
clear_metadata_cache()
except Exception as error:
print(f"[Anomalous Browser] Could not clear the model cache: {error}")
def _record(job, sha256):
add_entry("file", "model_download", job["rel"], {"download": {
"type": job["type"], "path_idx": job["root"], "rel": job["rel"], "file": job["file"],
"size": job["received"], "sha256": sha256, "verified": job["verified"],
"source": job["source"], "page": job["page"], "value": job["value"], "card": job.get("card"),
}})
def run_job(job):
job["state"] = "running"
try:
part, sha256 = _fetch(job)
_finish(job, part, sha256)
job["state"] = "done"
try:
_record(job, sha256)
except Exception as error:
print(f"[Anomalous Browser] Download: could not record it in the activity log: {error}")
except DownloadError as error:
job["state"] = "cancelled" if error.code == "cancelled" else "failed"
job["error"] = error.code
if error.code == "cancelled" and os.path.isfile(job["dest"] + ".part"):
os.remove(job["dest"] + ".part") # our own unfinished download
if error.code not in ("cancelled",):
print(f"[Anomalous Browser] Download of {job['rel']} failed: {error}")
except Exception as error:
job["state"], job["error"] = "failed", "unknown"
print(f"[Anomalous Browser] Download of {job['rel']} failed: {error}")
job["finished"] = time.time()
def _work():
global _worker
while True:
with _lock:
job = next((item for item in _queue if item["state"] == "queued"), None)
if job is None:
_queue.clear()
_worker = None
return
_queue.remove(job)
run_job(job)
def enqueue(job):
global _worker
with _lock:
_jobs[job["id"]] = job
_queue.append(job)
finished = [item for item in _jobs.values() if item["finished"]]
for old in finished[:-KEEP_FINISHED]:
_jobs.pop(old["id"], None)
if _worker is None:
_worker = threading.Thread(target=_work, daemon=True, name="anomalous-download")
_worker.start()
async def api_start(request):
data = await _body(request)
if not data:
return web.json_response({"error": "bad_request"}, status=400)
try:
job = await asyncio.to_thread(plan_job, data)
except DownloadError as error:
return web.json_response({"error": error.code}, status=409 if error.code in ("exists", "already") else 400)
except (ValueError, TypeError) as error:
return web.json_response({"error": "bad_path", "message": str(error)}, status=400)
enqueue(job)
return web.json_response(_public(job))
async def api_status(request):
with _lock:
jobs = [_public(job) for job in _jobs.values()]
return web.json_response({"jobs": jobs})
async def api_cancel(request):
data = await _body(request)
with _lock:
job = _jobs.get(str(data.get("id") or ""))
if not job:
return web.json_response({"error": "unknown"}, status=404)
if job["state"] == "queued":
job["state"], job["error"], job["finished"] = "cancelled", "cancelled", time.time()
if job in _queue:
_queue.remove(job)
else:
job["cancel"] = True
return web.json_response(_public(job))
def register_routes(app):
app.router.add_post("/anomalous/download/lookup", api_lookup)
app.router.add_post("/anomalous/download/start", api_start)
app.router.add_get("/anomalous/download/status", api_status)
app.router.add_post("/anomalous/download/cancel", api_cancel)
app.router.add_get("/anomalous/download/settings", api_settings)
app.router.add_post("/anomalous/download/settings", api_settings)
+125
View File
@@ -0,0 +1,125 @@
"""Shows a model file in this computer's file manager: Explorer or Finder with the file
selected, the folder on Linux; or, without a file name, opens a models folder (a type's root
or a subfolder). The request names it the way the browser lists it (type, folder index,
subfolder, file name), never a path, and local_guard lets only this computer ask (a POST)."""
import os
import subprocess
import sys
import threading
import time
from aiohttp import web
from .utils import model_file_within, resolve_folder_subdir
def _explorer_windows():
"""Handles of the open Explorer folder windows (window class CabinetWClass)."""
import ctypes
from ctypes import wintypes
found = []
callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
def collect(hwnd, _):
name = ctypes.create_unicode_buffer(64)
ctypes.windll.user32.GetClassNameW(hwnd, name, 64)
if name.value == "CabinetWClass":
found.append(hwnd)
return True
ctypes.windll.user32.EnumWindows(callback_type(collect), 0)
return found
def _window_title(hwnd):
import ctypes
title = ctypes.create_unicode_buffer(512)
ctypes.windll.user32.GetWindowTextW(hwnd, title, 512)
return title.value
def _to_front(hwnd):
"""Windows keeps a background program (ComfyUI here) from raising a window; borrowing the
foreground window's input queue for a moment lets it."""
import ctypes
user32, kernel32 = ctypes.windll.user32, ctypes.windll.kernel32
foreground = user32.GetForegroundWindow()
theirs = user32.GetWindowThreadProcessId(foreground, None)
mine = kernel32.GetCurrentThreadId()
attached = theirs and theirs != mine and user32.AttachThreadInput(mine, theirs, True)
try:
user32.ShowWindow(hwnd, 9 if user32.IsIconic(hwnd) else 5) # SW_RESTORE / SW_SHOW
user32.BringWindowToTop(hwnd)
user32.SetForegroundWindow(hwnd)
finally:
if attached:
user32.AttachThreadInput(mine, theirs, False)
def _raise_explorer(folder, before):
"""Waits up to 3 s for the window showing `folder` (a new one, or the open one Explorer
reused) and brings it to the front."""
names = (os.path.basename(folder).lower(), os.path.normcase(folder).lower())
def shows_folder(hwnd):
# The folder's name (or full path), then " - File Explorer" in the system's language.
title = _window_title(hwnd).lower()
return any(title == name or title.startswith(name + " - ") for name in names)
for _ in range(30):
time.sleep(0.1)
windows = _explorer_windows()
fresh = [hwnd for hwnd in windows if hwnd not in before]
match = fresh or [hwnd for hwnd in windows if shows_folder(hwnd)]
if match:
_to_front(match[0])
return
def _reveal(path):
if sys.platform == "win32":
before = set(_explorer_windows())
subprocess.Popen(f'explorer /select,"{path}"')
threading.Thread(target=_raise_explorer, args=(os.path.dirname(path), before), daemon=True).start()
elif sys.platform == "darwin":
subprocess.Popen(["open", "-R", path])
else:
subprocess.Popen(["xdg-open", os.path.dirname(path)])
def _open_folder(folder):
if sys.platform == "win32":
before = set(_explorer_windows())
subprocess.Popen(f'explorer "{folder}"')
threading.Thread(target=_raise_explorer, args=(folder, before), daemon=True).start()
elif sys.platform == "darwin":
subprocess.Popen(["open", folder])
else:
subprocess.Popen(["xdg-open", folder])
async def api_open_model_folder(request):
"""{type, path_idx, subfolder, filename}: Explorer with the model selected; without a
filename, the folder (type root or subfolder) itself."""
try:
data = await request.json()
except ValueError:
data = None
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
try:
_, folder = resolve_folder_subdir(data.get("type", ""), int(data.get("path_idx", 0)), data.get("subfolder", "/"))
filename = data.get("filename")
path = model_file_within(folder, filename) if filename else None
except (ValueError, KeyError, TypeError):
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
if not os.path.isfile(path) if path else not os.path.isdir(folder):
return web.json_response({"status": "error", "message": "Not found"}, status=404)
try:
_reveal(path) if path else _open_folder(folder)
except OSError as error:
return web.json_response({"status": "error", "message": str(error)}, status=500)
return web.json_response({"status": "success"})
+101
View File
@@ -0,0 +1,101 @@
"""What a model file's header says (model_kind.inspect_file: kind, base model, format), kept on
disk so each file version is read once, also across restarts. With many models, or models on a
network drive (a NAS, a cloud server's model disk), reading every header again each time the
scan page or a models folder opens takes minutes; the header only changes with the file.
Kept in user/anomalous/model_heads.json by the file's real path, with its size and time: a
changed file is read again. At most MAX_ENTRIES files, the longest unused dropped first, and
written a few seconds after the last change (one write for a whole folder). Only this plugin
reads it; it is a cache, not data to back up. It is kept on disk once the plugin's routes are
set up (keep_on_disk); before that (tests, tools) it lives in memory only."""
import os
import threading
import time
from .activity_log import log_path
from .path_utils import atomic_write_json
try:
from ..model_identity import read_json
from ..model_kind import inspect_file
except ImportError: # loaded outside the package (tests)
from model_identity import read_json
from model_kind import inspect_file
MAX_ENTRIES = 50000 # about 6 MB on disk; a library has far fewer models
MAX_BYTES = 64 * 1024 * 1024 # a cache file larger than this is not read (nor written)
SAVE_AFTER_S = 5
FIELDS = ("kind", "base", "sure", "format")
_lock = threading.Lock()
_heads = None # {real path: [size, mtime_ns, last used (days), kind, base, sure, format]}
_timer = None
_on_disk = False
def keep_on_disk():
"""From now on the cache is read from and written to cache_path() (the plugin's start)."""
global _on_disk
_on_disk = True
def cache_path():
return os.path.join(os.path.dirname(log_path()), "model_heads.json")
def _load():
global _heads
if _heads is None:
try:
data = read_json(cache_path()) if _on_disk and os.path.getsize(cache_path()) <= MAX_BYTES else None
except OSError:
data = None
_heads = {key: value for key, value in (data or {}).items()
if isinstance(key, str) and isinstance(value, list) and len(value) == 3 + len(FIELDS)}
return _heads
def save():
"""Writes the cache now (also what the timer does)."""
global _timer
with _lock:
_timer = None
heads = _load()
if len(heads) > MAX_ENTRIES:
for key in sorted(heads, key=lambda name: heads[name][2])[:len(heads) - MAX_ENTRIES]:
del heads[key]
snapshot = dict(heads)
try:
atomic_write_json(cache_path(), snapshot, max_bytes=MAX_BYTES)
except (OSError, ValueError) as error:
print(f"[Anomalous Browser] Model header cache not saved: {error}")
def _save_soon():
global _timer
if _on_disk and _timer is None:
_timer = threading.Timer(SAVE_AFTER_S, save)
_timer.daemon = True
_timer.start()
def inspect(path):
"""model_kind.inspect_file(path), from the cache while the file is unchanged."""
try:
stat = os.stat(path)
except OSError:
return inspect_file(path)
real, today = os.path.realpath(path), int(time.time() // 86400)
with _lock:
known = _load().get(real)
if known and known[0] == stat.st_size and known[1] == stat.st_mtime_ns:
if known[2] != today:
known[2] = today
_save_soon()
return dict(zip(FIELDS, known[3:]))
found = inspect_file(path)
with _lock:
_load()[real] = [stat.st_size, stat.st_mtime_ns, today, *(found.get(field) for field in FIELDS)]
_save_soon()
return found
+435
View File
@@ -0,0 +1,435 @@
"""Putting a model file the user drops on the models page into the right models folder.
A browser gives a dropped file's name, size, time and bytes, never its path. So:
GET /anomalous/import/folders -> {types: {type: roots}, settings}: the folders a model can go to
(model_download.roots_of) and the download settings, whose folder rule ({base}) imports share.
POST /anomalous/import/inspect?name&size&mtime body: the file's first bytes (its header)
-> what it is (model_kind.classify), files of the same name already there, and, when this
computer's Downloads or Desktop holds that very file (same name, size and time; asked only
by the computer ComfyUI runs on), a `token` for it.
POST /anomalous/import/identify {token} -> its SHA-256, what Civitai knows of it (base model,
model and version name) and a scanned model with the same SHA-256, if any.
POST /anomalous/import/place {token, type, root, rel, keep} -> moves that file there (a copy
when `keep`); same drive = a rename, another drive = copy, then the original goes.
PUT /anomalous/import/upload?type&root&rel&size body: the whole file -> written there.
identify, place and upload answer only the computer ComfyUI runs on, unless remote changes
are allowed (local_guard.may_change).
A file already at the destination is never replaced: the new one gets " (2)". Each import is
one activity log entry (model_import).
"""
import asyncio
import os
import shutil
import threading
import time
import uuid
from aiohttp import web
import folder_paths
try:
from ..model_identity import file_sha256
from ..model_kind import classify, inspect_file, parse_gguf, parse_safetensors
except ImportError: # loaded outside the package (tests)
from model_identity import file_sha256
from model_kind import classify, inspect_file, parse_gguf, parse_safetensors
from .activity_log import add_entry
from .download_sources import SourceUnreachable, civitai_by_hash
from .local_guard import is_local as _is_local, may_change
from .model_download import (
CHUNK, MODEL_EXTENSIONS, SPACE_MARGIN, clean_folder, folder_type_for, forget_file_lists, read_settings, roots_of,
)
from .path_utils import resolve_within
KINDS = ("checkpoints", "loras", "vae", "text_encoders", "diffusion_models", "controlnet", "embeddings",
"clip_vision", "upscale_models", "ipadapter", "model_patches")
SEARCH_DEPTH = 2
SEARCH_LIMIT = 20000
TIME_SLACK_MS = 2000
KEEP_TOKENS = 200
_FOLDER_IDS = { # Windows known folders: Downloads, Desktop
"downloads": "{374DE290-123F-4565-9164-39C4925E467B}",
"desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}",
}
_lock = threading.Lock()
_tokens = {} # token -> {path, name, size}
class Refused(Exception):
"""An import that cannot be done; `code` says why (the page has a sentence for each)."""
def __init__(self, code):
super().__init__(code)
self.code = code
def _error(code, status=400):
return web.json_response({"error": code}, status=status)
# --- where the dropped file is on this computer --------------------------------------------
def _known_folder(name):
if os.name == "nt":
try:
import ctypes
from ctypes import wintypes
folder_id = uuid.UUID(_FOLDER_IDS[name]).bytes_le
path = ctypes.c_wchar_p()
shell = ctypes.windll.shell32
shell.SHGetKnownFolderPath.argtypes = [ctypes.c_char_p, wintypes.DWORD, wintypes.HANDLE, ctypes.POINTER(ctypes.c_wchar_p)]
if shell.SHGetKnownFolderPath(folder_id, 0, None, ctypes.byref(path)) == 0:
found = path.value
ctypes.windll.ole32.CoTaskMemFree(path)
if found and os.path.isdir(found):
return found
except Exception:
pass
fallback = os.path.expanduser("~/" + name.capitalize())
return fallback if os.path.isdir(fallback) else ""
def search_folders():
"""[(label, path)] where dropped files usually come from: Downloads, then Desktop."""
found = []
for label in ("downloads", "desktop"):
path = _known_folder(label)
if path and all(os.path.realpath(path) != os.path.realpath(other) for _, other in found):
found.append((label, path))
return found
def find_original(name, size, mtime_ms, folders=None):
"""(label, path) of the file with this name, size and time in `folders` (and their
subfolders two deep), else None."""
wanted = name.lower()
seen = 0
for label, base in folders if folders is not None else search_folders():
for current, dirs, files in os.walk(base):
depth = 0 if current == base else os.path.relpath(current, base).count(os.sep) + 1
dirs[:] = sorted(item for item in dirs if not item.startswith(".")) if depth < SEARCH_DEPTH else []
for file in files:
seen += 1
if seen > SEARCH_LIMIT:
return None
if file.lower() != wanted:
continue
path = os.path.join(current, file)
try:
stat = os.stat(path)
except OSError:
continue
if stat.st_size == size and (not mtime_ms or abs(stat.st_mtime * 1000 - mtime_ms) <= TIME_SLACK_MS):
return label, path
return None
def _remember(path, name, size):
token = uuid.uuid4().hex
with _lock:
_tokens[token] = {"path": path, "name": name, "size": size}
while len(_tokens) > KEEP_TOKENS:
_tokens.pop(next(iter(_tokens)))
return token
def _original(token):
with _lock:
item = _tokens.get(str(token or ""))
if not item or not os.path.isfile(item["path"]) or os.path.getsize(item["path"]) != item["size"]:
raise Refused("gone")
return item
# --- what it is ----------------------------------------------------------------------------
def import_types():
known = getattr(folder_paths, "folder_names_and_paths", {})
return [kind for kind in KINDS if kind in known]
def classify_head(name, head):
"""classify() from a file's first bytes, by its extension."""
lower = name.lower()
if lower.endswith((".safetensors", ".sft")):
return classify("safetensors", parse_safetensors(head))
if lower.endswith(".gguf"):
return classify("gguf", parse_gguf(head))
return classify(None, None)
def same_name(kind, name):
"""Files of this name already in the type's folders: [{root, rel, size}]."""
if not kind:
return []
wanted, found = name.lower(), []
for index, base in enumerate(folder_paths.get_folder_paths(kind) or []):
if not os.path.isdir(base):
continue
for current, _dirs, files in os.walk(base):
for file in files:
if file.lower() == wanted:
path = os.path.join(current, file)
found.append({"root": index, "rel": os.path.relpath(path, base).replace(os.sep, "/"),
"size": os.path.getsize(path)})
return found[:10]
def inspect(name, size, mtime_ms, head, local):
found = find_original(name, size, mtime_ms) if local else None
if found:
result = inspect_file(found[1])
result.pop("format", None)
result.update(token=_remember(found[1], name, size), where=found[0])
else:
result = classify_head(name, head)
if result["kind"] not in import_types():
result.update(kind="", sure=False)
result["same_name"] = same_name(result["kind"], name)
return result
def _same_hash(sha256):
"""Where a scanned model with this SHA-256 is ("sub/name.safetensors"), else None."""
from .model_resolution import collect_model_hash_index
names = [key for key, value in collect_model_hash_index().items() if str(value.get("hash") or "").lower() == sha256]
return next((key for key in names if "/" in key), names[0] if names else None)
def identify(token):
"""SHA-256, Civitai's record, and an already scanned copy, of a found file."""
item = _original(token)
sha256 = file_sha256(item["path"])
result = {"sha256": sha256, "civitai": None, "reason": "", "duplicate": _same_hash(sha256)}
try:
found = civitai_by_hash(sha256)
if found:
result["civitai"] = {key: found.get(key, "") for key in ("base_model", "model_name", "version_name", "page")}
else:
result["reason"] = "not_found"
except SourceUnreachable:
result["reason"] = "network"
return result
# --- putting it there ----------------------------------------------------------------------
def free_path(path):
"""`path`, or "name (2).ext", "name (3).ext"… when it is taken."""
if not os.path.exists(path) and not os.path.exists(path + ".part"):
return path
stem, ext = os.path.splitext(path)
number = 2
while os.path.exists(f"{stem} ({number}){ext}") or os.path.exists(f"{stem} ({number}){ext}.part"):
number += 1
return f"{stem} ({number}){ext}"
def destination(data, size=0):
"""(type, root index, root path, dest) for a place/upload request; Refused if refused."""
folder_type = folder_type_for([data.get("type")])
if folder_type not in import_types():
raise Refused("bad_type")
paths = folder_paths.get_folder_paths(folder_type) or []
try:
root_index = int(data.get("root", 0))
except (TypeError, ValueError):
raise Refused("bad_root")
if not 0 <= root_index < len(paths):
raise Refused("bad_root")
try:
rel = clean_folder(data.get("rel"))
root = os.path.realpath(paths[root_index])
dest = resolve_within(root, *rel.split("/"))
except ValueError:
raise Refused("bad_path")
if not rel.lower().endswith(MODEL_EXTENSIONS):
raise Refused("bad_name")
probe = root
while probe and not os.path.isdir(probe):
probe = os.path.dirname(probe)
if size and probe and shutil.disk_usage(probe).free < size + SPACE_MARGIN:
raise Refused("no_space")
os.makedirs(os.path.dirname(dest), exist_ok=True)
return folder_type, root_index, root, free_path(dest)
def _same_drive(first, second):
"""Whether `first` can be renamed to `second`; a link counts where the link is (moving
it moves the link, not the model disk behind it)."""
try:
return os.lstat(first).st_dev == os.stat(os.path.dirname(second)).st_dev
except OSError:
return False
def _rename_new(source, dest):
"""Renames `source` to `dest` and never over a file there, even one that appeared since
free_path looked (FileExistsError). Windows' rename refuses a taken name by itself; elsewhere
a hard link does (of a link itself, not what it points to), with a last look for the file
systems that have none."""
if os.name == "nt":
os.rename(source, dest)
return
try:
os.link(source, dest, follow_symlinks=False)
except FileExistsError:
raise
except OSError: # no hard links here (FAT, some network shares)
if os.path.lexists(dest):
raise FileExistsError(dest)
os.rename(source, dest)
return
os.remove(source)
def _looks_whole(source, copy):
"""Whether `copy` has `source`'s size and its very bytes at the start, the middle and the end
(a megabyte each): a cut or garbled copy shows, at no felt cost, with no fingerprint needed."""
size = os.path.getsize(source)
if os.path.getsize(copy) != size:
return False
with open(source, "rb") as first, open(copy, "rb") as second:
for at in sorted({0, max(0, size // 2 - CHUNK // 2), max(0, size - CHUNK)}):
first.seek(at)
second.seek(at)
if first.read(CHUNK) != second.read(CHUNK):
return False
return True
def move_into(source, dest, keep):
"""Moves (or with `keep` copies) `source` to `dest`, never over a file; a copy goes through
"<dest>.part" and is checked (_looks_whole) before the original goes."""
if not keep and _same_drive(source, dest):
_rename_new(source, dest)
return
part = dest + ".part"
try:
shutil.copyfile(source, part)
if not _looks_whole(source, part):
raise Refused("incomplete")
_rename_new(part, dest)
except BaseException:
if os.path.exists(part):
os.remove(part) # our own unfinished copy
raise
if not keep:
os.remove(source) # moved: the whole file is at dest now
def _finish(folder_type, root_index, root, dest, how, size):
forget_file_lists()
rel = os.path.relpath(dest, root).replace(os.sep, "/")
add_entry("file", "model_import", rel, {"import": {
"type": folder_type, "path_idx": root_index, "rel": rel, "file": os.path.basename(dest), "size": size, "how": how,
}})
return {"type": folder_type, "root": root_index, "rel": rel, "file": os.path.basename(dest)}
def place(data):
item = _original(data.get("token"))
keep = data.get("keep") is True
folder_type, root_index, root, dest = destination(data, item["size"] if keep else 0)
move_into(item["path"], dest, keep)
if not keep:
with _lock:
_tokens.pop(str(data.get("token")), None)
return _finish(folder_type, root_index, root, dest, "copy" if keep else "move", item["size"])
# --- routes --------------------------------------------------------------------------------
async def api_folders(request):
types = {}
for kind in import_types():
types[kind] = await asyncio.to_thread(roots_of, kind)
return web.json_response({"types": types, "settings": read_settings()})
async def api_inspect(request):
name = os.path.basename(str(request.query.get("name") or "").replace("\\", "/"))
if not name.lower().endswith(MODEL_EXTENSIONS):
return _error("not_model")
try:
size = int(request.query.get("size") or 0)
mtime_ms = float(request.query.get("mtime") or 0)
except ValueError:
return _error("bad_request")
head = await request.read()
result = await asyncio.to_thread(inspect, name, size, mtime_ms, head, _is_local(request))
return web.json_response(result)
async def api_identify(request):
if not may_change(request):
return _error("not_local", 403)
try:
data = await request.json()
return web.json_response(await asyncio.to_thread(identify, data.get("token")))
except Refused as error:
return _error(error.code, 404)
except (ValueError, AttributeError):
return _error("bad_request")
async def api_place(request):
if not may_change(request):
return _error("not_local", 403)
try:
data = await request.json()
return web.json_response(await asyncio.to_thread(place, data))
except Refused as error:
return _error(error.code, 404 if error.code == "gone" else 400)
except (ValueError, AttributeError):
return _error("bad_request")
except OSError as error:
print(f"[Anomalous Browser] Import: could not put the file in place: {error}")
return _error("io", 500)
async def api_upload(request):
if not may_change(request):
return _error("not_local", 403)
try:
size = int(request.query.get("size") or 0)
folder_type, root_index, root, dest = await asyncio.to_thread(destination, dict(request.query), size)
except Refused as error:
return _error(error.code)
except ValueError:
return _error("bad_request")
part = dest + ".part"
received = 0
try:
target = await asyncio.to_thread(open, part, "wb")
try:
async for chunk in request.content.iter_chunked(CHUNK):
await asyncio.to_thread(target.write, chunk)
received += len(chunk)
finally:
await asyncio.to_thread(target.close)
if size and received != size:
raise Refused("incomplete")
dest = free_path(dest) if os.path.exists(dest) else dest
await asyncio.to_thread(os.replace, part, dest)
except BaseException as error:
if os.path.exists(part):
os.remove(part) # our own unfinished upload
if isinstance(error, Refused):
return _error(error.code)
if isinstance(error, OSError):
print(f"[Anomalous Browser] Import: upload failed: {error}")
return _error("io", 500)
raise
return web.json_response(await asyncio.to_thread(_finish, folder_type, root_index, root, dest, "upload", received))
def register_routes(app):
app.router.add_get("/anomalous/import/folders", api_folders)
app.router.add_post("/anomalous/import/inspect", api_inspect)
app.router.add_post("/anomalous/import/identify", api_identify)
app.router.add_post("/anomalous/import/place", api_place)
app.router.add_put("/anomalous/import/upload", api_upload)
+56 -11
View File
@@ -9,7 +9,12 @@ from aiohttp import web
import folder_paths
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES
from .utils import require_filename, resolve_folder_subdir, resolve_within
try:
from ..model_sidecars import backups, is_civitai_cover, keep_aside
except ImportError:
from model_sidecars import backups, is_civitai_cover, keep_aside
from .trash import move_to_trash
from .utils import model_file_within, require_filename, resolve_folder_subdir, resolve_within
def _cache_token(file_path):
try:
@@ -29,13 +34,21 @@ async def _handle_custom_cover(target_dir, filename, save_func, source_ext='.png
dest_path = os.path.join(target_dir, f"{base_name}{preview_ext}")
# Delete any existing .preview.* files to ensure only one custom cover is active
for ext in PREVIEW_SUFFIXES:
p = os.path.join(target_dir, f"{base_name}{ext}")
if os.path.exists(p) and p != dest_path:
try: os.remove(p)
except: pass
# Civitai's picture, when it is the cover, is kept aside first, to bring back from the editor.
base = os.path.join(target_dir, base_name)
covers = [path for path in (os.path.join(target_dir, f"{base_name}{ext}") for ext in PREVIEW_SUFFIXES) if os.path.isfile(path)]
if not backups(base):
civitai = next((path for path in covers if is_civitai_cover(path, base, [])), None)
if civitai:
keep_aside(base, civitai)
# Only one cover is active: the others go to the Recycle Bin (a failure keeps them).
older = [os.path.join(target_dir, f"{base_name}{ext}") for ext in PREVIEW_SUFFIXES]
try:
move_to_trash(*[path for path in older if path != dest_path])
except OSError as e:
print(f"[Anomalous Browser] Old cover kept: {e}")
await save_func(dest_path)
@@ -131,12 +144,44 @@ def _preview_url_for_model(folder_type, path_idx, base_dir, file_path):
)
if not preview_file:
return ""
rel_subfolder = os.path.relpath(root, base_dir)
return _media_url(folder_type, path_idx, base_dir, os.path.join(root, preview_file))
def _media_url(folder_type, path_idx, base_dir, media_path):
"""The /anomalous/image URL of a file beside a model, with a token that changes with it."""
rel_subfolder = os.path.relpath(os.path.dirname(media_path), base_dir)
if rel_subfolder == '.':
rel_subfolder = '/'
q_type = urllib.parse.quote(folder_type)
q_idx = str(path_idx)
q_sub = urllib.parse.quote(rel_subfolder)
q_file = urllib.parse.quote(preview_file)
version = _cache_token(os.path.join(root, preview_file))
q_file = urllib.parse.quote(os.path.basename(media_path))
version = _cache_token(media_path)
return f"/anomalous/image?type={q_type}&path_idx={q_idx}&subfolder={q_sub}&filename={q_file}&t={version}"
def _model_covers(folder_type, path_idx, base_dir, file_path):
"""Civitai's own image kept aside (<model>.civitai_bak.*, beside or in .amb) as a URL, or "",
and whether the cover shown is another picture (the user's), not Civitai's (model_sidecars)."""
base = os.path.splitext(file_path)[0]
kept = backups(base)
saved = kept[0] if kept else ""
shown = next((base + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS if os.path.isfile(base + suffix)), "")
custom = bool(shown) and not is_civitai_cover(shown, base, kept)
return {"civitai": _media_url(folder_type, path_idx, base_dir, saved) if saved else "", "custom": custom}
async def api_model_covers(request):
"""GET /anomalous/model_covers?type&path_idx&subfolder&filename - for the model editor: the
Civitai cover it can bring back, and whether the one shown is the user's."""
try:
folder_type = request.query.get('type', '')
path_idx = int(request.query.get('path_idx', 0))
filename = require_filename(request.query.get('filename', ''))
base_dir, target_dir = resolve_folder_subdir(folder_type, path_idx, request.query.get('subfolder', '/'))
file_path = model_file_within(target_dir, filename)
except (ValueError, KeyError, TypeError):
return web.json_response({"error": "Invalid model location"}, status=400)
if not os.path.isfile(file_path):
return web.json_response({"error": "Model not found"}, status=404)
return web.json_response(await asyncio.to_thread(_model_covers, folder_type, path_idx, base_dir, file_path))
+72 -60
View File
@@ -2,6 +2,7 @@
import json
import os
import re
import shutil
from aiohttp import web
@@ -14,7 +15,18 @@ from .model_constants import (
CIVITAI_BACKUP_SUFFIXES, MEDIA_EXTENSIONS, MODEL_EXTENSIONS,
PREVIEW_SUFFIXES, SIDECAR_SUFFIXES,
)
from .utils import require_filename, resolve_folder_subdir, resolve_within
from .activity_log import ACTIVITY_DETAIL
from .metadata import get_metadata
from .model_catalog import _resolve_paths_to_model_info_sync
from .trash import move_to_trash, trash_failure
from .utils import atomic_write_json
try:
from ..model_identity import USER_INFO_SUFFIX, read_json
from ..model_sidecars import drop_empty_hidden_dir, existing, follow, is_civitai_cover, note_cover_hash, target
except ImportError:
from model_identity import USER_INFO_SUFFIX, read_json
from model_sidecars import drop_empty_hidden_dir, existing, follow, is_civitai_cover, note_cover_hash, target
from .utils import model_file_within, require_filename, resolve_folder_subdir
def _first_existing_sidecar(base_path, suffixes):
for suffix in suffixes:
@@ -25,8 +37,9 @@ def _first_existing_sidecar(base_path, suffixes):
def _reset_model_cover(base_path):
"""Restore a recoverable cover without destroying the only existing image."""
backup_path, backup_suffix = _first_existing_sidecar(base_path, CIVITAI_BACKUP_SUFFIXES)
"""Restore a recoverable cover without destroying the only existing image. Civitai's picture
kept aside becomes the cover again and is then noted as Civitai's instead of kept twice."""
backup_path, backup_suffix = next(iter(existing(base_path, CIVITAI_BACKUP_SUFFIXES)), (None, None))
original_path, _ = _first_existing_sidecar(base_path, MEDIA_EXTENSIONS)
preview_paths = [
f"{base_path}{suffix}"
@@ -43,9 +56,9 @@ def _reset_model_cover(base_path):
# remains untouched. os.replace then makes the actual restore atomic.
shutil.copy2(backup_path, temp_path)
os.replace(temp_path, restored_path)
for preview_path in preview_paths:
if preview_path != restored_path and os.path.isfile(preview_path):
os.remove(preview_path)
move_to_trash(*[path for path in preview_paths if path != restored_path])
if note_cover_hash(base_path, restored_path):
move_to_trash(backup_path) # the cover is Civitai's picture now: no second copy
except Exception as exc:
try:
if os.path.isfile(temp_path):
@@ -55,12 +68,14 @@ def _reset_model_cover(base_path):
return False, 'restore_failed', str(exc)
return True, 'civitai_backup', None
if any(is_civitai_cover(path, base_path, []) for path in preview_paths):
return True, 'civitai_backup', None # Civitai's picture is the cover already
if original_path:
try:
for preview_path in preview_paths:
os.remove(preview_path)
move_to_trash(*preview_paths)
except Exception as exc:
return False, 'restore_failed', str(exc)
return False, 'restore_failed', trash_failure(exc)
return True, 'original_cover', None
if preview_paths:
@@ -74,6 +89,11 @@ def _reset_model_cover(base_path):
async def api_delete_model(request):
try:
data = await request.json()
except ValueError:
data = None
if not isinstance(data, dict):
return web.json_response({"status": "error", "message": "Invalid request body"}, status=400)
try:
folder_type = data.get('type', 'checkpoints')
subfolder = data.get('subfolder', '/')
filename = data.get('filename', '')
@@ -85,43 +105,33 @@ async def api_delete_model(request):
try:
filename = require_filename(filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
model_path = resolve_within(target_dir, filename)
model_path = model_file_within(target_dir, filename)
except (ValueError, KeyError):
return web.json_response({"status": "error", "message": "Invalid request parameters"}, status=400)
if not os.path.exists(model_path):
return web.json_response({"status": "error", "message": "Model file not found"})
# 1. 优先尝试删除你点击的主模型文件
try:
os.remove(model_path)
except Exception as e:
error_msg = str(e)
if "being used" in error_msg or "WinError 32" in error_msg or "Permission" in error_msg:
error_msg = "文件被占用 (正在被 ComfyUI 使用)。请先重启 ComfyUI 或在工作流中卸载该模型后再删除!"
return web.json_response({"status": "error", "message": f"主模型删除失败: {error_msg}"})
base_name = os.path.splitext(filename)[0]
# 2. 主模型成功删除后,再清理配套的垃圾文件
# Sidecars are keyed by stem, not by the main model extension. If a
# second real model shares this stem, preserve the shared sidecars for
# the survivor instead of treating that model as cleanup debris.
shared_stem_in_use = any(
os.path.isfile(os.path.join(target_dir, base_name + model_ext))
for model_ext in MODEL_EXTENSIONS
for model_ext in MODEL_EXTENSIONS if base_name + model_ext != filename
)
deleted_files = [filename]
if not shared_stem_in_use:
for suffix in SIDECAR_SUFFIXES:
file_to_del = os.path.join(target_dir, base_name + suffix)
if os.path.isfile(file_to_del):
try:
os.remove(file_to_del)
deleted_files.append(base_name + suffix)
except Exception as e:
print(f"[Anomalous Browser] Warning: Failed to delete {file_to_del}: {e}")
# 3. 修正:前端期待的成功状态是 "success" 而不是 "ok"
sidecars = [] if shared_stem_in_use else [
path for path, _ in existing(os.path.join(target_dir, base_name), SIDECAR_SUFFIXES)
]
# The model and its covers and info go to the Recycle Bin together, or nothing moves.
try:
moved = move_to_trash(model_path, *sidecars)
except Exception as e:
return web.json_response({"status": "error", "message": trash_failure(e)})
drop_empty_hidden_dir(target_dir)
deleted_files = [os.path.basename(path) for path in moved]
request[ACTIVITY_DETAIL] = {"files": deleted_files}
# 前端期待的成功状态是 "success" 而不是 "ok"
return web.json_response({
"status": "success",
"deleted": deleted_files,
@@ -134,6 +144,10 @@ async def api_delete_model(request):
return web.json_response({"status": "error", "message": str(e)}, status=500)
def _is_web_link(text):
return not text or (len(text) <= 2048 and re.fullmatch(r"https?://[^\s/]+\.\S+", text, re.IGNORECASE) is not None)
async def api_update_metadata(request):
try:
data = await request.json()
@@ -162,7 +176,7 @@ async def api_update_metadata(request):
try:
filename = require_filename(extracted_filename)
_, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder)
file_path = resolve_within(target_dir, filename)
file_path = model_file_within(target_dir, filename)
except (ValueError, KeyError):
pass
@@ -182,6 +196,9 @@ async def api_update_metadata(request):
if not file_path or not os.path.exists(file_path):
return web.json_response({"status": "error", "message": "Model not found"})
# A source link is opened from the page: a web address or nothing.
if custom_source_url is not None and not _is_web_link(str(custom_source_url).strip()):
return web.json_response({"status": "error", "message": "The source link must be an http(s) address."}, status=400)
physical_rename_skipped = physical_rename_requested and is_physical_rename_protected(
folder_type=folder_type,
@@ -191,28 +208,19 @@ async def api_update_metadata(request):
base_name = os.path.splitext(file_path)[0]
model_ext = os.path.splitext(file_path)[1]
info_file = f"{base_name}.civitai.info"
info_data = {}
if os.path.exists(info_file):
parsed = False
for enc in ['utf-8', 'utf-8-sig', 'mbcs', 'latin-1']:
try:
with open(info_file, 'r', encoding=enc) as f:
info_data = json.load(f)
parsed = True
break
except Exception:
pass
if not parsed:
return web.json_response({"status": "error", "message": "Failed to parse existing .civitai.info file due to encoding or corruption. Rename aborted to prevent data loss."})
# The user's edits have a file of their own (<model>.anomalous.json): a scan never
# writes it, and other tools that rewrite .civitai.info cannot lose them.
user_file = target(base_name, USER_INFO_SUFFIX)
if os.path.exists(user_file) and read_json(user_file) is None:
return web.json_response({"status": "error", "message": f"{os.path.basename(user_file)} is unreadable; nothing was changed."})
user_data = read_json(user_file) or {"format": 1}
before = get_metadata(file_path) # what was shown, for the activity log
if 'custom_name' in data:
info_data["anomalous_custom_name"] = custom_name
user_data["custom_name"] = str(custom_name)
if 'custom_notes' in data:
info_data["anomalous_custom_notes"] = custom_notes
user_data["custom_notes"] = str(custom_notes)
if custom_source_url is not None:
info_data["anomalous_source_url"] = str(custom_source_url).strip()
user_data["source_url"] = str(custom_source_url).strip()
reset_cover = data.get('reset_cover', False)
cover_reset = None
@@ -221,8 +229,7 @@ async def api_update_metadata(request):
if reset_cover:
cover_reset, cover_reset_source, cover_reset_warning = _reset_model_cover(base_name)
with open(info_file, 'w', encoding='utf-8') as f:
json.dump(info_data, f, indent=4, ensure_ascii=False)
atomic_write_json(user_file, user_data)
new_filename = filename
@@ -236,15 +243,20 @@ async def api_update_metadata(request):
if new_file_path != file_path and not os.path.exists(new_file_path):
os.rename(file_path, new_file_path)
for suffix in SIDECAR_SUFFIXES:
old_sidecar = f"{base_name}{suffix}"
if os.path.isfile(old_sidecar):
os.rename(old_sidecar, os.path.join(target_dir, f"{safe_name}{suffix}"))
for old_sidecar, suffix in existing(base_name, SIDECAR_SUFFIXES):
os.rename(old_sidecar, follow(old_sidecar, os.path.join(target_dir, safe_name), suffix))
new_filename = f"{safe_name}{model_ext}"
elif os.path.exists(new_file_path) and new_file_path != file_path:
return web.json_response({"status": "error", "message": "A file with the target physical name already exists."})
sent = [key for key in ("custom_name", "custom_notes") if key in data]
sent += ["source_url"] if custom_source_url is not None else []
fields = [{"field": key, "before": before.get(key) or "", "after": user_data[key]}
for key in sent if user_data[key] != (before.get(key) or "")]
if new_filename != filename:
fields.append({"field": "filename", "before": filename, "after": new_filename})
request[ACTIVITY_DETAIL] = {"fields": fields}
response_data = {
"status": "success",
"new_filename": new_filename,
+313
View File
@@ -0,0 +1,313 @@
"""Models in the wrong models folder, and identical copies (the models page's Tidy view,
ui_model_tidy.js).
GET /anomalous/placement/check[?deep=1] -> {misplaced, duplicates, unchecked}
misplaced: models whose header (model_kind.py, only when sure) names another type than
the folder they are in, with where they belong (the same subfolder in a folder of that
type, on the same drive when there is one) and whether they still load where they are.
duplicates: files with the same SHA-256 — known from scans, else read now for files of
the same size (small ones always, large ones with deep). unchecked: the same-size large
files a deep check would still read.
GET /anomalous/placement/look?type&root&rel -> {kind, sure, base}: what one model's header says
it is, even when unsure (the models page's "Change type", ui_model_retype.js).
POST /anomalous/placement/move {type, root, rel, to_type, to_root, to_rel} -> moves a model
and its sidecars (covers, info, the user's notes); never over a file (" (2)"). Only for
the computer ComfyUI runs on, unless remote changes are allowed (local_guard.may_change).
belongs() is the same check for one file (the AI's search_models and move_model,
api/mcp_model_use.py). Removing a copy goes through /anomalous/delete_model (Recycle Bin). Headers and computed
SHA-256s are kept in memory per file (path, size, time).
"""
import asyncio
import os
import threading
import urllib.parse
from aiohttp import web
import folder_paths
try:
from ..model_identity import file_sha256
from ..model_kind import inspect_file
from ..model_sidecars import drop_empty_hidden_dir, existing, follow
except ImportError: # loaded outside the package (tests)
from model_identity import file_sha256
from model_kind import inspect_file
from model_sidecars import drop_empty_hidden_dir, existing, follow
from .activity_log import add_entry
from .local_guard import may_change
from .metadata import get_metadata
from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES, SIDECAR_SUFFIXES
from .model_download import MODEL_EXTENSIONS, clean_folder, forget_file_lists
from .model_import import Refused, destination, import_types, move_into
from .path_utils import model_reference_within
# (folder type, what the file is): it loads there all the same.
WORKS_ANYWAY = {("diffusion_models", "checkpoints")}
# Same-size files up to this are read at once (LoRAs, embeddings); larger ones on "Compare".
AUTO_HASH = 256 * 1024 * 1024
CACHE_LIMIT = 5000
_lock = threading.Lock()
_heads = {} # path -> (size, mtime_ns, inspect_file result)
_hashes = {} # path -> (size, mtime_ns, sha256)
def _cached(store, path, stat, compute):
key = (stat.st_size, stat.st_mtime_ns)
with _lock:
hit = store.get(path)
if hit and hit[:2] == key:
return hit[2]
value = compute(path)
with _lock:
if len(store) >= CACHE_LIMIT:
store.clear()
store[path] = (*key, value)
return value
def library():
"""Every model file once: [{type, root, rel, path, size, dev}] (a folder listed under two
types counts for the first)."""
seen, files = set(), []
for folder_type in import_types():
for index, base in enumerate(folder_paths.get_folder_paths(folder_type) or []):
if not os.path.isdir(base):
continue
for current, dirs, names in os.walk(base):
dirs[:] = sorted(name for name in dirs if not name.startswith("."))
for name in sorted(names):
if not name.lower().endswith(MODEL_EXTENSIONS):
continue
path = os.path.join(current, name)
real = os.path.realpath(path)
if real in seen:
continue
seen.add(real)
try:
stat = os.stat(path)
except OSError:
continue
files.append({"type": folder_type, "root": index, "rel": os.path.relpath(path, base).replace(os.sep, "/"),
"path": path, "size": stat.st_size, "dev": stat.st_dev, "stat": stat})
return files
def _card(item):
"""What the page shows of a file: its name (the user's, else Civitai's, else the file's),
Civitai version and cover URL."""
try:
meta = get_metadata(item["path"])
except Exception:
meta = {}
folder, name = os.path.split(item["path"])
stem = os.path.splitext(name)[0]
cover = next((stem + suffix for suffix in PREVIEW_SUFFIXES + MEDIA_EXTENSIONS
if os.path.isfile(os.path.join(folder, stem + suffix))), "")
sub = item["rel"].rpartition("/")[0]
url = ""
if cover:
url = (f"/anomalous/image?type={urllib.parse.quote(item['type'])}&path_idx={item['root']}"
f"&subfolder={urllib.parse.quote(sub)}&filename={urllib.parse.quote(cover)}")
version = meta.get("version_name") or ""
name = meta.get("custom_name") or ""
if not name and meta.get("model_id"): # Civitai's "Model - Version": the model's part
full = meta.get("name") or ""
name = full[:-len(" - " + version)] if version and full.endswith(" - " + version) else full
return {"name": name or stem, "version": version, "preview_url": url}
def home_for(item, kind):
"""Where a file of `kind` goes: its own subfolder and name, in a folder of that type,
preferably on its drive (a move is then a rename)."""
roots = []
for index, base in enumerate(folder_paths.get_folder_paths(kind) or []):
if os.path.isdir(base):
roots.append((index, os.stat(base).st_dev))
if not roots:
return None
index = next((index for index, dev in roots if dev == item["dev"]), roots[0][0])
return {"type": kind, "root": index, "rel": item["rel"]}
def misplaced(files):
found = []
for item in files:
result = _cached(_heads, item["path"], item["stat"], inspect_file)
kind = result.get("kind")
if not kind or not result.get("sure") or kind == item["type"] or kind not in import_types():
continue
home = home_for(item, kind)
if home:
found.append({"type": item["type"], "root": item["root"], "rel": item["rel"], "size": item["size"],
"kind": kind, "base": result.get("base", ""), "works": (item["type"], kind) in WORKS_ANYWAY,
"to": home, **_card(item)})
return found
def one(folder_type, root, rel, path):
"""One model file as library() lists it."""
stat = os.stat(path)
return {"type": folder_type, "root": root, "rel": rel, "path": path, "size": stat.st_size, "dev": stat.st_dev, "stat": stat}
def belongs(item):
"""misplaced() of one file (from one()): where it belongs, or None when it is where it goes or its kind is unsure."""
found = misplaced([item])
return found[0] if found else None
def _known_hash(item):
try:
value = str(get_metadata(item["path"]).get("hash") or "").lower()
except Exception:
value = ""
if len(value) == 64:
return value
with _lock:
hit = _hashes.get(item["path"])
return hit[2] if hit and hit[:2] == (item["stat"].st_size, item["stat"].st_mtime_ns) else ""
def _copy(item):
"""One of identical files: where it is, how it shows, whether its folder fits what it is
(the copy to keep, when only one does)."""
kind = _cached(_heads, item["path"], item["stat"], inspect_file).get("kind")
return {"type": item["type"], "root": item["root"], "rel": item["rel"],
"fits": not kind or kind == item["type"], **_card(item)}
def duplicates(files, deep=False):
"""(groups of identical files, {groups, bytes} still to read for a deep check)."""
by_size = {}
for item in files:
if item["size"]:
by_size.setdefault(item["size"], []).append(item)
groups, unchecked = {}, {"groups": 0, "bytes": 0}
for size, items in by_size.items():
if len(items) < 2:
continue
unknown = [item for item in items if not _known_hash(item)]
read = deep or size <= AUTO_HASH
if unknown and not read:
unchecked["groups"] += 1
unchecked["bytes"] += size * len(unknown)
for item in items:
sha = _known_hash(item)
if not sha and read:
sha = _cached(_hashes, item["path"], item["stat"], file_sha256)
if sha:
groups.setdefault(sha, []).append(item)
found = [{"sha256": sha, "size": items[0]["size"], "files": [_copy(item) for item in items]}
for sha, items in groups.items() if len(items) > 1]
return found, unchecked
def check(deep=False):
files = library()
found, unchecked = duplicates(files, deep)
return {"misplaced": misplaced(files), "duplicates": found, "unchecked": unchecked, "checked": len(files)}
def _sidecars(path):
"""The model's covers, info and notes: files named by its stem, beside it or in the folder's
.amb, unless another model in its folder has that stem too (then they are shared, and stay)."""
folder, name = os.path.split(path)
stem = os.path.splitext(name)[0]
if any(os.path.isfile(os.path.join(folder, stem + ext)) for ext in MODEL_EXTENSIONS if stem + ext != name):
return []
return existing(os.path.join(folder, stem), SIDECAR_SUFFIXES)
def _source(folder_type, root, rel):
"""(root index, clean rel, file) of the model at type `folder_type`, root index `root`, `rel`."""
if folder_type not in import_types():
raise Refused("bad_type")
paths = folder_paths.get_folder_paths(folder_type) or []
try:
index = int(root)
if index < 0:
raise IndexError(index)
base = os.path.realpath(paths[index])
rel = clean_folder(rel)
source = model_reference_within(base, rel.replace("/", os.sep))
except (ValueError, TypeError, IndexError):
raise Refused("bad_path")
if not os.path.isfile(source) or not rel.lower().endswith(MODEL_EXTENSIONS):
raise Refused("gone")
return index, rel, source
def look(folder_type, root, rel):
"""What the header of one model says it is: {type, root, rel, kind, sure, base}."""
index, rel, source = _source(folder_type, root, rel)
found = _cached(_heads, source, os.stat(source), inspect_file)
return {"type": folder_type, "root": index, "rel": rel, "kind": found.get("kind") or "",
"sure": bool(found.get("sure")), "base": found.get("base") or ""}
def move(data):
folder_type = data.get("type")
index, rel, source = _source(folder_type, data.get("root", 0), data.get("rel"))
to_type, to_root, root_path, dest = destination(
{"type": data.get("to_type"), "root": data.get("to_root", 0), "rel": data.get("to_rel")}, os.path.getsize(source))
sidecars = _sidecars(source)
move_into(source, dest, keep=False)
moved, left = [os.path.basename(dest)], []
dest_stem = os.path.splitext(dest)[0]
for path, suffix in sidecars:
target = follow(path, dest_stem, suffix)
try:
if os.path.exists(target):
raise FileExistsError(target)
move_into(path, target, keep=False)
moved.append(os.path.basename(target))
except OSError as error:
left.append(os.path.basename(path))
print(f"[Anomalous Browser] Tidy: {os.path.basename(path)} stayed where it was: {error}")
drop_empty_hidden_dir(os.path.dirname(source))
forget_file_lists()
to_rel = os.path.relpath(dest, root_path).replace(os.sep, "/")
add_entry("file", "model_move", to_rel, {"move": {
"from": {"type": folder_type, "path_idx": index, "rel": rel},
"to": {"type": to_type, "path_idx": to_root, "rel": to_rel}, "files": moved, "left": left,
}})
return {"type": to_type, "root": to_root, "rel": to_rel, "left": left}
async def api_check(request):
deep = request.query.get("deep") == "1"
return web.json_response(await asyncio.to_thread(check, deep))
async def api_look(request):
query = request.query
try:
return web.json_response(await asyncio.to_thread(look, query.get("type"), query.get("root", "0"), query.get("rel")))
except Refused as error:
return web.json_response({"error": error.code}, status=404 if error.code == "gone" else 400)
async def api_move(request):
if not may_change(request):
return web.json_response({"error": "not_local"}, status=403)
try:
data = await request.json()
if not isinstance(data, dict):
raise ValueError
return web.json_response(await asyncio.to_thread(move, data))
except Refused as error:
return web.json_response({"error": error.code}, status=404 if error.code == "gone" else 400)
except ValueError:
return web.json_response({"error": "bad_request"}, status=400)
except OSError as error:
print(f"[Anomalous Browser] Tidy: could not move the model: {error}")
return web.json_response({"error": "io"}, status=500)
def register_routes(app):
app.router.add_get("/anomalous/placement/check", api_check)
app.router.add_get("/anomalous/placement/look", api_look)
app.router.add_post("/anomalous/placement/move", api_move)
+8 -10
View File
@@ -3,16 +3,17 @@
import asyncio
import json
import os
import struct
import threading
from aiohttp import web
import folder_paths
try:
from ..model_identity import computed_file_identity, normalise_sha256
from ..model_identity import computed_file_identity, file_sha256, infer_base_model_from_header, normalise_sha256
from ..model_sidecars import find, target
except ImportError:
from model_identity import computed_file_identity, normalise_sha256
from model_identity import computed_file_identity, file_sha256, infer_base_model_from_header, normalise_sha256
from model_sidecars import find, target
try:
from ..model_policies import requires_hash_for_model_recovery
except ImportError:
@@ -106,14 +107,12 @@ def _size_candidate_payload(candidate, require_hash=False):
def _compute_and_save_fallback_info(file_path, file_hash):
try:
base_path = os.path.splitext(file_path)[0]
info_path = base_path + ".info"
civitai_info_path = base_path + ".civitai.info"
if os.path.exists(info_path) or os.path.exists(civitai_info_path):
info_path = info_path if os.path.exists(info_path) else civitai_info_path
info_path = find(base_path, ".info") or (civitai_info_path if os.path.exists(civitai_info_path) else None)
if info_path:
with open(info_path, encoding='utf-8') as source:
info_data = json.load(source)
else:
from scraper import infer_base_model_from_header
filename = os.path.basename(file_path)
inferred_base = infer_base_model_from_header(file_path) if file_path.lower().endswith('.safetensors') else ""
if inferred_base == 'Unknown':
@@ -131,7 +130,7 @@ def _compute_and_save_fallback_info(file_path, file_hash):
"files": [{"hashes": {"SHA256": file_hash.lower()}}]
}
info_data["anomalous_file_identity"] = computed_file_identity(file_path, file_hash)
atomic_write_json(info_path, info_data)
atomic_write_json(info_path or target(base_path, ".info"), info_data)
except Exception:
pass
@@ -159,10 +158,9 @@ def _resolve_from_candidates(candidates, target_hash="", target_size=None, filen
# dynamically compute their hash on-demand to test against target_hash
unhashed_size_matches = [c for c in size_matches if not _candidate_hashes(c)]
if unhashed_size_matches:
from scraper import calculate_sha256
for candidate in unhashed_size_matches:
try:
computed_hash = calculate_sha256(candidate["path"]).upper()
computed_hash = file_sha256(candidate["path"]).upper()
candidate.setdefault("hashes", set()).add(computed_hash)
if computed_hash == target_hash:
_compute_and_save_fallback_info(candidate["path"], computed_hash)
+54
View File
@@ -0,0 +1,54 @@
"""One model type as a single list: a models folder and all its subfolders (the models page's type chips).
Listing a type also queues its covers' card images for the background (media_routes).
"""
import asyncio
import os
import urllib.parse
from aiohttp import web
import folder_paths
from .model_catalog import _collect_folder_models
from .media_routes import queue_card_images
def _collect_type_models(folder_type, path_idx):
"""Every model under one registered models folder, folder by folder in name order."""
try:
base_dir = folder_paths.get_folder_paths(folder_type)[path_idx]
except Exception:
return []
if not os.path.isdir(base_dir):
return []
models = []
for root, dirs, _files in os.walk(base_dir):
dirs.sort(key=str.lower)
rel = os.path.relpath(root, base_dir).replace('\\', '/')
models += _collect_folder_models(root, folder_type, path_idx, '' if rel == '.' else rel, 1, 0)["models"]
return models
async def api_get_type_models(request):
"""GET /anomalous/type_models?type&path_idx - {models, total}; each model names its own subfolder."""
try:
path_idx = int(request.query.get('path_idx', 0))
except ValueError:
path_idx = -1
if path_idx < 0:
return web.json_response({"status": "error", "message": "Invalid path_idx"}, status=400)
models = await asyncio.to_thread(_collect_type_models, request.query.get('type', ''), path_idx)
queue_card_images(_cover_paths(models))
return web.json_response({"models": models, "total": len(models)})
def _cover_paths(models):
"""The cover file of each listed model: it sits beside the model, named in its preview URL."""
paths = []
for model in models:
query = urllib.parse.urlparse(model.get("preview_url") or "").query
name = urllib.parse.parse_qs(query).get("filename", [""])[0]
if name and model.get("file_path"):
paths.append(os.path.join(os.path.dirname(model["file_path"]), name))
return paths
+365
View File
@@ -0,0 +1,365 @@
"""When each model and node pack here was last used (the models page's Tidy tab, "Not used lately").
GET /anomalous/usage/check -> {models, packs, seen, now, ledger_since}
Evidence, read only when the user asks:
- the run record (usage_ledger.py): every run since it started, kept or not, and every
workflow opened on the page (the open tabs are noted just before a check);
- the outputs from before that: PNG images (their prompt names model files and node types;
image_search.cached_record) and FLAC / Ogg sounds (audio_metadata), dated by the file;
- the saved ComfyUI workflows, recipes, combos and parameter sets (any string naming a model
file, any node type, any SHA-256).
A model counts as used when its path, its file name (two files of one name: both count, the
safe side) or its scanned SHA-256 appears.
Nothing new is judged: a model or pack counts as idle since its last use or since it came
here (`added`: the file's or folder's creation date), whichever is later.
models: idle for 30 days or more, with {type, root, rel, size, name, version, preview_url,
last_used (seconds or null), outputs, runs, opened, saved (saved files naming it), added}.
packs: the node packs in custom_nodes but this plugin and ComfyUI Manager, {name, nodes,
last_used, outputs, runs, opened, saved, added, web (it adds to ComfyUI's page too), feeds (packs
whose nodes take a kind of thing only its nodes make), words (what to search the Manager
for), kept (the user said they still use it)}; a pack of no nodes only works on the page or
the server, nothing here can tell.
seen: {outputs, saved, capped} what was read (capped: some outputs or files were not).
ledger_since: when the run record started (null: nothing run since).
POST /anomalous/usage/keep {pack, kept} -> {kept: [names]}
What no record can show (a pack used only through its panels or buttons) the user says:
"I still use it", kept in user/anomalous/usage_kept.json ({packs: {name: since}}); only a
pack ComfyUI loaded can be marked, any name unmarked.
What none of this can show (a workflow from before the record, dropped in and never saved) is
said on the page; nothing here deletes or disables anything.
"""
import asyncio
import json
import os
import re
import threading
import time
import folder_paths
from aiohttp import web
from . import usage_ledger
from .audio_metadata import read_audio_tags
from .backup import part_dirs
from .image_search import MODEL_FILE, cached_record
from .model_placement import _card, _known_hash, library
from .path_utils import atomic_write_json
RECENT_SECONDS = 30 * 24 * 3600 # used or added within this: not listed
MAX_IMAGES = 100_000 # outputs read at most, images and sounds
MAX_SAVED = 20_000
MAX_JSON_BYTES = 20 * 1024 * 1024
SAVED_PARTS = ("comfy_workflows", "recipes", "combos", "parameters")
SOUNDS = (".flac", ".ogg", ".opus")
SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
NOT_LISTED = {os.path.basename(PLUGIN_DIR).lower(), "comfyui-manager"} # the tools doing the tidying
FEEDS_SHOWN = 3
# Words left out of what the Manager is searched for: its search wants all words, in order, in
# one of a pack's title, author or description, and pack folders carry these where titles do not.
SEARCH_NOISE = {"comfyui", "comfy", "custom", "customnodes", "node", "nodes", "pack", "main", "master"}
_kept_lock = threading.Lock()
def _refs(value):
ref = str(value).replace("\\", "/").strip().lower()
return {ref, ref.rsplit("/", 1)[-1]}
def _arrived(stat, changed=True):
"""When a file or folder came here: its creation date, or its last change when that is later
(`changed`; a copied file keeps its old change date, a folder's changes with every update)."""
born = getattr(stat, "st_birthtime", None) or stat.st_ctime
return max(born, stat.st_mtime) if changed else born
class Evidence:
"""Per model reference / hash / node type: how many outputs, runs, openings and saved files,
and the latest time."""
def __init__(self):
self.outputs, self.runs, self.opened, self.saved, self.last = {}, {}, {}, {}, {}
self.seen = {"outputs": 0, "saved": 0, "capped": False}
def add(self, keys, when=None):
for key in keys:
if when is None:
self.saved[key] = self.saved.get(key, 0) + 1
else:
self.outputs[key] = self.outputs.get(key, 0) + 1
self.last[key] = max(self.last.get(key, 0), when)
def add_noted(self, counts, key, when, count):
"""A run record entry; `counts` is self.runs or self.opened."""
counts[key] = counts.get(key, 0) + count
self.last[key] = max(self.last.get(key, 0), when)
def of(self, keys):
keys = [key for key in keys if key]
last = max((self.last.get(key, 0) for key in keys), default=0)
return {"last_used": last or None,
**{kind: max((counts.get(key, 0) for key in keys), default=0)
for kind, counts in (("outputs", self.outputs), ("runs", self.runs), ("opened", self.opened), ("saved", self.saved))}}
def _walk_json(value, found):
"""Model references ('m:'), SHA-256s ('h:') and node types ('t:') anywhere in saved JSON."""
stack = [value]
while stack:
item = stack.pop()
if isinstance(item, dict):
for key in ("type", "class_type"):
if isinstance(item.get(key), str) and item[key]:
found.add("t:" + item[key])
stack.extend(item.values())
elif isinstance(item, list):
stack.extend(item)
elif isinstance(item, str):
if MODEL_FILE.search(item):
found.update("m:" + ref for ref in _refs(item))
elif SHA256.match(item):
found.add("h:" + item.upper())
def _read_saved(evidence):
dirs = part_dirs()
for part in SAVED_PARTS:
base = dirs.get(part)
if not base or not os.path.isdir(base):
continue
for current, folders, names in os.walk(base):
folders[:] = [name for name in folders if not name.startswith(".")]
for name in names:
if not name.lower().endswith(".json"):
continue
if evidence.seen["saved"] >= MAX_SAVED:
evidence.seen["capped"] = True
return
path = os.path.join(current, name)
try:
if os.path.getsize(path) > MAX_JSON_BYTES:
continue
with open(path, encoding="utf-8") as f:
data = json.load(f)
except (OSError, ValueError):
continue
found = set()
_walk_json(data, found)
evidence.seen["saved"] += 1
evidence.add(found)
def _sound_keys(path):
"""What a sound's embedded prompt names, as evidence keys; empty without one."""
try:
prompt = json.loads(read_audio_tags(path).get("prompt") or "null")
except (OSError, ValueError):
return set()
if not isinstance(prompt, dict):
return set()
types, models = usage_ledger.prompt_uses(prompt)
return {"t:" + node_type for node_type in types} | {"m:" + ref for model in models for ref in _refs(model)}
def _read_outputs(evidence):
base = folder_paths.get_output_directory()
for current, folders, names in os.walk(base):
folders[:] = [name for name in folders if not name.startswith(".")]
for name in names:
lower = name.lower()
if not lower.endswith((".png",) + SOUNDS):
continue
if evidence.seen["outputs"] >= MAX_IMAGES:
evidence.seen["capped"] = True
return
path = os.path.join(current, name)
try:
when = os.stat(path).st_mtime
except OSError:
continue
if lower.endswith(SOUNDS):
keys = _sound_keys(path)
else:
record = cached_record(path, when)
if not record:
continue
keys = {"m:" + ref for ref in record["models"]} | {"h:" + sha for sha in record["hashes"]}
keys |= {"t:" + node_type for node_type in record.get("types", ())}
if not keys:
continue
evidence.seen["outputs"] += 1
evidence.add(keys, when)
def _read_ledger(evidence):
"""Every run and opened workflow since the record started; its start, or None."""
ledger = usage_ledger.read()
for names, counts in ((ledger, evidence.runs), (ledger["opened"], evidence.opened)):
for node_type, (when, count) in names["types"].items():
evidence.add_noted(counts, "t:" + node_type, when, count)
for model, (when, count) in names["models"].items():
for ref in _refs(model):
evidence.add_noted(counts, "m:" + ref, when, count)
return ledger["started"]
def _io_types(cls):
"""(kinds of thing a node takes, kinds it makes); combo lists and '*' left out."""
taken, made = set(), set()
try:
inputs = cls.INPUT_TYPES() or {}
for group in ("required", "optional"):
for spec in (inputs.get(group) or {}).values():
kind = spec[0] if isinstance(spec, (list, tuple)) and spec else spec
if isinstance(kind, str):
taken.add(kind)
except Exception: # noqa: BLE001 - a pack's own code; it only means "takes nothing known"
pass
try:
made = {kind for kind in (cls.RETURN_TYPES or ()) if isinstance(kind, str)}
except Exception: # noqa: BLE001
pass
return taken - {"*"}, made - {"*"}
def _search_words(name):
words = [word for word in re.split(r"[\s_.\-]+", name.lower()) if word and word not in SEARCH_NOISE]
return " ".join(words) or name.lower()
def _pack_dirs():
"""{name: {name, path, dir, types: []}} for the node packs ComfyUI loaded from custom_nodes."""
import nodes # ComfyUI's registry, filled once ComfyUI has loaded the packs
roots = []
try:
roots = [os.path.normcase(os.path.realpath(root)) for root in folder_paths.get_folder_paths("custom_nodes") or []]
except Exception: # noqa: BLE001
pass
packs = {}
for module, directory in (getattr(nodes, "LOADED_MODULE_DIRS", {}) or {}).items():
real = os.path.normcase(os.path.realpath(directory))
name = os.path.splitext(os.path.basename(module))[0]
if real in roots: # a single .py file in custom_nodes
packs[name] = {"name": name, "path": os.path.join(directory, name + ".py"), "dir": None, "types": []}
elif os.path.normcase(os.path.realpath(os.path.dirname(directory))) in roots:
packs[name] = {"name": name, "path": directory, "dir": real, "types": []}
return packs
def _packs():
"""[{name, path, dir, types, web, feeds}] for the node packs ComfyUI loaded from custom_nodes."""
import nodes
packs = _pack_dirs()
io, core = {}, set()
for node_type, cls in (getattr(nodes, "NODE_CLASS_MAPPINGS", {}) or {}).items():
module = str(getattr(cls, "RELATIVE_PYTHON_MODULE", "") or "")
taken, made = _io_types(cls)
pack = module.split(".", 2)[1] if module.startswith("custom_nodes.") else None
if pack in packs:
packs[pack]["types"].append(node_type)
io.setdefault(pack, [set(), set()])
io[pack][0] |= taken
io[pack][1] |= made
elif pack is None:
core |= taken | made
web_dirs = [os.path.normcase(os.path.realpath(path)) for path in (getattr(nodes, "EXTENSION_WEB_DIRS", {}) or {}).values()]
for name, pack in packs.items():
pack["web"] = bool(pack["dir"]) and any(path == pack["dir"] or path.startswith(pack["dir"] + os.sep) for path in web_dirs)
own = io.get(name, [set(), set()])[1] - core
pack["feeds"] = sorted((other for other, (taken, _) in io.items() if other != name and own & taken), key=str.lower)
return [pack for name, pack in packs.items() if name.lower() not in NOT_LISTED]
def kept_path():
return os.path.join(os.path.dirname(usage_ledger.ledger_path()), "usage_kept.json")
def _kept():
"""{pack name: since} the user still uses."""
try:
with open(kept_path(), encoding="utf-8") as f:
data = json.load(f)
except (OSError, ValueError):
return {}
packs = data.get("packs") if isinstance(data, dict) else None
return packs if isinstance(packs, dict) else {}
def keep(name, kept):
"""Marks pack `name` as still used (`kept`) or not; the names marked. KeyError: not a loaded pack."""
with _kept_lock:
packs = _kept()
if kept:
if name not in _pack_dirs() or name.lower() in NOT_LISTED:
raise KeyError(name)
packs[name] = time.time()
else:
packs.pop(name, None)
atomic_write_json(kept_path(), {"packs": packs})
return sorted(packs)
def check(now=None):
now = time.time() if now is None else now
evidence = Evidence()
ledger_since = _read_ledger(evidence)
_read_saved(evidence)
_read_outputs(evidence)
models = []
for item in library():
keys = ["m:" + ref for ref in _refs(item["rel"])]
sha = _known_hash(item)
if sha:
keys.append("h:" + sha.upper())
use = evidence.of(keys)
added = _arrived(item["stat"]) if item.get("stat") else None
if now - max(use["last_used"] or 0, added or 0) < RECENT_SECONDS:
continue
models.append({"type": item["type"], "root": item["root"], "rel": item["rel"], "size": item["size"],
"added": added, **_card(item), **use})
packs, kept = [], _kept()
for pack in sorted(_packs(), key=lambda entry: entry["name"].lower()):
uses = [evidence.of(["t:" + node_type]) for node_type in pack["types"]]
try:
added = _arrived(os.stat(pack["path"]), changed=False)
except OSError:
added = None
packs.append({"name": pack["name"], "nodes": len(pack["types"]), "added": added, "web": pack["web"],
"feeds": pack["feeds"][:FEEDS_SHOWN], "words": _search_words(pack["name"]), "kept": pack["name"] in kept,
"last_used": max((use["last_used"] or 0 for use in uses), default=0) or None,
**{kind: max((use[kind] for use in uses), default=0) for kind in ("outputs", "runs", "opened", "saved")}})
return {"models": models, "packs": packs, "seen": evidence.seen, "now": now, "ledger_since": ledger_since}
async def api_check(request):
return web.json_response(await asyncio.to_thread(check))
async def api_keep(request):
try:
data = await request.json()
except ValueError:
data = None
name, kept = (data.get("pack"), data.get("kept")) if isinstance(data, dict) else (None, None)
if not isinstance(name, str) or not name or not isinstance(kept, bool):
return web.json_response({"error": "bad_request"}, status=400)
try:
return web.json_response({"kept": await asyncio.to_thread(keep, name, kept)})
except KeyError:
return web.json_response({"error": "unknown_pack"}, status=404)
except (OSError, ValueError):
return web.json_response({"error": "write_failed"}, status=500)
def register_routes(app):
app.router.add_get("/anomalous/usage/check", api_check)
app.router.add_post("/anomalous/usage/keep", api_keep)
+1 -1
View File
@@ -10,7 +10,7 @@ from .model_catalog import (
_allowed_folder_types, _collect_all_scan_models, _collect_folder_models,
_collect_folders, _find_model_sync, _iter_search_models, _model_info_for_path,
_resolve_paths_to_model_info_sync, _resolve_paths_to_previews_sync,
api_base_models, api_batch_select, api_compatible_models, api_find_model,
api_base_models, api_compatible_models, api_find_model,
api_get_all_scan_models, api_get_folders, api_get_models,
api_resolve_paths_to_previews,
)
+62
View File
@@ -0,0 +1,62 @@
"""GET /anomalous/civitai_check: whether this computer reaches Civitai now, through the proxy in
Settings -> Network when one is set (net_proxy.py); for Settings' "Test" button and the first-run
setup. One small request with the key, if one is set, so a key Civitai refuses is told apart;
when civitai.com does not answer, Civitai's other address (civitai.red) is asked too, and the
answer says which one answered (`host`). It goes online, so it answers this computer only
(local_guard LOCAL_GETS)."""
import asyncio
import time
import urllib.error
import urllib.request
from aiohttp import web
from .download_sources import civitai_headers
try:
from ..civitai_client import API_HOSTS, answered
from ..net_proxy import configured, failure, urlopen
except ImportError: # loaded outside the package (tests)
from civitai_client import API_HOSTS, answered
from net_proxy import configured, failure, urlopen
PROBE_PATH = "/api/v1/models?limit=1"
TIMEOUT = 8
def check_civitai():
"""{reachable, key ('set' | 'none' | 'refused'), ms, error, failure (net_proxy.failure), proxy,
host (the address that answered, or '')}."""
headers = civitai_headers()
key = "set" if "Authorization" in headers else "none"
started = time.monotonic()
def answer(reachable, key, error="", why="", host=""):
return {"reachable": reachable, "key": key, "ms": round((time.monotonic() - started) * 1000),
"error": error, "failure": why, "proxy": configured(), "host": host}
first = None
for host in API_HOSTS:
try:
with urlopen(urllib.request.Request(f"https://{host}{PROBE_PATH}", headers=headers), timeout=TIMEOUT) as response:
response.read(1024)
answered(host)
return answer(True, key, host=host)
except urllib.error.HTTPError as error:
# Civitai answered: the network is fine; 401/403 with a key means the key is refused.
answered(host)
if error.code in (401, 403) and key == "set":
return answer(True, "refused", f"HTTP {error.code}", failure(error), host)
return answer(error.code < 500, key, f"HTTP {error.code}", failure(error), host)
except (urllib.error.URLError, OSError, ValueError) as error:
reason = getattr(error, "reason", None) or error
first = first or answer(False, key, str(reason)[:200], failure(error))
return first
async def api_civitai_check(request):
return web.json_response(await asyncio.to_thread(check_civitai))
def register_routes(app):
app.router.add_get("/anomalous/civitai_check", api_civitai_check)
+65
View File
@@ -0,0 +1,65 @@
"""Saving one canvas node's current values as a parameter material (the current-node panel's "save")."""
import asyncio
import hashlib
import json
import time
import uuid
from aiohttp import web
from . import materials
from .material_schema import MAX_MATERIAL_NAME_LENGTH, _material_summary, _normalise_material_tags
from .notebooks import MAX_NOTEBOOK_BYTES
def _node_material(payload):
node = payload.get("node")
if not isinstance(node, dict):
raise ValueError("Invalid node")
node_type = node.get("type")
if not isinstance(node_type, str) or not node_type.strip() or len(node_type) > 200:
raise ValueError("Invalid node type")
values = node.get("widgets_values")
if not isinstance(values, list) or not values:
raise ValueError("Invalid node values")
encoded = json.dumps([node_type, values], ensure_ascii=False, allow_nan=False, sort_keys=True)
if len(encoded.encode("utf-8")) > MAX_NOTEBOOK_BYTES:
raise ValueError("Node values are too large")
title = node.get("title") if isinstance(node.get("title"), str) else ""
title = title.strip()[:200] or node_type
name = payload.get("name") or title
if not isinstance(name, str) or not (name := name.strip()) or len(name) > MAX_MATERIAL_NAME_LENGTH:
raise ValueError("Invalid material name")
return {
"schema_version": materials.MATERIAL_SCHEMA_VERSION,
"id": uuid.uuid4().hex,
"kind": "node_parameter_selection",
"name": name,
"tags": _normalise_material_tags(payload.get("tags", [])),
"timestamp": int(time.time() * 1000),
"source": {
"type": "canvas_node",
"parameter_name": title,
"parameter_signature": hashlib.sha256(encoded.encode("utf-8")).hexdigest(),
},
"workflow": {"nodes": [{"id": 1, "type": node_type, "title": title, "widgets_values": values}]},
"selection": {"scope": "nodes", "node_ids": [1]},
"capabilities": ["apply_node_parameters"],
}
async def api_save_node_material(request):
try:
material = _node_material(await request.json())
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid node material"}, status=400)
filename = f"material_{int(time.time())}_{material['id']}.json"
try:
duplicate = await asyncio.to_thread(
materials._persist_parameter_material, materials.get_materials_dir(), filename, material, False)
except (OSError, ValueError):
return web.json_response({"status": "error", "message": "Could not save node material"}, status=500)
if duplicate:
return web.json_response({"status": "duplicate", "filename": duplicate["filename"], "name": duplicate["name"]}, status=409)
return web.json_response({"status": "success", "filename": filename, "material": _material_summary(filename, material)})
+127 -123
View File
@@ -1,136 +1,140 @@
import asyncio
import hashlib
import json
import logging
import asyncio
import hashlib
import json
import logging
import os
import threading
from aiohttp import web
import folder_paths
from .utils import atomic_write_json, require_filename, resolve_within
MAX_NOTEBOOK_BYTES = 2 * 1024 * 1024
LEGACY_NOTEBOOKS_DIR = os.path.join(os.path.dirname(__file__), "notebooks")
_notebooks_lock = threading.RLock()
def _read_notebook(path):
if os.path.getsize(path) > MAX_NOTEBOOK_BYTES:
raise ValueError("Notebook is too large")
with open(path, encoding="utf-8") as source:
data = json.load(source)
if not isinstance(data, dict):
raise ValueError("Invalid notebook")
return data
def _migrate_notebooks(directory):
marker = resolve_within(directory, ".legacy_imported.json")
if os.path.exists(marker):
return
skipped = []
if os.path.isdir(LEGACY_NOTEBOOKS_DIR):
for filename in sorted(os.listdir(LEGACY_NOTEBOOKS_DIR)):
if not filename.endswith(".json") or filename.startswith("."):
continue
try:
source = resolve_within(LEGACY_NOTEBOOKS_DIR, require_filename(filename))
data = _read_notebook(source)
except (OSError, ValueError):
skipped.append(filename)
logging.warning("Anomalous: could not migrate notebook %s; original preserved", filename)
continue
target = resolve_within(directory, filename)
if os.path.exists(target):
try:
if _read_notebook(target) == data:
continue
except (OSError, ValueError):
pass
digest = hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()[:12]
recovered = f"{os.path.splitext(filename)[0][:100]}_legacy_{digest}.json"
target = resolve_within(directory, recovered)
if os.path.exists(target):
if _read_notebook(target) != data:
raise ValueError("Conflicting migrated notebook")
continue
atomic_write_json(target, data, MAX_NOTEBOOK_BYTES)
atomic_write_json(marker, {"version": 1, "skipped": skipped})
def get_notebooks_dir():
user_dir = folder_paths.get_user_directory()
directory = os.path.join(user_dir, "workflows", "anomalous_notebooks")
os.makedirs(directory, exist_ok=True)
with _notebooks_lock:
_migrate_notebooks(directory)
return directory
def _list_notebooks():
with _notebooks_lock:
directory = get_notebooks_dir()
notebooks = []
for filename in sorted(os.listdir(directory)):
if not filename.endswith(".json") or filename.startswith("."):
continue
try:
data = _read_notebook(resolve_within(directory, filename))
notebooks.append({"filename": filename, "name": data.get("name", filename[:-5]), "data": data})
except (OSError, ValueError):
continue
return notebooks
from .trash import move_to_trash
from .utils import atomic_write_json, require_filename, resolve_within
MAX_NOTEBOOK_BYTES = 2 * 1024 * 1024
LEGACY_NOTEBOOKS_DIR = os.path.join(os.path.dirname(__file__), "notebooks")
_notebooks_lock = threading.RLock()
def _read_notebook(path):
if os.path.getsize(path) > MAX_NOTEBOOK_BYTES:
raise ValueError("Notebook is too large")
with open(path, encoding="utf-8") as source:
data = json.load(source)
if not isinstance(data, dict):
raise ValueError("Invalid notebook")
return data
def _migrate_notebooks(directory):
marker = resolve_within(directory, ".legacy_imported.json")
if os.path.exists(marker):
return
skipped = []
if os.path.isdir(LEGACY_NOTEBOOKS_DIR):
for filename in sorted(os.listdir(LEGACY_NOTEBOOKS_DIR)):
if not filename.endswith(".json") or filename.startswith("."):
continue
try:
source = resolve_within(LEGACY_NOTEBOOKS_DIR, require_filename(filename))
data = _read_notebook(source)
except (OSError, ValueError):
skipped.append(filename)
logging.warning("Anomalous: could not migrate notebook %s; original preserved", filename)
continue
target = resolve_within(directory, filename)
if os.path.exists(target):
try:
if _read_notebook(target) == data:
continue
except (OSError, ValueError):
pass
digest = hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()[:12]
recovered = f"{os.path.splitext(filename)[0][:100]}_legacy_{digest}.json"
target = resolve_within(directory, recovered)
if os.path.exists(target):
if _read_notebook(target) != data:
raise ValueError("Conflicting migrated notebook")
continue
atomic_write_json(target, data, MAX_NOTEBOOK_BYTES)
atomic_write_json(marker, {"version": 1, "skipped": skipped})
def get_notebooks_dir():
user_dir = folder_paths.get_user_directory()
directory = os.path.join(user_dir, "workflows", "anomalous_notebooks")
os.makedirs(directory, exist_ok=True)
with _notebooks_lock:
_migrate_notebooks(directory)
return directory
def _list_notebooks():
with _notebooks_lock:
directory = get_notebooks_dir()
notebooks = []
for filename in sorted(os.listdir(directory)):
if not filename.endswith(".json") or filename.startswith("."):
continue
try:
data = _read_notebook(resolve_within(directory, filename))
notebooks.append({"filename": filename, "name": data.get("name", filename[:-5]), "data": data})
except (OSError, ValueError):
continue
return notebooks
async def api_get_notebooks(request):
try:
notebooks = await asyncio.to_thread(_list_notebooks)
return web.json_response({"notebooks": notebooks})
except (OSError, ValueError):
return web.json_response({"status": "error", "message": "Could not load notebooks"}, status=500)
def _save_notebook(filename, data):
with _notebooks_lock:
atomic_write_json(resolve_within(get_notebooks_dir(), filename), data, MAX_NOTEBOOK_BYTES)
try:
notebooks = await asyncio.to_thread(_list_notebooks)
return web.json_response({"notebooks": notebooks})
except (OSError, ValueError):
return web.json_response({"status": "error", "message": "Could not load notebooks"}, status=500)
def _save_notebook(filename, data):
with _notebooks_lock:
atomic_write_json(resolve_within(get_notebooks_dir(), filename), data, MAX_NOTEBOOK_BYTES)
async def api_save_notebook(request):
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if filename.startswith("."):
raise ValueError("Invalid notebook filename")
if not filename.endswith(".json"):
filename += ".json"
data = payload.get("data")
if not isinstance(data, dict):
raise ValueError("Invalid notebook")
await asyncio.to_thread(_save_notebook, filename, data)
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if filename.startswith("."):
raise ValueError("Invalid notebook filename")
if not filename.endswith(".json"):
filename += ".json"
data = payload.get("data")
if not isinstance(data, dict):
raise ValueError("Invalid notebook")
await asyncio.to_thread(_save_notebook, filename, data)
return web.json_response({"status": "success"})
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not save notebook"}, status=500)
def _delete_notebook(filename):
with _notebooks_lock:
os.remove(resolve_within(get_notebooks_dir(), filename))
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not save notebook"}, status=500)
def _delete_notebook(filename):
with _notebooks_lock:
path = resolve_within(get_notebooks_dir(), filename)
if not os.path.isfile(path):
raise FileNotFoundError(path)
move_to_trash(path)
async def api_delete_notebook(request):
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if not filename.endswith(".json") or filename.startswith("."):
raise ValueError("Invalid notebook filename")
await asyncio.to_thread(_delete_notebook, filename)
return web.json_response({"status": "success"})
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Notebook not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not delete notebook"}, status=500)
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if not filename.endswith(".json") or filename.startswith("."):
raise ValueError("Invalid notebook filename")
await asyncio.to_thread(_delete_notebook, filename)
return web.json_response({"status": "success"})
except (AttributeError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid notebook"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Notebook not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not delete notebook"}, status=500)
+58 -3
View File
@@ -9,9 +9,10 @@ from aiohttp import web
import folder_paths
from .utils import require_filename, resolve_within
from .recipe_constants import MAX_RECIPE_BYTES
from .recipe_images import _parameter_gallery_images
from .recipe_images import _parameter_gallery_images, _pictures_by_signature
from .recipe_store import get_recipes_dir, _read_recipe
from .workflow_schema import _parameter_signature
from .trash import move_to_trash
def get_parameters_dir():
# Store parameter notebooks in the user directory
@@ -49,6 +50,42 @@ async def api_get_parameters(request):
return web.json_response({"notebooks": notebooks})
MAX_COUNTED_NOTEBOOKS = 5000
def _parameter_set_signatures(parameters_dir):
"""Each recipe's parameter sets by their values' signature: {recipe filename: [signature]},
so the page can leave out the set saved with the recipe itself. Files over the save limit,
unreadable ones and any past the first MAX_COUNTED_NOTEBOOKS are skipped."""
sets = {}
try:
filenames = sorted(name for name in os.listdir(parameters_dir) if name.endswith(".json"))
except OSError:
return sets
for filename in filenames[:MAX_COUNTED_NOTEBOOKS]:
try:
file_path = resolve_within(parameters_dir, filename)
if os.path.getsize(file_path) > MAX_RECIPE_BYTES:
continue
with open(file_path, "r", encoding="utf-8") as f:
data = json.load(f)
except (OSError, ValueError):
continue
if not isinstance(data, dict):
continue
recipe_filename = data.get("recipe_filename")
signature = data.get("parameter_signature")
value = signature.get("value") if isinstance(signature, dict) else None
if isinstance(recipe_filename, str) and recipe_filename:
sets.setdefault(recipe_filename, []).append(value if isinstance(value, str) else "")
return sets
async def api_get_parameter_set_signatures(request):
sets = await asyncio.to_thread(_parameter_set_signatures, get_parameters_dir())
return web.json_response({"sets": sets})
_parameter_notebooks_cache = None
_parameter_notebooks_cache_time = 0
@@ -151,10 +188,12 @@ async def api_get_parameters_by_type(request):
if recipe_fn not in grouped:
grouped[recipe_fn] = []
signature = (nb.get("data", {}).get("parameter_signature") or {})
grouped[recipe_fn].append({
"filename": nb.get("filename"),
"name": nb.get("name"),
"timestamp": nb.get("timestamp"),
"signature": signature.get("value") if isinstance(signature, dict) else "",
"nodes": matched_nodes
})
@@ -295,8 +334,7 @@ async def api_delete_parameter(request):
try:
parameters_dir = get_parameters_dir()
file_path = resolve_within(parameters_dir, filename)
if os.path.exists(file_path):
os.remove(file_path)
move_to_trash(file_path)
_invalidate_parameter_notebooks_cache()
except OSError:
return web.json_response({"status": "error", "message": "Could not delete parameter notebook"}, status=500)
@@ -349,6 +387,23 @@ async def api_rename_parameter(request):
return web.json_response({"status": "success", "filename": filename, "name": new_name})
MAX_PICTURE_SIGNATURES = 64
async def api_get_parameter_pictures(request):
"""GET /anomalous/parameter_pictures?signatures=a,b - for Current node: how many recent
output pictures were made with each parameter set's values, and the newest, in one pass."""
signatures = {value.lower() for value in request.query.get("signatures", "").split(",") if value}
if not signatures or len(signatures) > MAX_PICTURE_SIGNATURES \
or any(not re.fullmatch(r"[0-9a-f]{64}", value) for value in signatures):
return web.json_response({"status": "error", "message": "Invalid signatures"}, status=400)
try:
pictures, scanned = await asyncio.to_thread(_pictures_by_signature, signatures)
except OSError:
return web.json_response({"status": "error", "message": "Could not read the pictures"}, status=500)
return web.json_response({"status": "success", "pictures": pictures, "scanned": scanned})
async def api_get_parameter_gallery(request):
"""Find recent output PNGs whose embedded parameters match one parameter notebook."""
try:
+50 -1
View File
@@ -6,6 +6,8 @@ import tempfile
import folder_paths
from .recipe_constants import MODEL_FILE_SUFFIXES
def resolve_within(base_dir, *parts):
"""Resolve a path and require it to stay inside base_dir, including through symlinks."""
@@ -19,6 +21,53 @@ def resolve_within(base_dir, *parts):
return candidate
def _model_roots():
for folder_type in getattr(folder_paths, "folder_names_and_paths", {}):
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
for path_index, base_dir in enumerate(paths or []):
if os.path.isdir(base_dir):
yield folder_type, path_index, os.path.realpath(base_dir)
def model_file_within(directory, filename):
"""The model file `filename` (a bare name) in `directory`, which was resolved inside its
models folder already, as named: a model that is a link stays the link (on a cloud server
the models folder links into a shared, read-only model disk). Its covers and info are the
ones beside the link, edits are written there and a delete moves the link; only the folder
part must stay inside the models folder, and the name cannot leave it."""
return os.path.join(directory, require_filename(filename))
def model_reference_within(base_dir, relative):
"""model_file_within for a path relative to the models folder `base_dir` (subfolders and
a file name): the subfolders must stay inside it, through links too."""
head, name = os.path.split(relative)
return model_file_within(resolve_within(base_dir, head), name)
def _resolve_exact_model_reference(saved_value):
"""Resolve one saved model value without recursive scanning or hashing."""
if not isinstance(saved_value, str) or not saved_value.strip():
return None
relative_value = saved_value.replace("/", os.sep)
for folder_type, path_index, base_dir in _model_roots():
try:
candidate = model_reference_within(base_dir, relative_value)
except ValueError:
continue
if not os.path.isfile(candidate) or not candidate.lower().endswith(MODEL_FILE_SUFFIXES):
continue
return {
"path": candidate,
"folder_type": folder_type,
"path_index": path_index,
}
return None
def get_folder_root(folder_type, path_idx=0):
paths = folder_paths.get_folder_paths(folder_type)
if not paths or not isinstance(path_idx, int) or path_idx < 0 or path_idx >= len(paths):
@@ -33,7 +82,7 @@ def resolve_folder_subdir(folder_type, path_idx=0, subfolder='/'):
def require_filename(filename):
if not isinstance(filename, str) or not filename or os.path.basename(filename) != filename:
if not isinstance(filename, str) or filename in ("", ".", "..") or os.path.basename(filename) != filename:
raise ValueError("Invalid filename")
return filename
+50
View File
@@ -0,0 +1,50 @@
"""Every model file in a few words, for the canvas's quick search (GET /anomalous/quick_index,
web/modules/quick_search_data.js): its folder type, place and file, the name it shows, its
base model, a few trigger words and its cover's URL. Read once when the search bar opens;
the bar keeps it a little while. Files a folder type shares with another (unet and
diffusion_models) are listed once; node packs' own files are not models."""
import asyncio
from aiohttp import web
from .model_catalog import _collect_all_scan_models
MAX_WORDS = 6
def _words(meta):
words = meta.get("trainedWords") or meta.get("trigger_words") or []
if isinstance(words, str):
words = [words]
return [str(word) for word in words if str(word).strip()][:MAX_WORDS]
def compact_models(models):
"""`models` (the shape GET /anomalous/all_scan_models gives) in a few words each."""
seen, found = set(), []
for model in models:
if model.get("type") == "custom_nodes":
continue
key = (model.get("subfolder") or "", model.get("filename"), model.get("size_bytes"))
if key in seen:
continue
seen.add(key)
meta = model.get("metadata") or {}
found.append({
"type": model.get("type"),
"path_idx": model.get("path_idx", 0),
"subfolder": model.get("subfolder") or "",
"filename": model.get("filename"),
"name": str(meta.get("custom_name") or "").strip(),
"civitai_name": str(meta.get("name") or "").strip(),
"base": str(meta.get("baseModel") or "").strip(),
"words": _words(meta),
"preview_url": model.get("preview_url") or "",
})
return found
async def api_quick_index(request):
payload = await asyncio.to_thread(_collect_all_scan_models, 1, 0)
return web.json_response({"models": compact_models(payload.get("models") or [])})
+3
View File
@@ -7,6 +7,9 @@ MAX_TAGS = 20
MAX_TAG_LENGTH = 60
MAX_NOTES_LENGTH = 3000
MAX_MODEL_NOTE_LENGTH = 1000
MAX_FOCUS_PARAMS = 12
MAX_FOCUS_NOTE_LENGTH = 120
MAX_FOCUS_NAME_LENGTH = 200
MAX_THUMBNAIL_LENGTH = 1_500_000
MAX_SOURCE_SUBFOLDER_LENGTH = 500
MAX_RECIPE_BYTES = 12 * 1024 * 1024
+32 -1
View File
@@ -1,6 +1,8 @@
"""Contained Recipe image assets and bounded output-gallery inspection."""
from io import BytesIO
from functools import lru_cache
import hashlib
import heapq
import json
import os
@@ -8,6 +10,7 @@ import os
import folder_paths
from PIL import Image
from .path_utils import _resolve_exact_model_reference
from .recipe_constants import *
from .utils import require_filename, resolve_within
from .workflow_schema import _node_type, _parameter_signature, _workflow_node_signature
@@ -190,6 +193,34 @@ def _recipe_gallery_images(node_signature):
return matches, scanned
@lru_cache(maxsize=4096)
def _picture_parameter_signature(image_path, mtime_ns):
"""A picture's parameter signature, read once per version of the file."""
return _embedded_parameter_signature(image_path)
def _pictures_by_signature(signatures):
"""({signature: {"count", "newest": {filename, subfolder}}}, pictures read) for the recent
output pictures made with each of `signatures`, newest first, in one pass."""
output_dir = folder_paths.get_output_directory()
found = {}
if not os.path.isdir(output_dir):
return found, 0
scanned = 0
for mtime_ns, image_path in _recent_output_pngs(output_dir):
scanned += 1
signature = _picture_parameter_signature(image_path, mtime_ns)
if signature not in signatures:
continue
entry = found.setdefault(signature, {"count": 0, "newest": None})
entry["count"] += 1
if entry["newest"] is None:
relative_dir = os.path.relpath(os.path.dirname(image_path), output_dir)
entry["newest"] = {"filename": os.path.basename(image_path),
"subfolder": "" if relative_dir == "." else relative_dir.replace("\\", "/")}
return found, scanned
def _parameter_gallery_images(parameter_signature):
output_dir = folder_paths.get_output_directory()
if not os.path.isdir(output_dir):
@@ -198,7 +229,7 @@ def _parameter_gallery_images(parameter_signature):
scanned = 0
for mtime_ns, image_path in _recent_output_pngs(output_dir):
scanned += 1
if _embedded_parameter_signature(image_path) != parameter_signature:
if _picture_parameter_signature(image_path, mtime_ns) != parameter_signature:
continue
relative_dir = os.path.relpath(os.path.dirname(image_path), output_dir)
subfolder = "" if relative_dir == "." else relative_dir.replace("\\", "/")
+45 -80
View File
@@ -1,4 +1,7 @@
"""Bounded import/export for self-contained Workflow Recipe packages."""
"""Recipe packages (配方包): one Workflow Recipe as a .zip to give to someone else — the recipe,
its cover, its models' thumbnails and, when asked, its versions — and taking one in as a new
recipe card. Bounded: entry names, count, sizes, compression ratio and checksums are checked
before anything is written; an import never replaces a recipe (a taken name gets " (2)")."""
import hashlib
import io
@@ -9,6 +12,7 @@ import shutil
import stat
import tempfile
import time
import urllib.parse
import uuid
import zipfile
@@ -19,10 +23,6 @@ from .recipe_store import get_recipes_dir
from .utils import require_filename, resolve_within
# Release gates: package transfers stay closed until validation is complete.
# Keep the format implementation for compatibility testing.
RECIPE_PACKAGE_EXPORT_ENABLED = False
RECIPE_PACKAGE_IMPORT_ENABLED = False
PACKAGE_VERSION = 1
MAX_UPLOAD_BYTES = 32 * 1024 * 1024
MAX_ENTRY_COUNT = 256
@@ -134,6 +134,18 @@ def _cover_asset_id(recipe):
return require_filename(asset_id)
def _picture_summary(recipe):
"""What a package shows the receiver: (has a cover, how many model thumbnails). The cover
is usually the small image inside recipe.json (`thumbnail`), not an asset file."""
thumbnail = recipe.get("thumbnail")
has_cover = bool(_cover_asset_id(recipe)) or (
isinstance(thumbnail, str) and thumbnail.lower().startswith(recipe_schema.SAFE_THUMBNAIL_PREFIXES))
references = recipe.get("params", {}).get("model_references", [])
models = {_asset_id_from_reference(item) for item in references if isinstance(item, dict)} if isinstance(references, list) else set()
models.discard(None)
return has_cover, len(models)
def _validate_manifest(manifest, archive, names):
if not isinstance(manifest, dict) or manifest.get("package_version") != PACKAGE_VERSION:
raise ValueError("Unsupported recipe package version")
@@ -212,7 +224,7 @@ def _recipe_history_files(recipes_dir, filename):
def _build_export(raw_recipe, recipes_dir, filename, options):
include_snapshots = options.get("include_snapshots") is True
include_snapshots = options.get("include_snapshots", True) is True
include_history = options.get("include_history") is True
include_identity = options.get("include_identity", True) is True
include_model_notes = options.get("include_model_notes", True) is True
@@ -372,42 +384,26 @@ def _write_staged_json(path, value):
def _commit_import(record, payload):
"""Writes the inspected package as a new recipe (never over one): (filename, name)."""
recipes_dir = get_recipes_dir()
package_recipe = json.loads(json.dumps(record["recipe"], ensure_ascii=False))
collision = payload.get("collision", "rename")
name_override = payload.get("name")
if name_override is not None:
if not isinstance(name_override, str) or not name_override.strip():
raise ValueError("Invalid imported recipe name")
package_recipe["name"] = name_override.strip()
existing_names = _existing_recipe_names(recipes_dir)
target_filename = None
if collision == "replace":
target_filename = require_filename(payload.get("target_filename", ""))
if not target_filename.endswith(".json"):
raise ValueError("Invalid replacement target")
if not os.path.isfile(resolve_within(recipes_dir, target_filename)):
raise FileNotFoundError
elif collision != "rename":
raise ValueError("Unsupported collision choice")
else:
package_recipe["name"] = _unique_name(package_recipe.get("name", ""), existing_names)
package_recipe["name"] = _unique_name(package_recipe.get("name", ""), _existing_recipe_names(recipes_dir))
normalized = recipe_schema._normalise_recipe(package_recipe)
normalized["presentation"]["imported"] = True
filename = target_filename or f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
filename = f"recipe_{int(time.time())}_{uuid.uuid4().hex[:8]}.json"
normalized = recipe_schema._enrich_recipe(normalized)
staging = tempfile.mkdtemp(prefix=".recipe-import-", dir=recipes_dir)
final_path = resolve_within(recipes_dir, filename)
final_assets = recipe_images._recipe_assets_dir(recipes_dir, filename)
final_history = recipe_store._history_dir(recipes_dir, filename)
backup_recipe = None
backup_assets = None
backup_history = None
installed_assets = False
installed_history = False
installed = []
try:
archive, _ = _validate_zip(record["raw"])
with archive:
@@ -419,54 +415,33 @@ def _commit_import(record, payload):
for name in record["history_names"]:
history = _parse_json(_read_zip_entry(archive, name), "historical recipe")
_write_staged_json(os.path.join(history_stage, require_filename(name.split("/", 1)[1])), history)
if collision == "replace":
backup_recipe = os.path.join(staging, "old-recipe.json")
os.replace(final_path, backup_recipe)
if os.path.isdir(final_assets):
backup_assets = os.path.join(staging, "old-assets")
shutil.move(final_assets, backup_assets)
if record["history_names"] and os.path.isdir(final_history):
backup_history = os.path.join(staging, "old-history")
shutil.move(final_history, backup_history)
os.replace(os.path.join(staging, "recipe.json"), final_path)
# The images first, the recipe last: a recipe card never shows without its pictures.
staged_assets = os.path.join(staging, "assets")
if os.path.isdir(staged_assets):
if os.listdir(staged_assets):
os.makedirs(os.path.dirname(final_assets), exist_ok=True)
os.replace(staged_assets, final_assets)
installed_assets = True
installed.append(final_assets)
if record["history_names"]:
history_stage = os.path.join(staging, "history")
if os.path.isdir(final_history):
shutil.rmtree(final_history)
os.replace(history_stage, final_history)
installed_history = True
os.makedirs(os.path.dirname(final_history), exist_ok=True)
os.replace(os.path.join(staging, "history"), final_history)
installed.append(final_history)
os.replace(os.path.join(staging, "recipe.json"), final_path)
except Exception:
if collision == "replace" and backup_recipe and os.path.isfile(backup_recipe):
if installed_assets and os.path.isdir(final_assets):
shutil.rmtree(final_assets, ignore_errors=True)
if installed_history and os.path.isdir(final_history):
shutil.rmtree(final_history, ignore_errors=True)
if os.path.isfile(final_path):
os.remove(final_path)
os.replace(backup_recipe, final_path)
if backup_assets and os.path.isdir(backup_assets):
shutil.move(backup_assets, final_assets)
if backup_history and os.path.isdir(backup_history):
shutil.move(backup_history, final_history)
for path in installed: # this import's own new folders; nothing of the user's
shutil.rmtree(path, ignore_errors=True)
raise
finally:
shutil.rmtree(staging, ignore_errors=True)
return filename, normalized.get("name", "")
def _download_name(recipe, filename):
"""The .zip's name for the browser: the recipe's name, safe on every system."""
name = "".join("_" if char in '<>:"/\\|?*' or ord(char) < 32 else char for char in str(recipe.get("name") or "")).strip(" .")
return f"{name or os.path.splitext(filename)[0]}.anomalous-recipe.zip"
async def api_export_recipe_package(request):
if not RECIPE_PACKAGE_EXPORT_ENABLED:
return web.json_response({
"status": "error",
"code": "recipe_export_disabled",
"message": "Recipe package export is temporarily unavailable",
}, status=503)
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
@@ -481,19 +456,13 @@ async def api_export_recipe_package(request):
return web.json_response({"status": "error", "message": "Recipe not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not export recipe"}, status=500)
return web.Response(
body=package,
content_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{os.path.splitext(filename)[0]}.anomalous-recipe.zip"'},
)
name = _download_name(recipe, filename)
return web.Response(body=package, content_type="application/zip", headers={
"Content-Disposition": f"attachment; filename=\"recipe.anomalous-recipe.zip\"; filename*=UTF-8''{urllib.parse.quote(name)}",
})
async def api_import_recipe_package_inspect(request):
if not RECIPE_PACKAGE_IMPORT_ENABLED:
return web.json_response({
"status": "error", "code": "recipe_import_disabled",
"message": "Recipe package import is temporarily unavailable",
}, status=503)
try:
raw = await request.content.read(MAX_UPLOAD_BYTES + 1)
report = await asyncio.to_thread(_inspect_package, raw)
@@ -507,17 +476,15 @@ async def api_import_recipe_package_inspect(request):
"token": token,
"recipe": {"name": report["recipe"].get("name", ""), "tags": report["recipe"].get("tags", [])},
"asset_count": len(report["asset_names"]),
"has_cover": _picture_summary(report["recipe"])[0],
"model_pictures": _picture_summary(report["recipe"])[1],
"history_count": len(report["history_names"]),
"existing_names": _existing_recipe_names(get_recipes_dir()),
"imported_name": _unique_name(report["recipe"].get("name", ""), _existing_recipe_names(get_recipes_dir())),
})
async def api_import_recipe_package_commit(request):
if not RECIPE_PACKAGE_IMPORT_ENABLED:
return web.json_response({
"status": "error", "code": "recipe_import_disabled",
"message": "Recipe package import is temporarily unavailable",
}, status=503)
try:
payload = await request.json()
token = payload.get("token")
@@ -529,8 +496,6 @@ async def api_import_recipe_package_commit(request):
filename, name = await asyncio.to_thread(_commit_import, record, payload)
except (AttributeError, TypeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Could not commit recipe package"}, status=400)
except FileNotFoundError:
return web.json_response({"status": "error", "message": "Replacement recipe not found"}, status=404)
except OSError:
return web.json_response({"status": "error", "message": "Could not commit recipe package"}, status=500)
return web.json_response({"status": "success", "filename": filename, "name": name})
+71 -35
View File
@@ -10,6 +10,7 @@ import folder_paths
from .metadata import get_metadata
from .recipe_constants import *
from .path_utils import _model_roots, _resolve_exact_model_reference
from .recipe_images import _attach_preview_snapshots
from .utils import require_filename, resolve_within
from .workflow_schema import (
@@ -17,13 +18,31 @@ from .workflow_schema import (
_widget_values, _workflow_fingerprint,
)
# All-in-one loaders with a verified serialized widget layout. Must stay in sync
# with ALL_IN_ONE_LOADER_SPECS in web/modules/recipe_identity.js.
ALL_IN_ONE_LOADER_SPECS = {
# ComfyUI-Easy-Use: ckpt_name, vae_name, clip_skip, lora_name, ...
"easy a1111loader": ((0, "checkpoint", "ckpt_name"), (1, "vae", "vae_name"), (3, "lora", "lora_name")),
# ComfyUI-Easy-Use: ckpt_name, config_name, vae_name, clip_skip, lora_name, ...
"easy fullloader": ((0, "checkpoint", "ckpt_name"), (2, "vae", "vae_name"), (4, "lora", "lora_name")),
# efficiency-nodes-comfyui: ckpt_name, vae_name, clip_skip, lora_name, ...
"efficient loader": ((0, "checkpoint", "ckpt_name"), (1, "vae", "vae_name"), (3, "lora", "lora_name")),
}
PLACEHOLDER_MODEL_VALUES = {"none", "baked vae"}
POWER_LORA_TYPE = "power lora loader (rgthree)"
EASY_LORA_STACK_TYPE = "easy lorastack"
EASY_STACK_ROWS = 10
def _model_reference_specs(node):
"""Known loader adapters only; arbitrary third-party widgets stay parameters."""
node_type = _node_type(node)
lowered = node_type.lower()
lowered = node_type.lower().strip()
specs = []
if re.search(r"checkpointloader(simple)?$", lowered):
if lowered in ALL_IN_ONE_LOADER_SPECS:
specs.extend(ALL_IN_ONE_LOADER_SPECS[lowered])
elif re.search(r"checkpointloader(simple)?$", lowered):
specs.append((0, "checkpoint", "checkpoint"))
elif lowered.endswith("unetloader"):
specs.append((0, "unet", "unet"))
@@ -44,37 +63,27 @@ def _model_reference_specs(node):
return specs
def _model_roots():
for folder_type in getattr(folder_paths, "folder_names_and_paths", {}):
def _model_reference_values(node):
"""(widget_index, category, widget_name, saved_value) for every model a saved node names: known
loaders by position, and two stack nodes by their rows: rgthree's Power Lora Loader (a row is a
dict {on, lora, strength}; switched-off rows load nothing) and Easy-Use's LoRA stack
(lora_N_name at 3 + 4*(N-1), up to num_loras). Must stay in sync with nodeModelValues in
web/modules/recipe_identity.js."""
values = _widget_values(node)
lowered = _node_type(node).lower().strip()
if lowered == POWER_LORA_TYPE:
rows = [(index, value) for index, value in enumerate(values) if isinstance(value, dict) and "lora" in value]
return [(index, "lora", f"lora_{row}", value.get("lora"))
for row, (index, value) in enumerate(rows, 1) if value.get("on") is not False]
if lowered == EASY_LORA_STACK_TYPE:
try:
paths = folder_paths.get_folder_paths(folder_type)
except Exception:
continue
for path_index, base_dir in enumerate(paths or []):
if os.path.isdir(base_dir):
yield folder_type, path_index, os.path.realpath(base_dir)
def _resolve_exact_model_reference(saved_value):
"""Resolve one saved model value without recursive scanning or hashing."""
if not isinstance(saved_value, str) or not saved_value.strip():
return None
relative_value = saved_value.replace("/", os.sep)
for folder_type, path_index, base_dir in _model_roots():
candidate = os.path.realpath(os.path.join(base_dir, relative_value))
try:
if os.path.commonpath((base_dir, candidate)) != base_dir:
continue
except ValueError:
continue
if not os.path.isfile(candidate) or not candidate.lower().endswith(MODEL_FILE_SUFFIXES):
continue
return {
"path": candidate,
"folder_type": folder_type,
"path_index": path_index,
}
return None
count = max(0, min(int(values[2]), EASY_STACK_ROWS))
except (IndexError, TypeError, ValueError):
count = 0
return [(3 + 4 * row, "lora", f"lora_{row + 1}_name", values[3 + 4 * row] if 3 + 4 * row < len(values) else None)
for row in range(count)]
return [(index, category, name, values[index] if index < len(values) else None)
for index, category, name in _model_reference_specs(node)]
def _identity_for_reference(saved_value):
@@ -170,11 +179,11 @@ def _build_model_references(recipe, verify_identities=False):
for node in workflow.get("nodes", []) if isinstance(workflow, dict) else []:
if not isinstance(node, dict):
continue
values = _widget_values(node)
for widget_index, category, widget_name in _model_reference_specs(node):
saved_value = values[widget_index] if widget_index < len(values) else None
for widget_index, category, widget_name, saved_value in _model_reference_values(node):
if not isinstance(saved_value, str) or not saved_value.strip():
continue
if saved_value.strip().lower() in PLACEHOLDER_MODEL_VALUES:
continue
identity_result = _identity_for_reference(saved_value)
if isinstance(identity_result, tuple):
identity, origin = (identity_result + (None,))[:2]
@@ -292,6 +301,31 @@ def _normalise_source_image(value):
}
def _normalise_focus_params(entries):
"""A workflow's focus values (重点参数): [{node, type, widget, note}], all short text; anything else is refused."""
if not isinstance(entries, list) or len(entries) > MAX_FOCUS_PARAMS:
raise ValueError("Invalid recipe focus values")
kept = []
seen = set()
for entry in entries:
if not isinstance(entry, dict):
raise ValueError("Invalid recipe focus value")
node, node_type, widget, note = (entry.get(key, "") for key in ("node", "type", "widget", "note"))
if not all(isinstance(value, str) for value in (node, node_type, widget, note)):
raise ValueError("Invalid recipe focus value")
if (
not node or not widget or len(node) > 32
or max(len(node_type), len(widget)) > MAX_FOCUS_NAME_LENGTH
or len(note) > MAX_FOCUS_NOTE_LENGTH
):
raise ValueError("Invalid recipe focus value")
if (node, widget) in seen:
continue
seen.add((node, widget))
kept.append({"node": node, "type": node_type, "widget": widget, "note": note.strip()})
return kept
def _normalise_recipe(payload):
if not isinstance(payload, dict):
raise ValueError("Invalid recipe")
@@ -339,6 +373,8 @@ def _normalise_recipe(payload):
reference["user_note"] = user_note.strip()
else:
reference.pop("user_note", None)
if "focusParams" in params:
params["focusParams"] = _normalise_focus_params(params["focusParams"])
_validate_workflow(workflow)
workflow_scope = payload.get("workflow_scope", "complete")
if workflow_scope not in {"partial", "complete"}:
+6 -8
View File
@@ -11,6 +11,7 @@ import folder_paths
from .recipe_constants import MAX_HISTORY_VERSIONS
from .recipe_images import _recipe_assets_dir
from .trash import move_to_trash
from .utils import require_filename, resolve_within
def get_recipes_dir():
@@ -131,11 +132,8 @@ def _list_recipe_history(recipes_dir, filename):
def _delete_recipe_with_history(recipes_dir, filename):
"""Delete an explicitly selected recipe and its contained local history."""
os.remove(resolve_within(recipes_dir, filename))
history_dir = _history_dir(recipes_dir, filename)
if os.path.isdir(history_dir):
shutil.rmtree(history_dir)
assets_dir = _recipe_assets_dir(recipes_dir, filename)
if os.path.isdir(assets_dir):
shutil.rmtree(assets_dir)
"""Moves an explicitly selected recipe, its history and its images to the Recycle Bin."""
path = resolve_within(recipes_dir, filename)
if not os.path.isfile(path):
raise FileNotFoundError(path)
move_to_trash(path, _history_dir(recipes_dir, filename), _recipe_assets_dir(recipes_dir, filename))
+36 -1
View File
@@ -26,13 +26,14 @@ from .recipe_schema import (
_enrich_recipe as _schema_enrich_recipe, _normalise_recipe, _normalise_source_image,
_identity_for_reference as _schema_identity_for_reference,
_model_reference_key, _model_reference_specs,
_model_roots, _preserve_model_reference_fields,
_model_roots, _normalise_focus_params, _preserve_model_reference_fields,
_resolve_exact_model_reference, _updated_recipe, _workflow_identity_for_reference,
)
from .recipe_store import (
_archive_recipe, _delete_recipe_with_history, _history_dir, _list_recipe_history,
_list_recipes, _read_recipe, _write_recipe, get_recipes_dir,
)
from .local_guard import may_change
from .utils import require_filename, resolve_within
from .workflow_schema import (
_clean_volatile_params, _node_title, _node_type, _normalise_workflow_key,
@@ -359,6 +360,40 @@ async def api_update_recipe(request):
})
def _set_recipe_focus(recipes_dir, filename, entries):
"""Writes a recipe's focus values (重点参数) alone: no version kept, nothing else read again."""
focus = _normalise_focus_params(entries)
path = resolve_within(recipes_dir, filename)
if os.path.getsize(path) > MAX_RECIPE_BYTES:
raise ValueError("Recipe is too large")
recipe = _read_recipe(path)
if not isinstance(recipe, dict) or not isinstance(recipe.get("workflow"), dict):
raise ValueError("Invalid recipe")
params = recipe.get("params") if isinstance(recipe.get("params"), dict) else {}
recipe["params"] = {**params, "focusParams": focus}
_write_recipe(recipes_dir, filename, recipe)
return focus
async def api_set_recipe_focus(request):
"""Marks or notes a recipe's focus values (a star in its details). Only from this computer (local_guard.may_change)."""
if not may_change(request):
return web.json_response({"status": "error", "message": "Only from this computer"}, status=403)
try:
payload = await request.json()
filename = require_filename(payload.get("filename", ""))
if not filename.endswith(".json"):
raise ValueError("Invalid filename")
focus = await asyncio.to_thread(_set_recipe_focus, get_recipes_dir(), filename, payload.get("focusParams"))
except FileNotFoundError:
return web.json_response({"status": "error", "message": "File not found"}, status=404)
except (AttributeError, ValueError, json.JSONDecodeError):
return web.json_response({"status": "error", "message": "Invalid focus values"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not update recipe"}, status=500)
return web.json_response({"status": "success", "focusParams": focus})
async def api_get_recipe_history(request):
try:
filename = require_filename(request.query.get("filename", ""))
+56
View File
@@ -0,0 +1,56 @@
"""ComfyUI's refusal of a run, said as what to do next: run_workflow's answer when ComfyUI does not
take the prompt. The page passes the refusal on as data (web/modules/mcp_run.js `refusal`: its
error and each node's, from ComfyUI's validation); each problem becomes one line naming the node,
what is wrong and the tool that fixes it, so the AI does not have to read ComfyUI's raw report and
guess. Kinds not listed here keep ComfyUI's own message."""
MAX_LINES = 8
NODE_HINTS = {
"required_input_missing": "node {node} ({class_type}) has nothing wired into {input}: connect_nodes into it, or add_node with wire_to={node}",
"bad_linked_input": "node {node} ({class_type}): {input} is wired from an output of the wrong kind; connect_nodes the right one ({details})",
"return_type_mismatch": "node {node} ({class_type}): {input} is wired from an output of the wrong kind; connect_nodes the right one ({details})",
"invalid_input_type": "node {node} ({class_type}): {input} has a value of the wrong kind ({details}); set_values with the right one",
"value_smaller_than_min": "node {node} ({class_type}): {input} = {value} is below its minimum ({details}); set_values within the range",
"value_bigger_than_max": "node {node} ({class_type}): {input} = {value} is above its maximum ({details}); set_values within the range",
"value_not_in_list": ("node {node} ({class_type}): {input} = {value} is not one of its choices. A model this computer "
"lacks: check_workflow_models / fix_workflow_models, or search_models then set_model; otherwise "
"set_values with one of its choices (find_nodes lists them)"),
}
TOP_HINTS = {
"prompt_no_outputs": ("the workflow has no output node: add SaveImage (or PreviewImage) behind the VAE Decode with "
"add_node(type='SaveImage', wire_from=<its id>)"),
"invalid_prompt": "{message} {details}: a node type this ComfyUI does not have; find_nodes for one it has, or ask the user to install its node pack",
}
class _Blank(dict):
def __missing__(self, key):
return "?"
def _say(template, item):
return template.format_map(_Blank({key: str(value) for key, value in item.items() if value not in (None, "")}))
def say(refusal):
"""What to do about ComfyUI's refusal (the page's `refusal` data), or "" when it says nothing."""
if not isinstance(refusal, dict):
return ""
lines = []
for item in refusal.get("nodes") or []:
if isinstance(item, dict):
template = NODE_HINTS.get(item.get("type"))
lines.append(_say(template, item) if template else
_say("node {node} ({class_type}): {message} {details}", item).replace(" ?", ""))
if not lines:
template = TOP_HINTS.get(refusal.get("type"))
if template:
lines.append(_say(template, refusal))
elif refusal.get("message"):
lines.append(_say("{message} {details}", refusal).replace(" ?", ""))
if not lines:
return ""
more = len(lines) - MAX_LINES
return ("ComfyUI did not take the run: " + "; ".join(lines[:MAX_LINES]) + (f"; and {more} more" if more > 0 else "")
+ ". Fix these, then run_workflow again.")
+74
View File
@@ -0,0 +1,74 @@
"""The user's choice to leave a model out of scans, or keep it in: POST /anomalous/scan_choice,
kept in each model's <model>.anomalous.json ("scan"; model_identity.scan_choice reads it). The
scan page asks it about new models that look like no drawing model; a model's details change it."""
import asyncio
import os
from aiohttp import web
from .local_guard import may_change
from .path_utils import atomic_write_json, resolve_folder_subdir
from .utils import model_file_within
try:
from ..model_identity import USER_INFO_SUFFIX, read_json
from ..model_sidecars import target as sidecar_target
except ImportError: # loaded outside the package (tests)
from model_identity import USER_INFO_SUFFIX, read_json
from model_sidecars import target as sidecar_target
MAX_CHOICE_FILES = 2000
def _set_scan_choice(targets, choice):
"""Writes the user's scan choice into each picked model's <model>.anomalous.json ("scan":
"skip" or "scan"; "" removes it). `targets`: [{type, path_idx, subfolder, files}] as a scan
takes them. Returns (models changed, file names left as they were: not found, not a
.safetensors, or an unreadable .anomalous.json)."""
if choice not in ("skip", "scan", "") or not isinstance(targets, list):
raise ValueError("Invalid scan choice")
picked = []
for target in targets:
names = target.get("files") if isinstance(target, dict) else None
if not isinstance(names, list) or not all(isinstance(name, str) for name in names):
raise ValueError("each target needs a list of file names")
_, target_dir = resolve_folder_subdir(target.get("type"), int(target.get("path_idx", 0)), target.get("subfolder") or "/")
picked += [(target_dir, name) for name in names]
if len(picked) > MAX_CHOICE_FILES:
raise ValueError("Too many models")
changed, failed = 0, []
for target_dir, name in picked:
try:
path = model_file_within(target_dir, name)
except ValueError:
failed.append(name)
continue
user_file = sidecar_target(os.path.splitext(path)[0], USER_INFO_SUFFIX)
user = read_json(user_file)
if not name.endswith(".safetensors") or not os.path.isfile(path) or (user is None and os.path.exists(user_file)):
failed.append(name)
continue
user = user or {"format": 1}
if choice:
user["scan"] = choice
else:
user.pop("scan", None)
atomic_write_json(user_file, user)
changed += 1
return changed, failed
async def api_set_scan_choice(request):
"""POST /anomalous/scan_choice {targets, choice} - leave models out of scans ("skip"), keep
them in ("scan"), or forget the choice (""). Only from this computer (local_guard.may_change)."""
if not may_change(request):
return web.json_response({"status": "error", "message": "Only from this computer"}, status=403)
try:
data = await request.json()
changed, failed = await asyncio.to_thread(_set_scan_choice, data.get("targets"), data.get("choice", ""))
except (AttributeError, KeyError, TypeError, ValueError):
return web.json_response({"status": "error", "message": "Invalid scan choice"}, status=400)
except OSError:
return web.json_response({"status": "error", "message": "Could not save the choice"}, status=500)
return web.json_response({"status": "success", "changed": changed, "failed": failed})
+169
View File
@@ -0,0 +1,169 @@
"""What a scan did, model by model.
The scraper appends one JSON line per model it changed to a report file (--report-file),
plus events: "civitai_down" when Civitai stopped answering, "done" with the count of models
left as they were. When a scan ends, ScanJob turns the lines of all its folders into one
result: kept as the last scan (GET /anomalous/last_scan, shown on the scan page) and recorded
in the activity log, with each model located by type, path index and relative path so the
browser can open it (GET /anomalous/scan_model).
"""
import asyncio
import json
import os
import time
from aiohttp import web
import folder_paths
from .activity_log import MAX_CHANGES, add_entry, log_path
from .folder_types import get_active_folder_types
from .model_catalog import _model_info_for_path
from .path_utils import atomic_write_json, resolve_folder_subdir
MAX_FILES = 2000 # models kept in the last scan's result
FILE_FIELDS = ("status", "reason", "name", "base", "cover", "error")
def last_scan_path():
return os.path.join(os.path.dirname(log_path()), "last_scan.json")
def locate_root(directory):
"""(type, path_idx) of the models folder that is `directory`, shown types first; None if none."""
target = os.path.normcase(os.path.realpath(directory))
active = get_active_folder_types()
types = active + [name for name in folder_paths.folder_names_and_paths if name not in active]
for folder_type in types:
try:
paths = folder_paths.get_folder_paths(folder_type) or []
except Exception:
continue
for path_idx, path in enumerate(paths):
if os.path.normcase(os.path.realpath(path)) == target:
return folder_type, path_idx
return None
def read_report(path):
"""(model entries, events) from a report file; both empty when there is none."""
entries, events = [], []
try:
with open(path, encoding="utf-8") as source:
for line in source:
try:
item = json.loads(line)
except ValueError:
continue
if isinstance(item, dict):
(events if "event" in item else entries).append(item)
except OSError:
pass
return entries, events
class ScanJob:
"""One scan from start to end: what to call it, its options, and its folders' reports."""
def __init__(self, kind, data):
self.kind = kind # "all" | "picked" | "one"
self.options = {key: bool(data.get(key)) for key in ("offline_only", "retry_unmatched", "force_overwrite")}
self.started = time.time()
self.files = []
self.unchanged = 0
self.civitai_down = False
self.civitai_error = "" # why Civitai could not be reached (net_proxy.failure), the first one
self.errors = []
def add_folder(self, report_file, base_dir, located=None):
"""Reads (and removes) one scraper run's report. `located`: (type, path_idx) of base_dir."""
located = located or locate_root(base_dir) or ("", 0)
entries, events = read_report(report_file)
try:
os.remove(report_file)
except OSError:
pass
for event in events:
if event.get("event") == "done":
self.unchanged += int(event.get("unchanged") or 0)
elif event.get("event") == "civitai_down":
self.civitai_down = True
self.civitai_error = self.civitai_error or str(event.get("reason") or "")[:40]
for entry in entries:
path = entry.get("renamed_to") if entry.get("renamed_to") and entry.get("status") != "failed" else entry.get("file")
if not isinstance(path, str):
continue
item = {key: entry[key] for key in FILE_FIELDS if entry.get(key)}
item.update(type=located[0], path_idx=located[1], filename=os.path.basename(path),
rel=os.path.relpath(path, base_dir).replace(os.sep, "/"))
if entry.get("renamed_to") and item["status"] != "failed":
item["renamed_from"] = os.path.basename(entry.get("file") or "")
if entry.get("duplicate_of"):
item["duplicate"] = True
self.files.append(item)
def add_error(self, message):
self.errors.append(str(message)[:300])
def result(self):
files = self.files
counts = {
"matched": sum(item["status"] == "matched" for item in files),
"inferred": sum(item["status"] == "inferred" for item in files),
"failed": sum(item["status"] == "failed" for item in files),
"covers": sum(item.get("cover") == "new" for item in files),
"renamed": sum(bool(item.get("renamed_from")) for item in files),
"unchanged": self.unchanged,
}
return {
"kind": self.kind, "options": self.options, "started": self.started,
"finished": time.time(), "counts": counts, "civitai_down": self.civitai_down,
"civitai_error": self.civitai_error,
"errors": self.errors[:10], "files": files[:MAX_FILES],
}
def finish(self):
"""Keeps the result as the last scan and records it in the activity log. Never raises:
the scan itself is done, the record of it is a convenience."""
result = self.result()
try:
atomic_write_json(last_scan_path(), result)
except Exception as error: # noqa: BLE001
print(f"[Anomalous Browser] Could not keep the scan result: {error}")
try:
target = result["files"][0]["filename"] if self.kind == "one" and result["files"] else ""
detail = {"scan": {key: result[key] for key in ("kind", "options", "counts", "civitai_down", "civitai_error", "errors")},
"files": result["files"][:MAX_CHANGES], "total": len(self.files),
"seconds": round(result["finished"] - result["started"])}
add_entry("file", "scan_done", target, detail)
except Exception as error: # noqa: BLE001
print(f"[Anomalous Browser] Could not record the scan in the activity log: {error}")
return result
async def api_last_scan(request):
"""GET /anomalous/last_scan - the last scan's result, or {} before the first one."""
def read():
try:
with open(last_scan_path(), encoding="utf-8") as source:
data = json.load(source)
return data if isinstance(data, dict) else {}
except (OSError, ValueError):
return {}
return web.json_response(await asyncio.to_thread(read))
async def api_scan_model(request):
"""GET /anomalous/scan_model?type&path_idx&rel - one model as the grid lists it, to open it."""
rel = request.query.get("rel", "").replace("\\", "/").strip("/")
subfolder, filename = os.path.split(rel)
try:
path_idx = int(request.query.get("path_idx", 0))
base_dir, target_dir = resolve_folder_subdir(request.query.get("type", ""), path_idx, "/" + subfolder)
except (ValueError, KeyError, TypeError):
return web.json_response({"error": "Invalid model location"}, status=400)
file_path = os.path.join(target_dir, filename)
if not filename or filename in (".", "..") or not os.path.isfile(file_path):
return web.json_response({"error": "Model not found"}, status=404)
model = await asyncio.to_thread(_model_info_for_path, request.query.get("type"), path_idx, base_dir, file_path)
return web.json_response({"model": model})
+262
View File
@@ -0,0 +1,262 @@
"""What the scan page shows before a scan: how many models are matched on Civitai, unmatched or
new, and which ones the last two are (located like the scan report's models, so the page can
open them); also the model files a scan never reads (other formats than .safetensors), so the
page can say why they are not there, the models the user set to "don't scan", and the new ones
whose header looks like no drawing model (a voice model, say), for the page to ask about. The
components a drawing model loads (VAE, text encoders…) are counted and listed on their own too,
since many are not on Civitai and that is no fault; and some covers of the scanned models, for
the scan card's band of pictures. And for the scan page's picker, every model
a scan reads with its name, size, cover and state.
Working the summary out reads every model folder, which takes a while with many models or on
a network drive. So the last one is kept (in memory, and in user/anomalous/scan_summary.json
for after a restart) and handed out at once (`?quick=1`) while a fresh one is worked out;
requests that come meanwhile wait for that same one instead of reading the folders again. The
models found on Civitai are kept with it and listed on request (GET /anomalous/scan_matched),
so the page's summary stays small."""
import asyncio
import os
import time
from concurrent.futures import ThreadPoolExecutor
from aiohttp import web
from .activity_log import log_path
from .folder_types import get_active_scan_paths
from .metadata import get_metadata
from .path_utils import atomic_write_json
from .model_media import _preview_url_for_model
from .scan_report import locate_root
try:
from ..model_identity import is_unmatched, read_json, scan_choice, sidecar_info, unmatched_reason
except ImportError: # loaded outside the package (tests)
from model_identity import is_unmatched, read_json, scan_choice, sidecar_info, unmatched_reason
MAX_LISTED = 2000 # models listed per kind; the counts stay exact
MAX_PICKABLE = 5000 # models the picker lists; its total stays exact
COVER_SAMPLE = 48 # covers the scan card's band gets (it shows as many as fit)
# Model files in other formats: listed as "not scanned" (scans read only .safetensors).
OTHER_MODEL_EXTENSIONS = (".ckpt", ".pt", ".pth", ".bin", ".gguf", ".onnx", ".sft", ".engine")
# Folders of the parts a drawing model loads: many are not on Civitai, and that is no fault.
COMPONENT_TYPES = ("vae", "clip", "text_encoders", "clip_vision", "upscale_models")
# What the picker needs of a model's metadata: its card badge (model_source.js) and its name.
PICKER_FIELDS = ("info_source", "unmatched_reason", "scan_choice", "not_drawing", "baseModel", "base_guess", "custom_name")
# New models' headers are read this many at a time: on a network drive (a NAS, a cloud server's
# model disk) each read waits on the network, and a thousand one after another take minutes.
# They are read through get_metadata, whose cache keeps them until the file changes (the page
# opened again reads none, and the picker after it finds them there).
HEAD_READERS = 16
MAX_SAVED_BYTES = 16 * 1024 * 1024 # the kept summary; MAX_LISTED keeps it far smaller
_last = {} # the last summary worked out ("summary", "at") and its models found on Civitai ("matched")
_working = None # the summary being worked out (an asyncio future), shared by the requests meanwhile
def _walk():
"""Each model file in the active scan folders once, as ((type, path_idx), base_dir, path,
other): the .safetensors files scans read, and (`other`) model files in other formats outside
hidden folders (tools keep their working files there)."""
seen = set()
for base_dir in get_active_scan_paths():
located = locate_root(base_dir) or ("", 0)
for root, _dirs, files in os.walk(base_dir):
hidden = any(part.startswith(".") for part in os.path.relpath(root, base_dir).split(os.sep) if part != ".")
for filename in files:
other = filename.lower().endswith(OTHER_MODEL_EXTENSIONS)
if not filename.endswith(".safetensors") and not (other and not hidden):
continue
path = os.path.join(root, filename)
real = os.path.realpath(path)
if real in seen:
continue
seen.add(real)
yield located, base_dir, path, other
def _looks_not_drawing(path):
"""Whether the header of a new model reads but names no drawing model kind."""
return bool(get_metadata(path).get("not_drawing"))
def _found_item(item, info):
"""A model found on Civitai, for the list of them: where it is, Civitai's name, its base model."""
model = info.get("model") if isinstance(info.get("model"), dict) else {}
names = [str(model.get("name") or ""), str(info.get("name") or "")]
return {**item, "name": " · ".join(name for name in names if name), "base": str(info.get("baseModel") or "")}
def _summarize(with_matched=False):
"""Counts the files the scanner handles (.safetensors in the active scan folders), each once.
`pending`: unmatched models Civitai was never asked about (offline, or unreachable then),
which the next online scan looks up. `skipped`: model files in other formats. `excluded`:
models set to "don't scan", not in the other counts. A new model not chosen about whose
header reads but names no drawing model kind carries `not_drawing` (counted too); only new
models' headers are read. Models in a component folder carry `component` (counted too) and
are listed again, matched ones as well, in `component_models` with their `state`
(matched | unmatched | new); `components` counts them. `covers`: see `_covers`."""
total = matched = 0
unmatched, new, skipped, excluded, components, scanned, to_read, found = [], [], [], [], [], [], [], []
for located, base_dir, path, other in _walk():
item = {"type": located[0], "path_idx": located[1], "filename": os.path.basename(path),
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/")}
if other:
skipped.append(item)
continue
base = os.path.splitext(path)[0]
choice = scan_choice(base)
if choice == "skip":
excluded.append(item)
continue
total += 1
info = sidecar_info(base)
if located[0] in COMPONENT_TYPES:
item["component"] = True
components.append(item)
elif info is not None:
scanned.append((located, base_dir, path))
if info is not None and not is_unmatched(info):
matched += 1
item["state"] = "matched"
if with_matched and not item.get("component"):
found.append(_found_item(item, info))
continue
if info is None:
if not choice:
to_read.append((item, path))
item["state"] = "new"
new.append(item)
else:
item.update(state="unmatched", reason=unmatched_reason(info), base=info.get("baseModel") or "")
unmatched.append(item)
if to_read:
with ThreadPoolExecutor(max_workers=HEAD_READERS) as pool:
for (item, _path), not_drawing in zip(to_read, pool.map(_looks_not_drawing, [path for _item, path in to_read])):
if not_drawing:
item["not_drawing"] = True
return {
**({"matched_models": found} if with_matched else {}),
"total": total, "matched": matched, "unmatched": len(unmatched), "new": len(new),
"pending": sum(item["reason"] in ("offline", "network") for item in unmatched),
"damaged": sum(item["reason"] == "damaged" for item in unmatched),
"not_drawing": sum(bool(item.get("not_drawing")) for item in new),
"excluded": len(excluded), "excluded_models": excluded[:MAX_LISTED],
"unmatched_models": unmatched[:MAX_LISTED], "new_models": new[:MAX_LISTED],
"skipped": len(skipped), "skipped_models": skipped[:MAX_LISTED],
"components": {
"total": len(components),
**{state: sum(item["state"] == state for item in components) for state in ("matched", "unmatched", "new")},
"pending": sum(item.get("reason") in ("offline", "network") for item in components),
},
"component_models": components[:MAX_LISTED],
"covers": _covers(scanned),
}
def _modified(entry):
try:
return os.path.getmtime(entry[2])
except OSError:
return 0
def _covers(scanned):
"""Up to COVER_SAMPLE covers of the scanned models (components left out, they seldom have
one), the newest model files first, as {name, preview_url}: the scan card's band of pictures."""
covers = []
for located, base_dir, path in sorted(scanned, key=_modified, reverse=True):
url = _preview_url_for_model(located[0], located[1], base_dir, path)
if url:
covers.append({"name": os.path.splitext(os.path.basename(path))[0], "preview_url": url})
if len(covers) >= COVER_SAMPLE:
break
return covers
def _pickable():
"""Every model a scan reads (the models set to "don't scan" too), located like the summary's,
with its size, cover and what its card badge needs; `roots`: the name of each models folder
they are in and of the folder holding it, to tell two folders of one type apart."""
models, roots, total = [], {}, 0
for located, base_dir, path, other in _walk():
if other:
continue
total += 1
if len(models) >= MAX_PICKABLE:
continue
key = f"{located[0]}|{located[1]}"
if key not in roots:
roots[key] = {"type": located[0], "path_idx": located[1], "name": os.path.basename(os.path.normpath(base_dir)),
"parent": os.path.basename(os.path.dirname(os.path.normpath(base_dir)))}
meta = get_metadata(path)
try:
size = os.path.getsize(path)
except OSError:
size = 0
models.append({
"type": located[0], "path_idx": located[1], "filename": os.path.basename(path),
"rel": os.path.relpath(path, base_dir).replace(os.sep, "/"), "size": size,
"preview_url": _preview_url_for_model(located[0], located[1], base_dir, path),
"metadata": {field: meta.get(field) for field in PICKER_FIELDS},
})
return {"models": models, "total": total, "roots": list(roots.values())}
def _summary_path():
return os.path.join(os.path.dirname(log_path()), "scan_summary.json")
def _work_out():
"""A fresh summary, kept as the last one (and on disk when it changed)."""
summary = _summarize(with_matched=True)
matched = summary.pop("matched_models")
changed = summary != _last.get("summary")
_last.update(summary=summary, matched=matched[:MAX_PICKABLE], matched_total=len(matched), at=time.time())
if changed:
try:
atomic_write_json(_summary_path(), {"at": _last["at"], "summary": summary}, max_bytes=MAX_SAVED_BYTES)
except (OSError, ValueError) as error:
print(f"[Anomalous Browser] Scan summary not kept: {error}")
return summary
async def _fresh():
global _working
if _working is None or _working.done():
_working = asyncio.ensure_future(asyncio.to_thread(_work_out))
return await asyncio.shield(_working)
def _kept():
"""The last summary ({"at", "summary"}), from memory or from disk; None before the first."""
if _last.get("summary"):
return {"at": _last["at"], "summary": _last["summary"]}
try:
if os.path.getsize(_summary_path()) > MAX_SAVED_BYTES:
return None
except OSError:
return None
data = read_json(_summary_path())
return data if data and isinstance(data.get("summary"), dict) else None
async def api_scan_summary(request):
"""GET /anomalous/scan_summary - counts, and the unmatched and new models. `?quick=1`: the
last one at once ({"at", "summary"}, or {} before the first), for the page to show while it
asks for a fresh one."""
if request.query.get("quick"):
return web.json_response(await asyncio.to_thread(_kept) or {})
return web.json_response(await _fresh())
async def api_scan_matched(request):
"""GET /anomalous/scan_matched - the models found on Civitai (components apart), as the last
summary saw them: {"models": [{type, path_idx, filename, rel, name, base}], "total"}."""
if "matched" not in _last:
await _fresh()
return web.json_response({"models": _last.get("matched") or [], "total": _last.get("matched_total", 0)})
async def api_scan_pickable(request):
"""GET /anomalous/scan_pickable - the models the scan settings' picker lists."""
return web.json_response(await asyncio.to_thread(_pickable))

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