feat: implement task cancellation for model scanning and bulk operations

This commit is contained in:
Will Miao
2026-01-02 18:48:28 +08:00
parent 953117efa1
commit 837c32c42f
24 changed files with 505 additions and 219 deletions

View File

@@ -1370,6 +1370,7 @@
"verificationCompleteSuccess": "Verifikation abgeschlossen. Alle Dateien sind bestätigte Duplikate.", "verificationCompleteSuccess": "Verifikation abgeschlossen. Alle Dateien sind bestätigte Duplikate.",
"verificationFailed": "Fehler beim Verifizieren der Hashes: {message}", "verificationFailed": "Fehler beim Verifizieren der Hashes: {message}",
"noTagsToAdd": "Keine Tags zum Hinzufügen", "noTagsToAdd": "Keine Tags zum Hinzufügen",
"bulkTagsUpdating": "Tags für {count} Modell(e) werden aktualisiert...",
"tagsAddedSuccessfully": "Erfolgreich {tagCount} Tag(s) zu {count} {type}(s) hinzugefügt", "tagsAddedSuccessfully": "Erfolgreich {tagCount} Tag(s) zu {count} {type}(s) hinzugefügt",
"tagsReplacedSuccessfully": "Tags für {count} {type}(s) erfolgreich durch {tagCount} Tag(s) ersetzt", "tagsReplacedSuccessfully": "Tags für {count} {type}(s) erfolgreich durch {tagCount} Tag(s) ersetzt",
"tagsAddFailed": "Fehler beim Hinzufügen von Tags zu {count} Modell(en)", "tagsAddFailed": "Fehler beim Hinzufügen von Tags zu {count} Modell(en)",
@@ -1490,6 +1491,8 @@
"metadataRefreshed": "Metadaten erfolgreich aktualisiert", "metadataRefreshed": "Metadaten erfolgreich aktualisiert",
"metadataRefreshFailed": "Fehler beim Aktualisieren der Metadaten: {message}", "metadataRefreshFailed": "Fehler beim Aktualisieren der Metadaten: {message}",
"metadataUpdateComplete": "Metadaten-Update abgeschlossen", "metadataUpdateComplete": "Metadaten-Update abgeschlossen",
"operationCancelled": "Vorgang vom Benutzer abgebrochen",
"operationCancelledPartial": "Vorgang abgebrochen. {success} Elemente verarbeitet.",
"metadataFetchFailed": "Fehler beim Abrufen der Metadaten: {message}", "metadataFetchFailed": "Fehler beim Abrufen der Metadaten: {message}",
"bulkMetadataCompleteAll": "Alle {count} {type}s erfolgreich aktualisiert", "bulkMetadataCompleteAll": "Alle {count} {type}s erfolgreich aktualisiert",
"bulkMetadataCompletePartial": "{success} von {total} {type}s aktualisiert", "bulkMetadataCompletePartial": "{success} von {total} {type}s aktualisiert",

View File

@@ -1370,6 +1370,7 @@
"verificationCompleteSuccess": "Verification complete. All files are confirmed duplicates.", "verificationCompleteSuccess": "Verification complete. All files are confirmed duplicates.",
"verificationFailed": "Failed to verify hashes: {message}", "verificationFailed": "Failed to verify hashes: {message}",
"noTagsToAdd": "No tags to add", "noTagsToAdd": "No tags to add",
"bulkTagsUpdating": "Updating tags for {count} model(s)...",
"tagsAddedSuccessfully": "Successfully added {tagCount} tag(s) to {count} {type}(s)", "tagsAddedSuccessfully": "Successfully added {tagCount} tag(s) to {count} {type}(s)",
"tagsReplacedSuccessfully": "Successfully replaced tags for {count} {type}(s) with {tagCount} tag(s)", "tagsReplacedSuccessfully": "Successfully replaced tags for {count} {type}(s) with {tagCount} tag(s)",
"tagsAddFailed": "Failed to add tags to {count} model(s)", "tagsAddFailed": "Failed to add tags to {count} model(s)",
@@ -1490,6 +1491,8 @@
"metadataRefreshed": "Metadata refreshed successfully", "metadataRefreshed": "Metadata refreshed successfully",
"metadataRefreshFailed": "Failed to refresh metadata: {message}", "metadataRefreshFailed": "Failed to refresh metadata: {message}",
"metadataUpdateComplete": "Metadata update complete", "metadataUpdateComplete": "Metadata update complete",
"operationCancelled": "Operation cancelled by user",
"operationCancelledPartial": "Operation cancelled. {success} items processed.",
"metadataFetchFailed": "Failed to fetch metadata: {message}", "metadataFetchFailed": "Failed to fetch metadata: {message}",
"bulkMetadataCompleteAll": "Successfully refreshed all {count} {type}s", "bulkMetadataCompleteAll": "Successfully refreshed all {count} {type}s",
"bulkMetadataCompletePartial": "Refreshed {success} of {total} {type}s", "bulkMetadataCompletePartial": "Refreshed {success} of {total} {type}s",

View File

@@ -1370,6 +1370,7 @@
"verificationCompleteSuccess": "Verificación completa. Todos los archivos son confirmados duplicados.", "verificationCompleteSuccess": "Verificación completa. Todos los archivos son confirmados duplicados.",
"verificationFailed": "Error al verificar hashes: {message}", "verificationFailed": "Error al verificar hashes: {message}",
"noTagsToAdd": "No hay etiquetas para añadir", "noTagsToAdd": "No hay etiquetas para añadir",
"bulkTagsUpdating": "Actualizando etiquetas para {count} modelo(s)...",
"tagsAddedSuccessfully": "Se añadieron exitosamente {tagCount} etiqueta(s) a {count} {type}(s)", "tagsAddedSuccessfully": "Se añadieron exitosamente {tagCount} etiqueta(s) a {count} {type}(s)",
"tagsReplacedSuccessfully": "Se reemplazaron exitosamente las etiquetas de {count} {type}(s) con {tagCount} etiqueta(s)", "tagsReplacedSuccessfully": "Se reemplazaron exitosamente las etiquetas de {count} {type}(s) con {tagCount} etiqueta(s)",
"tagsAddFailed": "Error al añadir etiquetas a {count} modelo(s)", "tagsAddFailed": "Error al añadir etiquetas a {count} modelo(s)",
@@ -1490,6 +1491,8 @@
"metadataRefreshed": "Metadatos actualizados exitosamente", "metadataRefreshed": "Metadatos actualizados exitosamente",
"metadataRefreshFailed": "Error al actualizar metadatos: {message}", "metadataRefreshFailed": "Error al actualizar metadatos: {message}",
"metadataUpdateComplete": "Actualización de metadatos completada", "metadataUpdateComplete": "Actualización de metadatos completada",
"operationCancelled": "Operación cancelada por el usuario",
"operationCancelledPartial": "Operación cancelada. {success} elementos procesados.",
"metadataFetchFailed": "Error al obtener metadatos: {message}", "metadataFetchFailed": "Error al obtener metadatos: {message}",
"bulkMetadataCompleteAll": "Actualizados exitosamente todos los {count} {type}s", "bulkMetadataCompleteAll": "Actualizados exitosamente todos los {count} {type}s",
"bulkMetadataCompletePartial": "Actualizados {success} de {total} {type}s", "bulkMetadataCompletePartial": "Actualizados {success} de {total} {type}s",

