Initial commit

This commit is contained in:
John Cantu
2025-09-29 23:05:02 -07:00
commit ddd6584da9
8 changed files with 3755 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
name: Publish to Comfy registry
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "pyproject.toml"
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ## Add your own personal access token to your Github Repository secrets and reference it here.
+114
View File
@@ -0,0 +1,114 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# OSX useful to ignore
*.DS_Store
.AppleDouble
.LSOverride
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# C extensions
*.so
# Distribution / packaging
.Python
env/
venv/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
# Sphinx documentation
docs/_build/
# IntelliJ Idea
.idea
*.iml
*.ipr
*.iws
# PyBuilder
target/
# Cookiecutter
output/
python_boilerplate/
cookiecutter-pypackage-env/
# vscode settings
.history/
*.code-workspace
# Frontend extension
node_modules/
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
node.zip
.vscode/
.claude/
+147
View File
@@ -0,0 +1,147 @@
# ComfyUI-AutoNotes
An intelligent note-taking extension for ComfyUI that automatically displays relevant notes based on your workflow context.
## Features
- **Smart Note Display**: Notes automatically appear based on trigger conditions like selected nodes, node attributes, or workflow names
- **Sidebar Interface**: Non-intrusive sidebar that doesn't interfere with your workflow
- **Multiple Display Modes**:
- "All" mode shows all notes
- "Automatic" mode shows only relevant notes based on triggers
- **Rich Formatting**: Support for both plaintext and Markdown formatting
- **Trigger Conditions**: Configure when notes should appear:
- **Node Selected**: Show notes when specific node types are selected
- **Node Attribute**: Show notes when nodes have specific attribute values
- **Workflow Name**: Show notes when workflow filename contains specific text
- **Pinnable Notes**: Pin important notes to always display regardless of context
- **Organized Storage**: Notes stored in your ComfyUI user directory with UUID-based organization
- **Edit Dialog**: Full-featured editor with tree view and trigger condition management
## Installation
1. Clone or download this repository into your ComfyUI `custom_nodes` directory:
```
cd ComfyUI/custom_nodes
git clone https://github.com/yourusername/ComfyUI-AutoNotes.git
```
2. Restart ComfyUI
3. The AutoNotes sidebar will appear on the right side of your ComfyUI interface
## Usage
### Basic Operations
1. **Creating Notes**: Click the "Add" button in the sidebar control panel
2. **Editing Notes**: Click the "Edit" button to open the full editor dialog
3. **Display Modes**: Switch between "All" and "Automatic" in the dropdown
### Setting Up Trigger Conditions
In the Edit dialog, you can configure when notes should automatically appear:
#### Node Selected Trigger
- Displays the note when any of the specified node types is selected
- Example: `CheckpointLoaderSimple, LoraLoader`
#### Node Attribute Trigger
- Displays the note when a specific node has certain attribute values
- Useful for showing notes about specific models or settings
- Example:
- Node Type: `CheckpointLoaderSimple`
- Attribute Name: `ckpt_name`
- Attribute Values: `mymodel.safetensors, specialmodel.ckpt`
#### Workflow Name Trigger
- Displays the note when the workflow filename contains specific text
- Example: `portrait, character, anime`
### Example Use Cases
1. **Model-Specific Notes**: Create notes with tips about specific checkpoints that appear when you select them
2. **Node Documentation**: Add usage instructions that appear when you select certain node types
3. **Workflow Reminders**: Set up notes that remind you of important settings for specific types of workflows
4. **Quick Reference**: Pin frequently-used information to always stay visible
### Data Storage
Notes are stored in your ComfyUI user directory under `autonotes/`:
- `notes.json`: Contains all note data and metadata
- `folders.json`: Contains folder organization (future feature)
## Technical Details
### File Structure
```text
ComfyUI-AutoNotes/
├── __init__.py # Extension entry point
├── autonotes.py # Main Python backend
├── web/
│ └── autonotes.js # Frontend JavaScript
├── README.md # This file
└── requirements.md # Original requirements document
```
### API Endpoints
The extension exposes REST API endpoints for note management:
- `GET /autonotes/notes` - Retrieve notes with filtering
- `POST /autonotes/notes` - Create new note
- `PUT /autonotes/notes/{uuid}` - Update existing note
- `DELETE /autonotes/notes/{uuid}` - Delete note
- `GET /autonotes/folders` - Retrieve folders
- `POST /autonotes/folders` - Create new folder
### Data Models
**Note Structure**:
```json
{
"uuid": "unique-identifier",
"folder_uuid": "parent-folder-id",
"name": "Note Title",
"content": "Note content...",
"format_style": "plaintext|markdown",
"pinned": true|false,
"trigger_conditions": [...]
}
```
**Trigger Condition Structure**:
```json
{
"type": "node_selected|node_attribute|workflow_name",
"node_types": ["NodeType1", "NodeType2"],
"node_type": "SpecificNodeType",
"attribute_name": "attribute_name",
"attribute_values": ["value1", "value2"],
"workflow_names": ["name1", "name2"]
}
```
## Development
The extension follows ComfyUI's standard custom node structure:
- Python backend handles data management and API endpoints
- JavaScript frontend provides the user interface
- Uses ComfyUI's built-in web server and extension system
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test thoroughly with ComfyUI
5. Submit a pull request
+3
View File
@@ -0,0 +1,3 @@
from .autonotes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS, WEB_DIRECTORY
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
+409
View File
@@ -0,0 +1,409 @@
import os
import json
import uuid
from typing import Dict, List, Optional, Any
from dataclasses import dataclass, asdict
import folder_paths
from server import PromptServer
from aiohttp import web
@dataclass
class TriggerCondition:
type: str # "node_selected", "node_attribute", "workflow_name"
node_types: Optional[List[str]] = None
node_type: Optional[str] = None
attribute_name: Optional[str] = None
attribute_values: Optional[List[str]] = None
workflow_names: Optional[List[str]] = None
@dataclass
class Note:
uuid: str
folder_uuid: Optional[str]
content: str
format_style: str # "plaintext" or "markdown"
trigger_conditions: List[TriggerCondition]
pinned: bool = False
name: str = ""
@dataclass
class Folder:
uuid: str
parent_uuid: Optional[str]
name: str
class AutoNotesManager:
def __init__(self):
self.user_dir = folder_paths.get_user_directory()
self.data_dir = os.path.join(self.user_dir, "autonotes")
self.notes_file = os.path.join(self.data_dir, "notes.json")
self.folders_file = os.path.join(self.data_dir, "folders.json")
# Ensure data directory exists
os.makedirs(self.data_dir, exist_ok=True)
# Load existing data
self.notes: Dict[str, Note] = self._load_notes()
self.folders: Dict[str, Folder] = self._load_folders()
def _load_notes(self) -> Dict[str, Note]:
if os.path.exists(self.notes_file):
try:
with open(self.notes_file, 'r', encoding='utf-8') as f:
data = json.load(f)
notes = {}
for note_data in data:
# Convert trigger conditions
trigger_conditions = []
for tc_data in note_data.get('trigger_conditions', []):
trigger_conditions.append(TriggerCondition(**tc_data))
note_data['trigger_conditions'] = trigger_conditions
note = Note(**note_data)
notes[note.uuid] = note
return notes
except Exception as e:
print(f"Error loading notes: {e}")
return {}
def _load_folders(self) -> Dict[str, Folder]:
if os.path.exists(self.folders_file):
try:
with open(self.folders_file, 'r', encoding='utf-8') as f:
data = json.load(f)
folders = {}
for folder_data in data:
folder = Folder(**folder_data)
folders[folder.uuid] = folder
return folders
except Exception as e:
print(f"Error loading folders: {e}")
return {}
def _save_notes(self):
try:
data = []
for note in self.notes.values():
note_dict = asdict(note)
# Convert trigger conditions to dict
note_dict['trigger_conditions'] = [asdict(tc) for tc in note.trigger_conditions]
data.append(note_dict)
with open(self.notes_file, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
except Exception as e:
print(f"Error saving notes: {e}")
def _save_folders(self):
try:
data = [asdict(folder) for folder in self.folders.values()]
with open(self.folders_file, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
except Exception as e:
print(f"Error saving folders: {e}")
def create_note(self, name: str, folder_uuid: Optional[str] = None) -> str:
note_uuid = str(uuid.uuid4())
note = Note(
uuid=note_uuid,
folder_uuid=folder_uuid,
content="",
format_style="plaintext",
trigger_conditions=[],
name=name
)
self.notes[note_uuid] = note
self._save_notes()
return note_uuid
def update_note(self, note_uuid: str, **kwargs) -> bool:
if note_uuid not in self.notes:
return False
note = self.notes[note_uuid]
for key, value in kwargs.items():
if hasattr(note, key):
setattr(note, key, value)
self._save_notes()
return True
def delete_note(self, note_uuid: str) -> bool:
if note_uuid in self.notes:
del self.notes[note_uuid]
self._save_notes()
return True
return False
def create_folder(self, name: str, parent_uuid: Optional[str] = None) -> str:
folder_uuid = str(uuid.uuid4())
folder = Folder(
uuid=folder_uuid,
parent_uuid=parent_uuid,
name=name
)
self.folders[folder_uuid] = folder
self._save_folders()
return folder_uuid
def get_notes_for_display(self, mode: str = "all", selected_node_type: Optional[str] = None,
selected_node_attributes: Optional[Dict[str, Any]] = None,
workflow_name: Optional[str] = None,
workflow_nodes: Optional[Dict[str, Any]] = None) -> List[Note]:
if mode == "all":
return list(self.notes.values())
# Automatic mode - filter by trigger conditions
matching_notes = []
for note in self.notes.values():
if note.pinned:
matching_notes.append(note)
continue
for condition in note.trigger_conditions:
if self._check_trigger_condition(condition, selected_node_type,
selected_node_attributes, workflow_name,
workflow_nodes):
matching_notes.append(note)
break
return matching_notes
def _check_trigger_condition(self, condition: TriggerCondition,
selected_node_type: Optional[str],
selected_node_attributes: Optional[Dict[str, Any]],
workflow_name: Optional[str],
workflow_nodes: Optional[Dict[str, Any]] = None) -> bool:
if condition.type == "node_selected":
return (selected_node_type is not None and
condition.node_types is not None and
selected_node_type in condition.node_types)
elif condition.type == "node_attribute":
if (selected_node_type == condition.node_type and
selected_node_attributes is not None and
condition.attribute_name in selected_node_attributes and
condition.attribute_values is not None):
attr_value = str(selected_node_attributes[condition.attribute_name])
return any(val in attr_value for val in condition.attribute_values)
elif condition.type == "node_in_workflow":
if workflow_nodes is not None and condition.node_types is not None:
# Check if any of the specified node types exist in the workflow
return any(node_type in workflow_nodes for node_type in condition.node_types)
elif condition.type == "attribute_in_workflow":
if (workflow_nodes is not None and
condition.node_type is not None and
condition.attribute_name is not None):
# Check if the node type exists and has the specified attribute
if condition.node_type in workflow_nodes:
node_attrs = workflow_nodes[condition.node_type]
if condition.attribute_name in node_attrs:
# If attribute_values is specified, check if any match
if condition.attribute_values is not None and len(condition.attribute_values) > 0:
attr_value = str(node_attrs[condition.attribute_name])
return any(val in attr_value for val in condition.attribute_values)
else:
# No specific values required, just presence is enough
return True
elif condition.type == "workflow_name":
if workflow_name is not None and condition.workflow_names is not None:
return any(name in workflow_name for name in condition.workflow_names)
return False
# Global manager instance
autonotes_manager = AutoNotesManager()
class AutoNotesNode:
@classmethod
def INPUT_TYPES(s):
return {
"required": {},
}
RETURN_TYPES = ()
FUNCTION = "process"
OUTPUT_NODE = True
CATEGORY = "AutoNotes"
def process(self):
# This node doesn't actually process anything in the workflow
# It's just here to provide a presence in the node list
return {}
# Web API routes
@PromptServer.instance.routes.get("/autonotes/notes")
async def get_notes(request):
try:
mode = request.query.get('mode', 'all')
selected_node_type = request.query.get('node_type')
workflow_name = request.query.get('workflow_name')
# Parse node attributes if provided
selected_node_attributes = None
if 'node_attributes' in request.query:
try:
selected_node_attributes = json.loads(request.query['node_attributes'])
except:
pass
# Parse workflow nodes if provided
workflow_nodes = None
if 'workflow_nodes' in request.query:
try:
workflow_nodes = json.loads(request.query['workflow_nodes'])
except:
pass
notes = autonotes_manager.get_notes_for_display(
mode=mode,
selected_node_type=selected_node_type,
selected_node_attributes=selected_node_attributes,
workflow_name=workflow_name,
workflow_nodes=workflow_nodes
)
# Convert notes to JSON-serializable format
notes_data = []
for note in notes:
note_dict = asdict(note)
note_dict['trigger_conditions'] = [asdict(tc) for tc in note.trigger_conditions]
notes_data.append(note_dict)
return web.json_response(notes_data)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/autonotes/notes")
async def create_note(request):
try:
data = await request.json()
name = data.get('name', 'New Note')
folder_uuid = data.get('folder_uuid')
note_uuid = autonotes_manager.create_note(name, folder_uuid)
return web.json_response({"uuid": note_uuid})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.put("/autonotes/notes/{note_uuid}")
async def update_note(request):
try:
note_uuid = request.match_info['note_uuid']
data = await request.json()
# Handle trigger conditions conversion
if 'trigger_conditions' in data:
trigger_conditions = []
for tc_data in data['trigger_conditions']:
trigger_conditions.append(TriggerCondition(**tc_data))
data['trigger_conditions'] = trigger_conditions
success = autonotes_manager.update_note(note_uuid, **data)
return web.json_response({"success": success})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.delete("/autonotes/notes/{note_uuid}")
async def delete_note(request):
try:
note_uuid = request.match_info['note_uuid']
success = autonotes_manager.delete_note(note_uuid)
return web.json_response({"success": success})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.get("/autonotes/folders")
async def get_folders(request):
try:
folders_data = [asdict(folder) for folder in autonotes_manager.folders.values()]
return web.json_response(folders_data)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/autonotes/folders")
async def create_folder(request):
try:
data = await request.json()
name = data.get('name', 'New Folder')
parent_uuid = data.get('parent_uuid')
folder_uuid = autonotes_manager.create_folder(name, parent_uuid)
return web.json_response({"uuid": folder_uuid})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.put("/autonotes/folders/{folder_uuid}")
async def update_folder(request):
try:
folder_uuid = request.match_info['folder_uuid']
data = await request.json()
if folder_uuid not in autonotes_manager.folders:
return web.json_response({"success": False, "error": "Folder not found"}, status=404)
folder = autonotes_manager.folders[folder_uuid]
if 'name' in data:
folder.name = data['name']
if 'parent_uuid' in data:
folder.parent_uuid = data['parent_uuid']
autonotes_manager._save_folders()
return web.json_response({"success": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.delete("/autonotes/folders/{folder_uuid}")
async def delete_folder(request):
try:
folder_uuid = request.match_info['folder_uuid']
if folder_uuid not in autonotes_manager.folders:
return web.json_response({"success": False, "error": "Folder not found"}, status=404)
# Remove folder_uuid from any notes that reference it
for note in autonotes_manager.notes.values():
if note.folder_uuid == folder_uuid:
note.folder_uuid = None
autonotes_manager._save_notes()
# Delete the folder
del autonotes_manager.folders[folder_uuid]
autonotes_manager._save_folders()
return web.json_response({"success": True})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Node mappings
NODE_CLASS_MAPPINGS = {
"AutoNotesNode": AutoNotesNode
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AutoNotesNode": "Auto Notes"
}
# Web directory for frontend files
WEB_DIRECTORY = "./web"
+73
View File
@@ -0,0 +1,73 @@
[build-system]
requires = ["setuptools>=70.0"]
build-backend = "setuptools.build_meta"
[project]
name = "ComfyUI-AutoNotes"
version = "0.0.1"
description = "Automatically display context-aware notes based on workflow state, selected nodes, and node attributes in ComfyUI."
authors = [
{name = "John Cantu", email = "jfcantu@gmail.com"}
]
readme = "README.md"
license = {text = "MIT license"}
requires-python = ">=3.10"
classifiers = []
dependencies = []
[project.urls]
Repository = "https://github.com/jfcantu/ComfyUI-AutoNotes"
BugTracker = "https://github.com/jfcantu/ComfyUI-AutoNotes/issues"
Documentation = "https://github.com/jfcantu/ComfyUI-AutoNotes/wiki"
[tool.comfy]
PublisherId = "jfcantu"
DisplayName = "ComfyUI AutoNotes"
Icon = ""
Tags = []
Repository = "https://github.com/jfcantu/ComfyUI-AutoNotes"
includes = []
[tool.setuptools.package-data]
"*" = ["*.*"]
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = [
"tests",
]
[tool.mypy]
files = "."
# Use strict defaults
strict = true
warn_unreachable = true
warn_no_return = true
[[tool.mypy.overrides]]
# Don't require test functions to include types
module = "tests.*"
allow_untyped_defs = true
disable_error_code = "attr-defined"
[tool.ruff]
# extend-exclude = ["static", "ci/templates"]
line-length = 140
src = ["src", "tests"]
target-version = "py39"
# Add rules to ban exec/eval
[tool.ruff.lint]
select = [
"S102", # exec-builtin
"S307", # eval-used
"W293",
"F", # The "F" series in Ruff stands for "Pyflakes" rules, which catch various Python syntax errors and undefined names.
# See all rules here: https://docs.astral.sh/ruff/rules/#pyflakes-f
]
[tool.ruff.lint.flake8-quotes]
inline-quotes = "double"
+2983
View File
File diff suppressed because it is too large Load Diff
+6
View File
File diff suppressed because one or more lines are too long