From e6b3fd3bc68b410d4192cde8e83d4dfd89df2729 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 17 Oct 2026 16:04:05 -0700 Subject: [PATCH] Add AGENT.md file --- AGENT.md | 230 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 AGENT.md diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..b695d3e --- /dev/null +++ b/AGENT.md @@ -0,0 +1,230 @@ +# AGENT.md + +Guidelines for working on this project. Read this before making changes. + +## Project Goals + +Prompt Stash is a small ComfyUI custom node pack for saving prompts into named lists and capturing text from upstream nodes (LLMs, etc.). The philosophy: + +- **Small and focused** -- three nodes, one shared JSON library, no extra dependencies +- **Never break saved workflows** -- widget order, node class names and the data file format are effectively public API +- **Work on both renderers** -- classic LiteGraph canvas and the Vue (Nodes 2.0) renderer, plus nodes inside subgraphs +- **Robust error handling** -- a missing/corrupt data file or an odd frontend version must not crash ComfyUI or the node + +## Architecture + +### Layout + +| File | Purpose | +|------|---------| +| `__init__.py` | HTTP routes (save/delete/init/get_prompt/list ops/import/export) + node registration | +| `prompt_stash_saver_node.py` | `PromptStashSaver` -- prompt box, save/load from lists, `text` + `meta` outputs | +| `prompt_stash_passthrough_node.py` | `PromptStashPassthrough` -- text capture + "pause to edit"; owns the `continue` / `clear_all` routes | +| `prompt_stash_manager_node.py` | `PromptStashManager` -- add/delete/sort lists (no outputs, UI-only node) | +| `data_utils.py` | Data file location/init/migration, import validation + merge, workflow/prompt helpers | +| `default_prompt_stash_data.json` | Template copied to the user data file on first run | +| `js/*.js` | Frontend extensions, one per node + `multi_button.js` widget + shared `utils.js` | +| `node_list.json` | Registry descriptions per node | +| `example_workflows/` | Sample workflow (JSON + PNG) | + +### Python (Nodes + Routes) + +- **V1 schema** (`INPUT_TYPES`, `RETURN_TYPES`, `FUNCTION`, `NODE_CLASS_MAPPINGS` in `__init__.py`). This pack is NOT on V3 -- don't mix in `comfy_api.latest` / `comfy_entrypoint` piecemeal. Adding `comfy_entrypoint` does nothing while `NODE_CLASS_MAPPINGS` exists (ComfyUI's loader takes the V1 branch). A V3 migration would be a deliberate, whole-pack change that must keep the class names and widget order +- Node class names (`PromptStashSaver`, `PromptStashPassthrough`, `PromptStashManager`) are the workflow `type` -- never rename them +- All nodes live in category `utils` +- `WEB_DIRECTORY = "./js"` +- Routes are plain `@PromptServer.instance.routes.post/get(...)` decorators at module import. Prefixes: `/prompt_stash_saver/*` (also hosts list ops: `add_list`, `delete_list`, `sort_list`), `/prompt_stash_manager/*` (import/export), `/prompt_stash_passthrough/*` (continue, clear_all) +- Route handlers create a **fresh node instance per request** (`PromptStashSaver()` / `PromptStashManager()`), and `__init__` re-reads the data file from disk. There is no in-memory cache shared between requests, and no file lock +- Server -> client pushes go through `PromptServer.instance.send_sync(...)`: + - `prompt-stash-update-all` `{lists}` -- after any data change, and **also on every `PromptStashSaver()` construction** (`load_data()` broadcasts). Saver JS relies on this: it calls `/prompt_stash_saver/init` and ignores the response, getting its lists from the broadcast that the constructor sends. Don't remove the broadcast from `load_data()` without changing `prompt_stash_saver.js` to read the `init` response + - `prompt-stash-update-prompt` `{node_id, prompt}` -- captured input text pushed into the node's prompt box + - `prompt-stash-set-continue` `{node_id, show}` -- show/hide the Passthrough Continue button +- Output messages use `print(...)`, prefixed `Prompt Stash:` for normal messages (no `logging` module in use) + +### Data File + +- Location: `folder_paths.get_user_directory()/prompt_stash/prompt_stash_data.json` (`get_user_data_directory()`). Fallback when that fails: `prompt_stash_data.json` in the node folder (gitignored) +- `init_data_file()` handles: one-time migration from the old node-folder location (copy then delete), first-run seeding from `default_prompt_stash_data.json` (or a built-in minimal default), and adding `"version"` to old files +- Format: `{"version": "1.0", "lists": {list_name: {prompt_name: prompt_text}}}`. Dict key order is the display order (Sort A-Z rewrites it with `casefold()`) +- Unknown list names on save/get fall back to `"default"`. The last remaining list can't be deleted (`len(lists) <= 1`); `"default"` itself is deletable +- Import (`validate_import_data` + `deep_merge_lists`): drops invalid entries, strips names, merges into existing lists, renames clashes to `name (2)`, `name (3)`... Never overwrites. Returns a summary shown in a toast +- Export streams the raw file as `prompt_stash_export_.json` +- Always write with `encoding='utf-8'`, `indent=2` -- users hand-edit and diff this file + +### Node Behaviour + +- **Saver / Passthrough shared pattern**: + - `use_input_text` (BOOLEAN) chooses between the `text` input (lazy, `forceInput`) and the `prompt_text` widget + - `check_lazy_status` requests `text` only when `use_input_text` is on. If `text` isn't in the prompt at all it raises a clear `ValueError` (`prompt_has_input`) instead of ComfyUI's "says it needs input text, but there is no input to that node at all" + - `IS_CHANGED` hashes the inputs that affect output; `text` only counts when `use_input_text` is on + - When input text is used, `process()` pushes it to the UI (`prompt-stash-update-prompt`) **and rewrites the saved metadata**: in `extra_pnginfo.workflow` (via `update_node_in_workflow`, subgraph-aware) sets `use_input_text=False` and `prompt_text=`, and does the same in `prompt`. Loading an output image therefore reproduces the captured text without re-running the LLM +- **Saver**: outputs `text` and `meta` (JSON string: `prompt_name`, `list_name`, `source` = `input|saved|manual`, `save_as_key`, `load_saved`, `use_input_text`; precedence documented in `build_meta`). `load_saved` / `prompt_lists` are COMBOs declared in Python with stub values (`["None"]` / `["default"]`) only so the frontend creates Primitive-connectable input sockets; real options are filled client-side. `VALIDATE_INPUTS` takes those two params so ComfyUI skips static combo validation for them +- **Passthrough**: `pause_to_edit` blocks the executor thread in a `time.sleep(0.1)` loop until `/prompt_stash_passthrough/continue/{node_id}` sets status `"continue"` (with the edited text). State is in class-level dicts (`status_by_id`, `edited_text_by_id`) keyed by the **string** UNIQUE_ID (`"5"` or `"12:5"` in subgraphs). Re-sends `set-continue` every 2s so a refreshed browser gets the button back. `/clear_all` (Manager's "(Clear All Paused)" button) releases every paused node +- **Manager**: no outputs, `process()` is a no-op. The `existing_lists` combo is created in JS (not declared in Python) + +### JavaScript (Frontend Extensions) + +- Plain ES modules, no build step. Imports: `import { app } from "../../scripts/app.js"`, `import { api } from "../../scripts/api.js"`, shared helpers from `./utils.js` +- Extension names: `phazei.PromptStashSaver`, `phazei.PromptStashPassthrough`, `phazei.PromptStashManager`, `phazei.MULTI_BUTTON` +- Pattern: `beforeRegisterNodeDef` -> wrap `nodeType.prototype.onNodeCreated` (always call the original first) -> find widgets by `.name` -> attach callbacks / listeners -> wrap `onRemoved` to remove every `api` listener and interval +- Event handlers are stored on the node (`this.handlePromptStash...`) so `onRemoved` can unregister the same function. Each handler starts with `if (isUnassignedNode(this)) { this.onRemoved?.(); return; }` to drop listeners of detached/zombie nodes +- Match server pushes to nodes with `nodeMatchesUniqueId(node, event.detail.node_id)`; build ids to send with `getUniqueIdFromNode(node)`. Both are subgraph-aware +- Labels via `setDefaultLabel(node, widget, "Label")` -- only changes `.label`, keeps a user's rename. Never change `widget.name` (it must equal the Python input key) +- Buttons via the custom `MULTI_BUTTON` widget (`js/multi_button.js`, registered through `getCustomWidgets`): a row of canvas-drawn buttons, `serialize: false`, supports `confirm`, `setDisabled(i, bool)`, `update()`, optional single/multi select. Used by Saver (Save / Delete) and Manager (Sort / Add / Delete, Export / Import, and a decorative easter-egg row named `actions`). Guarded with `typeof app.widgets?.MULTI_BUTTON === "function"` +- Toasts via `app.extensionManager?.toast?.add({...})` -- always optional-chained +- Global "R" refresh: Saver implements the extension's `refreshComboInNodes()` hook, walking all graphs (`walkGraph`) and calling `node._repopulateCombos()` because `reloadNodeDefs` resets combos to the Python stubs + +### Shared Utilities (`js/utils.js`) + +- `nodeMatchesUniqueId`, `getUniqueIdFromNode`, `isUnassignedNode`, `walkGraph`, `setDefaultLabel` +- Primitive-node support: `findGetConfigSymbol`, `installComboGetConfig`, `refreshConnectedPrimitives`, `syncValueToConnectedPrimitive` +- `forceWidgetReactiveUpdate(node, widget)` -- Vue re-render + clears `value_not_in_list` error + +## LiteGraph + Nodes 2.0 Compatibility + +Every UI change must work on **both** renderers. Both use the same `LGraphNode` objects, widgets and callbacks. They differ in how nodes are drawn and receive input: +- **LiteGraph (classic)**: nodes and widgets are painted on the graph ``, and mouse events reach the node's `onMouse*` handlers. +- **Nodes 2.0 (Vue)**: each node is a Vue DOM component (`[data-node-id=""]`) layered over the canvas. Widgets are Vue components that read reactive state, and canvas-drawn custom widgets are hosted in a small per-widget canvas (`WidgetLegacy.vue`). + +The user can switch renderers while a workflow is open, so **don't decide the renderer once in `onNodeCreated`**. Check at the moment you act, as `handlePromptStashSetContinue` does with `isVueRenderer()`, or do both paths when both are harmless. + +### Works the same on both (prefer these) +- Reading and writing `widget.value`, `widget.label`, `widget.callback`, finding widgets by `.name` +- `api` events, routes, toasts (`app.extensionManager?.toast`) +- `node.setDirtyCanvas(true, true)` / `app.graph.setDirtyCanvas(...)`: needed for LiteGraph and harmless under Vue. Keep calling it +- Built-in widget types declared in Python (`STRING`, `BOOLEAN`, COMBO): the frontend renders them natively in both modes. Declaring a widget in `INPUT_TYPES` is more robust than creating it in JS + +Prefer the frontend's own data model (widgets, values, labels) over custom drawing. That way both renderers pick up the change without extra code. + +### LiteGraph-only. Needs a separate Vue path +- **Painting on the node** (`onDrawForeground` / `onDrawBackground`): Vue nodes don't use it. Passthrough's Continue button is drawn in the title bar on the canvas and also added as a DOM `