View File

@@ -1370,6 +1370,7 @@
"verificationCompleteSuccess": "Vérification terminée. Tous les fichiers sont confirmés comme doublons.", "verificationCompleteSuccess": "Vérification terminée. Tous les fichiers sont confirmés comme doublons.",
"verificationFailed": "Échec de la vérification des hash : {message}", "verificationFailed": "Échec de la vérification des hash : {message}",
"noTagsToAdd": "Aucun tag à ajouter", "noTagsToAdd": "Aucun tag à ajouter",
"bulkTagsUpdating": "Mise à jour des tags pour {count} modèle(s)...",
"tagsAddedSuccessfully": "{tagCount} tag(s) ajouté(s) avec succès à {count} {type}(s)", "tagsAddedSuccessfully": "{tagCount} tag(s) ajouté(s) avec succès à {count} {type}(s)",
"tagsReplacedSuccessfully": "Tags remplacés avec succès pour {count} {type}(s) avec {tagCount} tag(s)", "tagsReplacedSuccessfully": "Tags remplacés avec succès pour {count} {type}(s) avec {tagCount} tag(s)",
"tagsAddFailed": "Échec de l'ajout des tags à {count} modèle(s)", "tagsAddFailed": "Échec de l'ajout des tags à {count} modèle(s)",
@@ -1490,6 +1491,8 @@
"metadataRefreshed": "Métadonnées actualisées avec succès", "metadataRefreshed": "Métadonnées actualisées avec succès",
"metadataRefreshFailed": "Échec de l'actualisation des métadonnées : {message}", "metadataRefreshFailed": "Échec de l'actualisation des métadonnées : {message}",
"metadataUpdateComplete": "Mise à jour des métadonnées terminée", "metadataUpdateComplete": "Mise à jour des métadonnées terminée",
"operationCancelled": "Opération annulée par l'utilisateur",
"operationCancelledPartial": "Opération annulée. {success} éléments traités.",
"metadataFetchFailed": "Échec de la récupération des métadonnées : {message}", "metadataFetchFailed": "Échec de la récupération des métadonnées : {message}",
"bulkMetadataCompleteAll": "Actualisation réussie de tous les {count} {type}s", "bulkMetadataCompleteAll": "Actualisation réussie de tous les {count} {type}s",
"bulkMetadataCompletePartial": "{success} sur {total} {type}s actualisés", "bulkMetadataCompletePartial": "{success} sur {total} {type}s actualisés",

View File

@@ -1370,6 +1370,7 @@
"verificationCompleteSuccess": "האימות הושלם. כל הקבצים אושרו ככפולים.", "verificationCompleteSuccess": "האימות הושלם. כל הקבצים אושרו ככפולים.",
"verificationFailed": "אימות ה-hashes נכשל: {message}", "verificationFailed": "אימות ה-hashes נכשל: {message}",
"noTagsToAdd": "אין תגיות להוספה", "noTagsToAdd": "אין תגיות להוספה",
"bulkTagsUpdating": "מעדכן תגיות עבור {count} מודלים...",
"tagsAddedSuccessfully": "נוספו בהצלחה {tagCount} תגית(ות) ל-{count} {type}(ים)", "tagsAddedSuccessfully": "נוספו בהצלחה {tagCount} תגית(ות) ל-{count} {type}(ים)",
"tagsReplacedSuccessfully": "הוחלפו בהצלחה תגיות עבור {count} {type}(ים) ב-{tagCount} תגית(ות)", "tagsReplacedSuccessfully": "הוחלפו בהצלחה תגיות עבור {count} {type}(ים) ב-{tagCount} תגית(ות)",
"tagsAddFailed": "הוספת תגיות ל-{count} מודל(ים) נכשלה", "tagsAddFailed": "הוספת תגיות ל-{count} מודל(ים) נכשלה",
@@ -1490,6 +1491,8 @@
"metadataRefreshed": "המטא-דאטה רועננה בהצלחה", "metadataRefreshed": "המטא-דאטה רועננה בהצלחה",
"metadataRefreshFailed": "רענון המטא-דאטה נכשל: {message}", "metadataRefreshFailed": "רענון המטא-דאטה נכשל: {message}",
"metadataUpdateComplete": "עדכון המטא-דאטה הושלם", "metadataUpdateComplete": "עדכון המטא-דאטה הושלם",
"operationCancelled": "הפעולה בוטלה על ידי המשתמש",
"operationCancelledPartial": "הפעולה בוטלה. {success} פריטים עובדו.",
"metadataFetchFailed": "אחזור המטא-דאטה נכשל: {message}", "metadataFetchFailed": "אחזור המטא-דאטה נכשל: {message}",
"bulkMetadataCompleteAll": "רועננו בהצלחה כל {count} ה-{type}s", "bulkMetadataCompleteAll": "רועננו בהצלחה כל {count} ה-{type}s",
"bulkMetadataCompletePartial": "רועננו {success} מתוך {total} {type}s", "bulkMetadataCompletePartial": "רועננו {success} מתוך {total} {type}s",

View File

@@ -1370,6 +1370,7 @@
"verificationCompleteSuccess": "検証完了。すべてのファイルが重複であることが確認されました。", "verificationCompleteSuccess": "検証完了。すべてのファイルが重複であることが確認されました。",
"verificationFailed": "ハッシュの検証に失敗しました:{message}", "verificationFailed": "ハッシュの検証に失敗しました:{message}",
"noTagsToAdd": "追加するタグがありません", "noTagsToAdd": "追加するタグがありません",
"bulkTagsUpdating": "{count} 個のモデルのタグを更新しています...",
"tagsAddedSuccessfully": "{count} {type} に {tagCount} 個のタグを追加しました", "tagsAddedSuccessfully": "{count} {type} に {tagCount} 個のタグを追加しました",
"tagsReplacedSuccessfully": "{count} {type} のタグを {tagCount} 個に置換しました", "tagsReplacedSuccessfully": "{count} {type} のタグを {tagCount} 個に置換しました",
"tagsAddFailed": "{count} モデルへのタグ追加に失敗しました", "tagsAddFailed": "{count} モデルへのタグ追加に失敗しました",
@@ -1490,6 +1491,8 @@
"metadataRefreshed": "メタデータが正常に更新されました", "metadataRefreshed": "メタデータが正常に更新されました",
"metadataRefreshFailed": "メタデータの更新に失敗しました:{message}", "metadataRefreshFailed": "メタデータの更新に失敗しました:{message}",
"metadataUpdateComplete": "メタデータ更新完了", "metadataUpdateComplete": "メタデータ更新完了",
"operationCancelled": "ユーザーによって操作がキャンセルされました",
"operationCancelledPartial": "操作がキャンセルされました。{success} 個の項目が処理されました。",
"metadataFetchFailed": "メタデータの取得に失敗しました:{message}", "metadataFetchFailed": "メタデータの取得に失敗しました:{message}",
"bulkMetadataCompleteAll": "{count} {type}すべてが正常に更新されました", "bulkMetadataCompleteAll": "{count} {type}すべてが正常に更新されました",
"bulkMetadataCompletePartial": "{total} {type}のうち {success} が更新されました", "bulkMetadataCompletePartial": "{total} {type}のうち {success} が更新されました",

