Files
DemonGatanjieu-Anomalous_Mo…/api/activity_log.py
T
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

241 lines
9.1 KiB
Python

"""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_CHANGES = 60
# Write routes worth a line in the log → the action id the browser has words for.
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/clean_civitai_info': 'model_info_clean',
'/anomalous/scan': 'scan',
'/anomalous/scan_all': 'scan',
'/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_parameter_material': 'material_save',
'/anomalous/save_prompt_note_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',
'/anomalous_tts/storage': 'voice_storage',
}
# 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')
_lock = threading.RLock()
_entries = None
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 _short(value):
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
return text if len(text) <= MAX_VALUE_CHARS else text[:MAX_VALUE_CHARS] + '…'
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)
return entry
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 {}
@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))
await asyncio.get_running_loop().run_in_executor(None, add_entry, 'file', action, target)
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 = {key: _short(change[key]) for key in ('kind', 'node', 'type', 'widget', 'before', 'after')
if key in change and change[key] is not None}
if item.get('kind') in ('changed', '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}."""
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))}
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)