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

258 lines
10 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. 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_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')
# 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
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 {}
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'] = [{key: _short(item[key]) for key in ('field', 'before', 'after') if key in item}
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 = {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)