View File

@@ -1370,6 +1370,7 @@
"verificationCompleteSuccess": "검증 완료. 모든 파일이 중복임을 확인했습니다.", "verificationCompleteSuccess": "검증 완료. 모든 파일이 중복임을 확인했습니다.",
"verificationFailed": "해시 검증 실패: {message}", "verificationFailed": "해시 검증 실패: {message}",
"noTagsToAdd": "추가할 태그가 없습니다", "noTagsToAdd": "추가할 태그가 없습니다",
"bulkTagsUpdating": "{count}개 모델의 태그를 업데이트 중입니다...",
"tagsAddedSuccessfully": "{count}개의 {type}에 {tagCount}개의 태그가 성공적으로 추가되었습니다", "tagsAddedSuccessfully": "{count}개의 {type}에 {tagCount}개의 태그가 성공적으로 추가되었습니다",
"tagsReplacedSuccessfully": "{count}개의 {type}의 태그가 {tagCount}개의 태그로 성공적으로 교체되었습니다", "tagsReplacedSuccessfully": "{count}개의 {type}의 태그가 {tagCount}개의 태그로 성공적으로 교체되었습니다",
"tagsAddFailed": "{count}개의 모델에 태그 추가에 실패했습니다", "tagsAddFailed": "{count}개의 모델에 태그 추가에 실패했습니다",
@@ -1490,6 +1491,8 @@
"metadataRefreshed": "메타데이터가 성공적으로 새로고침되었습니다", "metadataRefreshed": "메타데이터가 성공적으로 새로고침되었습니다",
"metadataRefreshFailed": "메타데이터 새로고침 실패: {message}", "metadataRefreshFailed": "메타데이터 새로고침 실패: {message}",
"metadataUpdateComplete": "메타데이터 업데이트 완료", "metadataUpdateComplete": "메타데이터 업데이트 완료",
"operationCancelled": "사용자에 의해 작업이 취소되었습니다",
"operationCancelledPartial": "작업이 취소되었습니다. {success}개 항목이 처리되었습니다.",
"metadataFetchFailed": "메타데이터 가져오기 실패: {message}", "metadataFetchFailed": "메타데이터 가져오기 실패: {message}",
"bulkMetadataCompleteAll": "모든 {count}개 {type}이(가) 성공적으로 새로고침되었습니다", "bulkMetadataCompleteAll": "모든 {count}개 {type}이(가) 성공적으로 새로고침되었습니다",
"bulkMetadataCompletePartial": "{total}개 중 {success}개 {type}이(가) 새로고침되었습니다", "bulkMetadataCompletePartial": "{total}개 중 {success}개 {type}이(가) 새로고침되었습니다",

View File

@@ -1370,6 +1370,7 @@
"verificationCompleteSuccess": "Проверка завершена. Все файлы подтверждены как дубликаты.", "verificationCompleteSuccess": "Проверка завершена. Все файлы подтверждены как дубликаты.",
"verificationFailed": "Не удалось проверить хеши: {message}", "verificationFailed": "Не удалось проверить хеши: {message}",
"noTagsToAdd": "Нет тегов для добавления", "noTagsToAdd": "Нет тегов для добавления",
"bulkTagsUpdating": "Обновление тегов для {count} модел(ей)...",
"tagsAddedSuccessfully": "Успешно добавлено {tagCount} тег(ов) к {count} {type}(ам)", "tagsAddedSuccessfully": "Успешно добавлено {tagCount} тег(ов) к {count} {type}(ам)",
"tagsReplacedSuccessfully": "Успешно заменены теги для {count} {type}(ов) на {tagCount} тег(ов)", "tagsReplacedSuccessfully": "Успешно заменены теги для {count} {type}(ов) на {tagCount} тег(ов)",
"tagsAddFailed": "Не удалось добавить теги к {count} модель(ям)", "tagsAddFailed": "Не удалось добавить теги к {count} модель(ям)",
@@ -1490,6 +1491,8 @@
"metadataRefreshed": "Метаданные успешно обновлены", "metadataRefreshed": "Метаданные успешно обновлены",
"metadataRefreshFailed": "Не удалось обновить метаданные: {message}", "metadataRefreshFailed": "Не удалось обновить метаданные: {message}",
"metadataUpdateComplete": "Обновление метаданных завершено", "metadataUpdateComplete": "Обновление метаданных завершено",
"operationCancelled": "Операция отменена пользователем",
"operationCancelledPartial": "Операция отменена. Обработано {success} элементов.",
"metadataFetchFailed": "Не удалось получить метаданные: {message}", "metadataFetchFailed": "Не удалось получить метаданные: {message}",
"bulkMetadataCompleteAll": "Успешно обновлены все {count} {type}s", "bulkMetadataCompleteAll": "Успешно обновлены все {count} {type}s",
"bulkMetadataCompletePartial": "Обновлено {success} из {total} {type}s", "bulkMetadataCompletePartial": "Обновлено {success} из {total} {type}s",

View File

@@ -1370,6 +1370,7 @@
"verificationCompleteSuccess": "验证完成。所有文件均为重复项。", "verificationCompleteSuccess": "验证完成。所有文件均为重复项。",
"verificationFailed": "验证哈希失败:{message}", "verificationFailed": "验证哈希失败:{message}",
"noTagsToAdd": "没有可添加的标签", "noTagsToAdd": "没有可添加的标签",
"bulkTagsUpdating": "正在更新 {count} 个模型的标签...",
"tagsAddedSuccessfully": "已成功为 {count} 个 {type} 添加 {tagCount} 个标签", "tagsAddedSuccessfully": "已成功为 {count} 个 {type} 添加 {tagCount} 个标签",
"tagsReplacedSuccessfully": "已成功为 {count} 个 {type} 替换为 {tagCount} 个标签", "tagsReplacedSuccessfully": "已成功为 {count} 个 {type} 替换为 {tagCount} 个标签",
"tagsAddFailed": "为 {count} 个模型添加标签失败", "tagsAddFailed": "为 {count} 个模型添加标签失败",
@@ -1490,6 +1491,8 @@
"metadataRefreshed": "元数据刷新成功", "metadataRefreshed": "元数据刷新成功",
"metadataRefreshFailed": "刷新元数据失败:{message}", "metadataRefreshFailed": "刷新元数据失败:{message}",
"metadataUpdateComplete": "元数据更新完成", "metadataUpdateComplete": "元数据更新完成",
"operationCancelled": "操作已由用户取消",
"operationCancelledPartial": "操作已取消。已处理 {success} 个项目。",
"metadataFetchFailed": "获取元数据失败:{message}", "metadataFetchFailed": "获取元数据失败:{message}",
"bulkMetadataCompleteAll": "全部 {count} 个 {type} 元数据刷新成功", "bulkMetadataCompleteAll": "全部 {count} 个 {type} 元数据刷新成功",
"bulkMetadataCompletePartial": "已刷新 {success}/{total} 个 {type} 元数据", "bulkMetadataCompletePartial": "已刷新 {success}/{total} 个 {type} 元数据",

View File

@@ -1370,6 +1370,7 @@
"verificationCompleteSuccess": "驗證完成。所有檔案均確認為重複項。", "verificationCompleteSuccess": "驗證完成。所有檔案均確認為重複項。",
"verificationFailed": "驗證雜湊失敗:{message}", "verificationFailed": "驗證雜湊失敗:{message}",
"noTagsToAdd": "沒有可新增的標籤", "noTagsToAdd": "沒有可新增的標籤",
"bulkTagsUpdating": "正在更新 {count} 個模型的標籤...",
"tagsAddedSuccessfully": "已成功將 {tagCount} 個標籤新增到 {count} 個 {type}", "tagsAddedSuccessfully": "已成功將 {tagCount} 個標籤新增到 {count} 個 {type}",
"tagsReplacedSuccessfully": "已成功以 {tagCount} 個標籤取代 {count} 個 {type} 的標籤", "tagsReplacedSuccessfully": "已成功以 {tagCount} 個標籤取代 {count} 個 {type} 的標籤",
"tagsAddFailed": "新增標籤到 {count} 個模型失敗", "tagsAddFailed": "新增標籤到 {count} 個模型失敗",
@@ -1490,6 +1491,8 @@
"metadataRefreshed": "metadata 已成功刷新", "metadataRefreshed": "metadata 已成功刷新",
"metadataRefreshFailed": "刷新 metadata 失敗:{message}", "metadataRefreshFailed": "刷新 metadata 失敗:{message}",
"metadataUpdateComplete": "metadata 更新完成", "metadataUpdateComplete": "metadata 更新完成",
"operationCancelled": "操作已由用戶取消",
"operationCancelledPartial": "操作已取消。已處理 {success} 個項目。",
"metadataFetchFailed": "取得 metadata 失敗:{message}", "metadataFetchFailed": "取得 metadata 失敗:{message}",
"bulkMetadataCompleteAll": "已成功刷新全部 {count} 個 {type}", "bulkMetadataCompleteAll": "已成功刷新全部 {count} 個 {type}",
"bulkMetadataCompletePartial": "已刷新 {success} / {total} 個 {type}", "bulkMetadataCompletePartial": "已刷新 {success} / {total} 個 {type}",

View File

@@ -618,6 +618,8 @@ class ModelQueryHandler:
try: try:
full_rebuild = request.query.get("full_rebuild", "false").lower() == "true" full_rebuild = request.query.get("full_rebuild", "false").lower() == "true"
await self._service.scan_models(force_refresh=True, rebuild_cache=full_rebuild) await self._service.scan_models(force_refresh=True, rebuild_cache=full_rebuild)
if self._service.scanner.is_cancelled():
return web.json_response({"status": "cancelled", "message": f"{self._service.model_type.capitalize()} scan cancelled"})
return web.json_response({"status": "success", "message": f"{self._service.model_type.capitalize()} scan completed"}) return web.json_response({"status": "success", "message": f"{self._service.model_type.capitalize()} scan completed"})
except Exception as exc: except Exception as exc:
self._logger.error("Error scanning %ss: %s", self._service.model_type, exc, exc_info=True) self._logger.error("Error scanning %ss: %s", self._service.model_type, exc, exc_info=True)
@@ -639,6 +641,14 @@ class ModelQueryHandler:
self._logger.error("Error getting folders: %s", exc) self._logger.error("Error getting folders: %s", exc)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def cancel_task(self, request: web.Request) -> web.Response:
try:
self._service.scanner.cancel_task()
return web.json_response({"status": "success", "message": "Cancellation requested"})
except Exception as exc:
self._logger.error("Error cancelling task for %s: %s", self._service.model_type, exc)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_folder_tree(self, request: web.Request) -> web.Response: async def get_folder_tree(self, request: web.Request) -> web.Response:
try: try:
model_root = request.query.get("model_root") model_root = request.query.get("model_root")
@@ -1262,6 +1272,8 @@ class ModelUpdateHandler:
force_refresh=force_refresh, force_refresh=force_refresh,
target_model_ids=target_model_ids or None, target_model_ids=target_model_ids or None,
) )
if self._service.scanner.is_cancelled():
return web.json_response({"success": False, "status": "cancelled", "message": "Update refresh cancelled"})
except RateLimitError as exc: except RateLimitError as exc:
return web.json_response( return web.json_response(
{"success": False, "error": str(exc) or "Rate limited"}, status=429 {"success": False, "error": str(exc) or "Rate limited"}, status=429
@@ -1678,4 +1690,5 @@ class ModelHandlerSet:
"set_version_update_ignore": self.updates.set_version_update_ignore, "set_version_update_ignore": self.updates.set_version_update_ignore,
"get_model_update_status": self.updates.get_model_update_status, "get_model_update_status": self.updates.get_model_update_status,
"get_model_versions": self.updates.get_model_versions, "get_model_versions": self.updates.get_model_versions,
"cancel_task": self.query.cancel_task,
} }

View File

@@ -68,6 +68,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"), RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"), RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
RouteDefinition("GET", "/api/lm/download-progress/{download_id}", "get_download_progress"), RouteDefinition("GET", "/api/lm/download-progress/{download_id}", "get_download_progress"),
RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
RouteDefinition("GET", "/{prefix}", "handle_models_page"), RouteDefinition("GET", "/{prefix}", "handle_models_page"),
) )

View File

@@ -36,11 +36,13 @@ class AutoOrganizeResult:
self.results_truncated: bool = False self.results_truncated: bool = False
self.sample_results: List[Dict[str, Any]] = [] self.sample_results: List[Dict[str, Any]] = []
self.is_flat_structure: bool = False self.is_flat_structure: bool = False
self.status: str = 'success'
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary""" """Convert result to dictionary"""
result = { result = {
'success': True, 'success': self.status != 'error',
'status': self.status,
'message': f'Auto-organize {self.operation_type} completed: {self.success_count} moved, {self.skipped_count} skipped, {self.failure_count} failed out of {self.total} total', 'message': f'Auto-organize {self.operation_type} completed: {self.success_count} moved, {self.skipped_count} skipped, {self.failure_count} failed out of {self.total} total',
'summary': { 'summary': {
'total': self.total, 'total': self.total,
@@ -98,6 +100,8 @@ class ModelFileService:
result = AutoOrganizeResult() result = AutoOrganizeResult()
source_directories: Set[str] = set() source_directories: Set[str] = set()
self.scanner.reset_cancellation()
try: try:
# Get all models from cache # Get all models from cache
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
@@ -187,6 +191,21 @@ class ModelFileService:
source_directories # Pass the set to track source directories source_directories # Pass the set to track source directories
) )
if self.scanner.is_cancelled():
result.status = 'cancelled'
if progress_callback:
await progress_callback.on_progress({
'type': 'auto_organize_progress',
'status': 'cancelled',
'total': result.total,
'processed': result.processed,
'success': result.success_count,
'failures': result.failure_count,
'skipped': result.skipped_count,
'operation_type': result.operation_type
})
return result
# Send cleanup progress # Send cleanup progress
if progress_callback: if progress_callback:
await progress_callback.on_progress({ await progress_callback.on_progress({
@@ -246,9 +265,15 @@ class ModelFileService:
"""Process models in batches to avoid overwhelming the system""" """Process models in batches to avoid overwhelming the system"""
for i in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE): for i in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE):
if self.scanner.is_cancelled():
logger.info(f"{self.model_type.capitalize()} File Service: Auto-organize cancelled by user")
break
batch = all_models[i:i + AUTO_ORGANIZE_BATCH_SIZE] batch = all_models[i:i + AUTO_ORGANIZE_BATCH_SIZE]
for model in batch: for model in batch:
if self.scanner.is_cancelled():
break
await self._process_single_model(model, model_roots, result, source_directories) await self._process_single_model(model, model_roots, result, source_directories)
result.processed += 1 result.processed += 1
@@ -535,8 +560,12 @@ class ModelMoveService:
""" """
try: try:
results = [] results = []
self.scanner.reset_cancellation()
for file_path in file_paths: for file_path in file_paths:
if self.scanner.is_cancelled():
logger.info(f"{self.model_type.capitalize()} Move Service: Bulk move cancelled by user")
break
result = await self.move_model(file_path, target_path, use_default_paths=use_default_paths) result = await self.move_model(file_path, target_path, use_default_paths=use_default_paths)
results.append({ results.append({
"original_file_path": file_path, "original_file_path": file_path,

View File

@@ -84,6 +84,7 @@ class ModelScanner:
self._excluded_models = [] # List to track excluded models self._excluded_models = [] # List to track excluded models
self._persistent_cache = get_persistent_cache() self._persistent_cache = get_persistent_cache()
self._name_display_mode = self._resolve_name_display_mode() self._name_display_mode = self._resolve_name_display_mode()
self._cancel_requested = False # Flag for cancellation
try: try:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
except RuntimeError: except RuntimeError:
@@ -678,6 +679,7 @@ class ModelScanner:
async def _reconcile_cache(self) -> None: async def _reconcile_cache(self) -> None:
"""Fast cache reconciliation - only process differences between cache and filesystem""" """Fast cache reconciliation - only process differences between cache and filesystem"""
self.reset_cancellation()
self._is_initializing = True # Set flag for reconciliation duration self._is_initializing = True # Set flag for reconciliation duration
try: try:
start_time = time.time() start_time = time.time()
@@ -737,6 +739,9 @@ class ModelScanner:
# Yield control periodically # Yield control periodically
await asyncio.sleep(0) await asyncio.sleep(0)
if self.is_cancelled():
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile scan cancelled")
return
# Process new files in batches # Process new files in batches
total_added = 0 total_added = 0
@@ -785,6 +790,10 @@ class ModelScanner:
except Exception as e: except Exception as e:
logger.error(f"Error adding {path} to cache: {e}") logger.error(f"Error adding {path} to cache: {e}")
if self.is_cancelled():
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile processing cancelled")
return
# Find missing files (in cache but not in filesystem) # Find missing files (in cache but not in filesystem)
missing_files = cached_paths - found_paths missing_files = cached_paths - found_paths
total_removed = 0 total_removed = 0
@@ -838,6 +847,19 @@ class ModelScanner:
"""Check if the scanner is currently initializing""" """Check if the scanner is currently initializing"""
return self._is_initializing return self._is_initializing
def cancel_task(self) -> None:
"""Request cancellation of the current long-running task."""
self._cancel_requested = True
logger.info(f"{self.model_type.capitalize()} Scanner: Cancellation requested")
def reset_cancellation(self) -> None:
"""Reset the cancellation flag."""
self._cancel_requested = False
def is_cancelled(self) -> bool:
"""Check if cancellation has been requested."""
return self._cancel_requested
def get_model_roots(self) -> List[str]: def get_model_roots(self) -> List[str]:
"""Get model root directories""" """Get model root directories"""
raise NotImplementedError("Subclasses must implement get_model_roots") raise NotImplementedError("Subclasses must implement get_model_roots")
@@ -1030,6 +1052,8 @@ class ModelScanner:
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error(f"Error reporting progress for {self.model_type}: {exc}") logger.error(f"Error reporting progress for {self.model_type}: {exc}")
self.reset_cancellation()
async def scan_recursive(current_path: str, root_path: str, visited_paths: Set[str]) -> None: async def scan_recursive(current_path: str, root_path: str, visited_paths: Set[str]) -> None:
nonlocal processed_files nonlocal processed_files
@@ -1073,6 +1097,8 @@ class ModelScanner:
await handle_progress() await handle_progress()
await asyncio.sleep(0) await asyncio.sleep(0)
if self.is_cancelled():
return
elif entry.is_dir(follow_symlinks=True): elif entry.is_dir(follow_symlinks=True):
await scan_recursive(entry.path, root_path, visited_paths) await scan_recursive(entry.path, root_path, visited_paths)
except Exception as entry_error: except Exception as entry_error:
@@ -1080,6 +1106,9 @@ class ModelScanner:
except Exception as scan_error: except Exception as scan_error:
logger.error(f"Error scanning {current_path}: {scan_error}") logger.error(f"Error scanning {current_path}: {scan_error}")
if self.is_cancelled():
return
for model_root in self.get_model_roots(): for model_root in self.get_model_roots():
if not os.path.exists(model_root): if not os.path.exists(model_root):
continue continue
@@ -1448,6 +1477,10 @@ class ModelScanner:
deleted_models = [] deleted_models = []
for file_path in file_paths: for file_path in file_paths:
if self.is_cancelled():
logger.info(f"{self.model_type.capitalize()} Scanner: Bulk delete cancelled by user")
break
try: try:
target_dir = os.path.dirname(file_path) target_dir = os.path.dirname(file_path)
base_name = os.path.basename(file_path) base_name = os.path.basename(file_path)
@@ -1488,6 +1521,7 @@ class ModelScanner:
return { return {
'success': True, 'success': True,
'status': 'cancelled' if self.is_cancelled() else 'success',
'total_deleted': total_deleted, 'total_deleted': total_deleted,
'total_attempted': len(file_paths), 'total_attempted': len(file_paths),
'cache_updated': cache_updated, 'cache_updated': cache_updated,

View File

@@ -466,6 +466,7 @@ class ModelUpdateService:
target_model_ids: Optional[Sequence[int]] = None, target_model_ids: Optional[Sequence[int]] = None,
) -> Dict[int, ModelUpdateRecord]: ) -> Dict[int, ModelUpdateRecord]:
"""Refresh update information for every model present in the cache.""" """Refresh update information for every model present in the cache."""
scanner.reset_cancellation()
normalized_targets = ( normalized_targets = (
self._normalize_sequence(target_model_ids) self._normalize_sequence(target_model_ids)
@@ -542,6 +543,9 @@ class ModelUpdateService:
force_refresh=force_refresh, force_refresh=force_refresh,
prefetched_response=prefetched.get(model_id), prefetched_response=prefetched.get(model_id),
) )
if scanner.is_cancelled():
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
return results
if record: if record:
results[model_id] = record results[model_id] = record
if index % progress_interval == 0 or index == total_models: if index % progress_interval == 0 or index == total_models:

View File

@@ -59,6 +59,8 @@ class BulkMetadataRefreshUseCase:
success = 0 success = 0
needs_resort = False needs_resort = False
self._service.scanner.reset_cancellation()
async def emit(status: str, **extra: Any) -> None: async def emit(status: str, **extra: Any) -> None:
if progress_callback is None: if progress_callback is None:
return return
@@ -69,6 +71,10 @@ class BulkMetadataRefreshUseCase:
await emit("started") await emit("started")
for model in to_process: for model in to_process:
if self._service.scanner.is_cancelled():
self._logger.info("Bulk metadata refresh cancelled by user")
await emit("cancelled", processed=processed, success=success)
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models}
try: try:
original_name = model.get("model_name") original_name = model.get("model_name")
await MetadataManager.hydrate_model_data(model) await MetadataManager.hydrate_model_data(model)

View File

@@ -60,6 +60,7 @@ export function getApiEndpoints(modelType) {
exclude: `/api/lm/${modelType}/exclude`, exclude: `/api/lm/${modelType}/exclude`,
rename: `/api/lm/${modelType}/rename`, rename: `/api/lm/${modelType}/rename`,
save: `/api/lm/${modelType}/save-metadata`, save: `/api/lm/${modelType}/save-metadata`,
cancelTask: `/api/lm/${modelType}/cancel-task`,
// Bulk operations // Bulk operations
bulkDelete: `/api/lm/${modelType}/bulk-delete`, bulkDelete: `/api/lm/${modelType}/bulk-delete`,

View File

@@ -82,6 +82,19 @@ export class BaseModelApiClient {
} }
} }
async cancelTask() {
try {
const endpoint = this.apiConfig.endpoints.cancelTask;
const response = await fetch(endpoint, {
method: 'POST'
});
return await response.json();
} catch (error) {
console.error(`Error cancelling task for ${this.modelType}:`, error);
return { success: false, error: error.message };
}
}
async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) { async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) {
const pageState = this.getPageState(); const pageState = this.getPageState();
@@ -336,9 +349,11 @@ export class BaseModelApiClient {
async refreshModels(fullRebuild = false) { async refreshModels(fullRebuild = false) {
try { try {
state.loadingManager.showSimpleLoading( state.loadingManager.show(
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...` `${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...`,
0
); );
state.loadingManager.showCancelButton(() => this.cancelTask());
const url = new URL(this.apiConfig.endpoints.scan, window.location.origin); const url = new URL(this.apiConfig.endpoints.scan, window.location.origin);
url.searchParams.append('full_rebuild', fullRebuild); url.searchParams.append('full_rebuild', fullRebuild);
@@ -349,6 +364,12 @@ export class BaseModelApiClient {
throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`); throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`);
} }
const data = await response.json();
if (data.status === 'cancelled') {
showToast('toast.api.operationCancelled', {}, 'info');
return;
}
resetAndReload(true); resetAndReload(true);
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success'); showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
@@ -402,6 +423,7 @@ export class BaseModelApiClient {
await state.loadingManager.showWithProgress(async (loading) => { await state.loadingManager.showWithProgress(async (loading) => {
try { try {
loading.showCancelButton(() => this.cancelTask());
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`); ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
@@ -427,7 +449,12 @@ export class BaseModelApiClient {
loading.setStatus( loading.setStatus(
`Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s` `Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s`
); );
resolve(); resolve(data);
break;
case 'cancelled':
loading.setStatus('Operation cancelled by user');
resolve(data); // Consider it complete but marked as cancelled
break; break;
case 'error': case 'error':
@@ -458,10 +485,14 @@ export class BaseModelApiClient {
} }
// Wait for the operation to complete via WebSocket // Wait for the operation to complete via WebSocket
await operationComplete; const finalData = await operationComplete;
resetAndReload(false); resetAndReload(false);
showToast('toast.api.metadataUpdateComplete', {}, 'success'); if (finalData && finalData.status === 'cancelled') {
showToast('toast.api.operationCancelledPartial', { success: finalData.success, total: finalData.total }, 'info');
} else {
showToast('toast.api.metadataUpdateComplete', {}, 'success');
}
} catch (error) { } catch (error) {
console.error('Error fetching metadata:', error); console.error('Error fetching metadata:', error);
showToast('toast.api.metadataFetchFailed', { message: error.message }, 'error'); showToast('toast.api.metadataFetchFailed', { message: error.message }, 'error');
@@ -487,9 +518,17 @@ export class BaseModelApiClient {
let failedItems = []; let failedItems = [];
const progressController = state.loadingManager.showEnhancedProgress('Starting metadata refresh...'); const progressController = state.loadingManager.showEnhancedProgress('Starting metadata refresh...');
let cancelled = false;
progressController.showCancelButton(() => {
cancelled = true;
this.cancelTask();
});
try { try {
for (let i = 0; i < filePaths.length; i++) { for (let i = 0; i < filePaths.length; i++) {
if (cancelled) {
break;
}
const filePath = filePaths[i]; const filePath = filePaths[i];
const fileName = filePath.split('/').pop(); const fileName = filePath.split('/').pop();
@@ -531,20 +570,15 @@ export class BaseModelApiClient {
} }
let completionMessage; let completionMessage;
if (successCount === totalItems) { if (cancelled) {
completionMessage = translate('toast.api.operationCancelledPartial', { success: successCount, total: totalItems }, `Operation cancelled. ${successCount} items processed.`);
showToast('toast.api.operationCancelledPartial', { success: successCount, total: totalItems }, 'info');
} else if (successCount === totalItems) {
completionMessage = translate('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, `Successfully refreshed all ${successCount} ${this.apiConfig.config.displayName}s`); completionMessage = translate('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, `Successfully refreshed all ${successCount} ${this.apiConfig.config.displayName}s`);
showToast('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, 'success'); showToast('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, 'success');
} else if (successCount > 0) { } else if (successCount > 0) {
completionMessage = translate('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, `Refreshed ${successCount} of ${totalItems} ${this.apiConfig.config.displayName}s`); completionMessage = translate('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, `Refreshed ${successCount} of ${totalItems} ${this.apiConfig.config.displayName}s`);
showToast('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, 'warning'); showToast('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, 'warning');
// if (failedItems.length > 0) {
// const failureMessage = failedItems.length <= 3
// ? failedItems.map(item => `${item.fileName}: ${item.error}`).join('\n')
// : failedItems.slice(0, 3).map(item => `${item.fileName}: ${item.error}`).join('\n') +
// `\n(and ${failedItems.length - 3} more)`;
// showToast('toast.api.bulkMetadataFailureDetails', { failures: failureMessage }, 'warning', 6000);
// }
} else { } else {
completionMessage = translate('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, `Failed to refresh metadata for any ${this.apiConfig.config.displayName}s`); completionMessage = translate('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, `Failed to refresh metadata for any ${this.apiConfig.config.displayName}s`);
showToast('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, 'error'); showToast('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, 'error');
@@ -574,28 +608,42 @@ export class BaseModelApiClient {
throw new Error('No model IDs provided'); throw new Error('No model IDs provided');
} }
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_ids: modelIds,
force
})
});
let payload = {};
try { try {
payload = await response.json(); state.loadingManager.show('Checking for updates...', 0);
state.loadingManager.showCancelButton(() => this.cancelTask());
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_ids: modelIds,
force
})
});
let payload = {};
try {
payload = await response.json();
} catch (error) {
console.warn('Unable to parse refresh updates response as JSON', error);
}
if (!response.ok || payload?.success !== true) {
if (payload?.status === 'cancelled') {
showToast('toast.api.operationCancelled', {}, 'info');
return null;
}
const message = payload?.error || response.statusText || 'Failed to refresh updates';
throw new Error(message);
}
return payload;
} catch (error) { } catch (error) {
console.warn('Unable to parse refresh updates response as JSON', error); console.error('Error refreshing updates for models:', error);
throw error;
} finally {
state.loadingManager.hide();
} }
if (!response.ok || payload?.success !== true) {
const message = payload?.error || response.statusText || 'Failed to refresh updates';
throw new Error(message);
}
return payload;
} }
async fetchCivitaiVersions(modelId, source = null) { async fetchCivitaiVersions(modelId, source = null) {
@@ -1016,6 +1064,7 @@ export class BaseModelApiClient {
try { try {
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`); state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`);
state.loadingManager.showCancelButton(() => this.cancelTask());
const response = await fetch(this.apiConfig.endpoints.bulkDelete, { const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
method: 'POST', method: 'POST',
@@ -1055,6 +1104,7 @@ export class BaseModelApiClient {
let ws = null; let ws = null;
await state.loadingManager.showWithProgress(async (loading) => { await state.loadingManager.showWithProgress(async (loading) => {
loading.showCancelButton(() => this.stopExampleImages());
try { try {
// Connect to WebSocket for progress updates // Connect to WebSocket for progress updates
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
@@ -1202,6 +1252,7 @@ export class BaseModelApiClient {
let ws = null; let ws = null;
await state.loadingManager.showWithProgress(async (loading) => { await state.loadingManager.showWithProgress(async (loading) => {
loading.showCancelButton(() => this.cancelTask());
try { try {
// Connect to WebSocket for progress updates // Connect to WebSocket for progress updates
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
@@ -1255,6 +1306,11 @@ export class BaseModelApiClient {
}, 1500); }, 1500);
break; break;
case 'cancelled':
loading.setStatus(translate('toast.api.operationCancelled', {}, 'Operation cancelled by user'));
resolve(data);
break;
case 'error': case 'error':
loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.error', { error: data.error }, `Error: ${data.error}`)); loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.error', { error: data.error }, `Error: ${data.error}`));
reject(new Error(data.error)); reject(new Error(data.error));
@@ -1299,7 +1355,9 @@ export class BaseModelApiClient {
const result = await operationComplete; const result = await operationComplete;
// Show appropriate success message based on results // Show appropriate success message based on results
if (result.failures === 0) { if (result.status === 'cancelled') {
showToast('toast.api.operationCancelledPartial', { success: result.success, total: result.total }, 'info');
} else if (result.failures === 0) {
showToast('toast.loras.autoOrganizeSuccess', { showToast('toast.loras.autoOrganizeSuccess', {
count: result.success, count: result.success,
type: result.operation_type === 'bulk' ? 'selected models' : 'all models' type: result.operation_type === 'bulk' ? 'selected models' : 'all models'
@@ -1326,4 +1384,17 @@ export class BaseModelApiClient {
completionMessage: translate('loras.bulkOperations.autoOrganizeProgress.complete', {}, 'Auto-organize complete') completionMessage: translate('loras.bulkOperations.autoOrganizeProgress.complete', {}, 'Auto-organize complete')
}); });
} }
async stopExampleImages() {
try {
const response = await fetch('/api/lm/stop-example-images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
return response.ok;
} catch (error) {
console.error('Error stopping example images:', error);
return false;
}
}
} }

View File

@@ -979,9 +979,19 @@ export class BulkManager {
const filePaths = Array.from(state.selectedModels); const filePaths = Array.from(state.selectedModels);
let successCount = 0; let successCount = 0;
let failCount = 0; let failCount = 0;
let cancelled = false;
state.loadingManager.showSimpleLoading(translate('toast.models.bulkTagsUpdating', { count: filePaths.length }));
state.loadingManager.showCancelButton(() => {
cancelled = true;
});
// Add or replace tags for each selected model based on mode // Add or replace tags for each selected model based on mode
for (const filePath of filePaths) { for (const filePath of filePaths) {
if (cancelled) {
showToast('toast.api.operationCancelled', {}, 'info');
break;
}
try { try {
if (mode === 'replace') { if (mode === 'replace') {
await apiClient.saveModelMetadata(filePath, { tags: tags }); await apiClient.saveModelMetadata(filePath, { tags: tags });
@@ -1140,6 +1150,10 @@ export class BulkManager {
const levelName = getNSFWLevelName(level); const levelName = getNSFWLevelName(level);
state.loadingManager.showSimpleLoading(translate('toast.models.bulkContentRatingUpdating', { count: totalCount })); state.loadingManager.showSimpleLoading(translate('toast.models.bulkContentRatingUpdating', { count: totalCount }));
let cancelled = false;
state.loadingManager.showCancelButton(() => {
cancelled = true;
});
let successCount = 0; let successCount = 0;
let failureCount = 0; let failureCount = 0;
@@ -1147,6 +1161,10 @@ export class BulkManager {
try { try {
const apiClient = getModelApiClient(); const apiClient = getModelApiClient();
for (const filePath of targets) { for (const filePath of targets) {
if (cancelled) {
showToast('toast.api.operationCancelled', {}, 'info');
break;
}
try { try {
await apiClient.saveModelMetadata(filePath, { preview_nsfw_level: level }); await apiClient.saveModelMetadata(filePath, { preview_nsfw_level: level });
successCount++; successCount++;
@@ -1232,10 +1250,18 @@ export class BulkManager {
let successCount = 0; let successCount = 0;
let errorCount = 0; let errorCount = 0;
const errors = []; const errors = [];
let cancelled = false;
state.loadingManager.showSimpleLoading(translate('toast.models.bulkBaseModelUpdating')); state.loadingManager.showSimpleLoading(translate('toast.models.bulkBaseModelUpdating'));
state.loadingManager.showCancelButton(() => {
cancelled = true;
});
for (const filepath of state.selectedModels) { for (const filepath of state.selectedModels) {
if (cancelled) {
showToast('toast.api.operationCancelled', {}, 'info');
break;
}
try { try {
await getModelApiClient().saveModelMetadata(filepath, { base_model: newBaseModel }); await getModelApiClient().saveModelMetadata(filepath, { base_model: newBaseModel });
successCount++; successCount++;
@@ -1402,9 +1428,9 @@ export class BulkManager {
// Check if card intersects with marquee rectangle // Check if card intersects with marquee rectangle
const intersects = !(rect.right < left || const intersects = !(rect.right < left ||
rect.left > right || rect.left > right ||
rect.bottom < top || rect.bottom < top ||
rect.top > bottom); rect.top > bottom);
const filepath = card.dataset.filepath; const filepath = card.dataset.filepath;

View File

@@ -39,6 +39,25 @@ export class LoadingManager {
this.loadingContent.appendChild(this.statusText); this.loadingContent.appendChild(this.statusText);
} }
this.cancelButton = this.loadingContent.querySelector('.loading-cancel');
if (!this.cancelButton) {
this.cancelButton = document.createElement('button');
this.cancelButton.className = 'loading-cancel secondary-btn';
this.cancelButton.style.display = 'none';
this.cancelButton.style.margin = 'var(--space-2) auto 0';
this.cancelButton.textContent = translate('common.actions.cancel', {}, 'Cancel');
this.loadingContent.appendChild(this.cancelButton);
}
this.onCancelCallback = null;
this.cancelButton.onclick = () => {
if (this.onCancelCallback) {
this.onCancelCallback();
this.cancelButton.disabled = true;
this.cancelButton.textContent = translate('common.status.loading', {}, 'Loading...');
}
};
this.detailsContainer = null; // Will be created when needed this.detailsContainer = null; // Will be created when needed
} }
@@ -70,9 +89,26 @@ export class LoadingManager {
this.setProgress(0); this.setProgress(0);
this.setStatus(''); this.setStatus('');
this.removeDetailsContainer(); this.removeDetailsContainer();
this.hideCancelButton();
this.progressBar.style.display = 'block'; this.progressBar.style.display = 'block';
} }
showCancelButton(onCancel) {
if (this.cancelButton) {
this.onCancelCallback = onCancel;
this.cancelButton.style.display = 'flex';
this.cancelButton.disabled = false;
this.cancelButton.textContent = translate('common.actions.cancel', {}, 'Cancel');
}
}
hideCancelButton() {
if (this.cancelButton) {
this.cancelButton.style.display = 'none';
this.onCancelCallback = null;
}
}
// Create a details container for enhanced progress display // Create a details container for enhanced progress display
createDetailsContainer() { createDetailsContainer() {
// Remove existing container if any // Remove existing container if any
@@ -238,7 +274,7 @@ export class LoadingManager {
overallLabel.textContent = `Overall progress (${currentIndex}/${totalItems} complete):`; overallLabel.textContent = `Overall progress (${currentIndex}/${totalItems} complete):`;
// Calculate and update overall progress // Calculate and update overall progress
const overallProgress = Math.floor((currentIndex + currentProgress/100) / totalItems * 100); const overallProgress = Math.floor((currentIndex + currentProgress / 100) / totalItems * 100);
this.setProgress(overallProgress); this.setProgress(overallProgress);
} else { } else {
// Single item, just update main progress // Single item, just update main progress
@@ -276,6 +312,10 @@ export class LoadingManager {
} }
}, },
showCancelButton: (onCancel) => {
this.showCancelButton(onCancel);
},
complete: async (completionMessage = 'Complete') => { complete: async (completionMessage = 'Complete') => {
this.setProgress(100); this.setProgress(100);
this.setStatus(completionMessage); this.setStatus(completionMessage);

View File

@@ -2,7 +2,7 @@ import { state } from '../state/index.js';
import { translate } from './i18nHelpers.js'; import { translate } from './i18nHelpers.js';
import { showToast } from './uiHelpers.js'; import { showToast } from './uiHelpers.js';
import { getCompleteApiConfig, getCurrentModelType } from '../api/apiConfig.js'; import { getCompleteApiConfig, getCurrentModelType } from '../api/apiConfig.js';
import { resetAndReload } from '../api/modelApiFactory.js'; import { resetAndReload, getModelApiClient } from '../api/modelApiFactory.js';
import { getStorageItem, setStorageItem } from './storageHelpers.js'; import { getStorageItem, setStorageItem } from './storageHelpers.js';
import { modalManager } from '../managers/ModalManager.js'; import { modalManager } from '../managers/ModalManager.js';
@@ -18,6 +18,7 @@ const CHECK_UPDATES_CONFIRMATION_KEY = 'ack_check_updates_for_all_models';
export async function performModelUpdateCheck({ onStart, onComplete } = {}) { export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
const modelType = getCurrentModelType(); const modelType = getCurrentModelType();
const apiConfig = getCompleteApiConfig(modelType); const apiConfig = getCompleteApiConfig(modelType);
const apiClient = getModelApiClient(modelType);
const displayName = apiConfig?.config?.displayName ?? 'Model'; const displayName = apiConfig?.config?.displayName ?? 'Model';
if (!apiConfig?.endpoints?.refreshUpdates) { if (!apiConfig?.endpoints?.refreshUpdates) {
@@ -41,6 +42,7 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
onStart?.({ displayName, loadingMessage }); onStart?.({ displayName, loadingMessage });
state.loadingManager?.showSimpleLoading?.(loadingMessage); state.loadingManager?.showSimpleLoading?.(loadingMessage);
state.loadingManager?.showCancelButton?.(() => apiClient.cancelTask());
let status = 'success'; let status = 'success';
let records = []; let records = [];
@@ -61,6 +63,10 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
} }
if (!response.ok || payload.success !== true) { if (!response.ok || payload.success !== true) {
if (payload?.status === 'cancelled') {
showToast('toast.api.operationCancelled', {}, 'info');
return { status: 'cancelled', displayName, records: [], error: null };
}
const errorMessage = payload?.error || response.statusText || 'Unknown error'; const errorMessage = payload?.error || response.statusText || 'Unknown error';
throw new Error(errorMessage); throw new Error(errorMessage);
} }

View File

@@ -168,6 +168,17 @@ class MockScanner:
self.updated_models: List[Dict[str, Any]] = [] self.updated_models: List[Dict[str, Any]] = []
self.preview_updates: List[Dict[str, Any]] = [] self.preview_updates: List[Dict[str, Any]] = []
self.bulk_deleted: List[Sequence[str]] = [] self.bulk_deleted: List[Sequence[str]] = []
self._cancelled = False
self.model_type = "test-model"
def is_cancelled(self) -> bool:
return self._cancelled
def cancel_task(self) -> None:
self._cancelled = True
def reset_cancellation(self) -> None:
self._cancelled = False
async def get_cached_data(self, force_refresh: bool = False): async def get_cached_data(self, force_refresh: bool = False):
return self._cache return self._cache

View File

@@ -14,6 +14,13 @@ from py.services.model_update_service import ModelUpdateRecord, ModelVersionReco
class DummyScanner: class DummyScanner:
def __init__(self, cache): def __init__(self, cache):
self._cache = cache self._cache = cache
self._cancelled = False
def is_cancelled(self) -> bool:
return self._cancelled
def reset_cancellation(self) -> None:
self._cancelled = False
async def get_cached_data(self): async def get_cached_data(self):
return self._cache return self._cache

View File

@@ -15,6 +15,13 @@ from py.services.model_update_service import (
class DummyScanner: class DummyScanner:
def __init__(self, raw_data): def __init__(self, raw_data):
self._cache = SimpleNamespace(raw_data=raw_data, version_index={}) self._cache = SimpleNamespace(raw_data=raw_data, version_index={})
self._cancelled = False
def is_cancelled(self) -> bool:
return self._cancelled
def reset_cancellation(self) -> None:
self._cancelled = False
async def get_cached_data(self, *args, **kwargs): async def get_cached_data(self, *args, **kwargs):
return self._cache return self._cache