mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
Compare commits
6 Commits
v1.2.2
..
e747946f7a
| Author | SHA1 | Date | |
|---|---|---|---|
| e747946f7a | |||
| 53fa22f39c | |||
| 82b34097fb | |||
| a7995db009 | |||
| 5ae4aef30e | |||
| 08023f0cd9 |
@@ -122,12 +122,8 @@ async def _save_hf_metadata(dest_path: str, repo: str, model_root: str) -> None:
|
|||||||
metadata._unknown_fields["hf_url"] = hf_url
|
metadata._unknown_fields["hf_url"] = hf_url
|
||||||
metadata.from_civitai = False # HF models are not from CivitAI
|
metadata.from_civitai = False # HF models are not from CivitAI
|
||||||
|
|
||||||
metadata_dict = metadata.to_dict()
|
|
||||||
if "trainedWords" in metadata_dict and not metadata_dict["trainedWords"]:
|
|
||||||
del metadata_dict["trainedWords"]
|
|
||||||
|
|
||||||
# 3. Save metadata atomically
|
# 3. Save metadata atomically
|
||||||
await MetadataManager.save_metadata(dest_path, metadata_dict)
|
await MetadataManager.save_metadata(dest_path, metadata)
|
||||||
logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
|
logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
|
||||||
|
|
||||||
# 4. Determine relative folder path for cache
|
# 4. Determine relative folder path for cache
|
||||||
|
|||||||
@@ -407,7 +407,6 @@ class AgentService:
|
|||||||
"base_model": metadata.get("base_model", ""),
|
"base_model": metadata.get("base_model", ""),
|
||||||
"tags": metadata.get("tags", []),
|
"tags": metadata.get("tags", []),
|
||||||
"modelDescription": metadata.get("modelDescription", ""),
|
"modelDescription": metadata.get("modelDescription", ""),
|
||||||
"trainedWords": metadata.get("trainedWords", []),
|
|
||||||
"sha256": (metadata.get("sha256") or "")[:16] + "..." if metadata.get("sha256") else "",
|
"sha256": (metadata.get("sha256") or "")[:16] + "..." if metadata.get("sha256") else "",
|
||||||
"size": metadata.get("size", 0),
|
"size": metadata.get("size", 0),
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-48
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -32,8 +33,26 @@ _catalog_cache: Optional[Dict[str, List[str]]] = None
|
|||||||
# ``{provider_id: {model_id: max_output_tokens}}``.
|
# ``{provider_id: {model_id: max_output_tokens}}``.
|
||||||
_model_output_limits: Dict[str, Dict[str, int]] = {}
|
_model_output_limits: Dict[str, Dict[str, int]] = {}
|
||||||
|
|
||||||
|
# Monotonic timestamp of the last failed catalog fetch (None = no failure
|
||||||
|
# yet). Failed fetches are negatively cached: further calls return the
|
||||||
|
# empty fallback without hitting the network until the cooldown elapses,
|
||||||
|
# so users on broken networks don't stall on every settings-modal open.
|
||||||
|
_catalog_last_failure: Optional[float] = None
|
||||||
|
_CATALOG_FAILURE_COOLDOWN = 600.0 # seconds
|
||||||
|
|
||||||
|
# Serializes catalog fetches so concurrent callers don't duplicate requests.
|
||||||
|
_catalog_lock = asyncio.Lock()
|
||||||
|
|
||||||
_CATALOG_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
_CATALOG_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
||||||
|
|
||||||
|
# Cloudflare serves brotli when the client advertises it, and brotli is a
|
||||||
|
# required dependency here — a corrupted br stream can crash the native
|
||||||
|
# decoder with a Windows access violation (issue #1099). Request gzip
|
||||||
|
# instead; zlib decompression is not affected and corrupt gzip data only
|
||||||
|
# raises ContentEncodingError (an aiohttp.ClientError subclass), which the
|
||||||
|
# exception handlers below already catch.
|
||||||
|
_NO_BROTLI_HEADERS = {"Accept-Encoding": "gzip, deflate"}
|
||||||
|
|
||||||
|
|
||||||
async def _load_model_catalog() -> Dict[str, List[str]]:
|
async def _load_model_catalog() -> Dict[str, List[str]]:
|
||||||
"""Fetch and parse the model catalog.
|
"""Fetch and parse the model catalog.
|
||||||
@@ -46,61 +65,85 @@ async def _load_model_catalog() -> Dict[str, List[str]]:
|
|||||||
value has a ``models`` sub-dict keyed by model ID. The result is cached
|
value has a ``models`` sub-dict keyed by model ID. The result is cached
|
||||||
in memory after the first successful fetch.
|
in memory after the first successful fetch.
|
||||||
Subsequent calls return the cached data immediately.
|
Subsequent calls return the cached data immediately.
|
||||||
|
|
||||||
|
Failed fetches are negatively cached: further calls return an empty
|
||||||
|
dict without hitting the network until ``_CATALOG_FAILURE_COOLDOWN``
|
||||||
|
has elapsed, so a broken network does not stall every settings-modal
|
||||||
|
open. Concurrent callers are serialized behind :data:`_catalog_lock`
|
||||||
|
so only one request is ever in flight.
|
||||||
"""
|
"""
|
||||||
global _catalog_cache, _model_output_limits
|
global _catalog_cache, _model_output_limits, _catalog_last_failure
|
||||||
if _catalog_cache is not None:
|
if _catalog_cache is not None:
|
||||||
return _catalog_cache
|
return _catalog_cache
|
||||||
|
|
||||||
try:
|
async with _catalog_lock:
|
||||||
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
|
# Re-check under the lock: another caller may have fetched (or
|
||||||
async with session.get(_MODEL_CATALOG_URL) as resp:
|
# failed) while we were waiting.
|
||||||
if resp.status != 200:
|
if _catalog_cache is not None:
|
||||||
logger.warning("Model catalog returned HTTP %s", resp.status)
|
return _catalog_cache
|
||||||
return _catalog_cache or {}
|
if (
|
||||||
data = await resp.json()
|
_catalog_last_failure is not None
|
||||||
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
and time.monotonic() - _catalog_last_failure < _CATALOG_FAILURE_COOLDOWN
|
||||||
logger.warning("Failed to fetch model catalog: %s", exc)
|
):
|
||||||
return _catalog_cache or {}
|
logger.debug(
|
||||||
|
"Skipping model catalog fetch: last attempt failed %.0fs ago",
|
||||||
|
time.monotonic() - _catalog_last_failure,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
try:
|
||||||
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
|
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
|
||||||
return _catalog_cache or {}
|
async with session.get(_MODEL_CATALOG_URL, headers=_NO_BROTLI_HEADERS) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
logger.warning("Model catalog returned HTTP %s", resp.status)
|
||||||
|
_catalog_last_failure = time.monotonic()
|
||||||
|
return {}
|
||||||
|
data = await resp.json()
|
||||||
|
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||||
|
logger.warning("Failed to fetch model catalog: %s", exc)
|
||||||
|
_catalog_last_failure = time.monotonic()
|
||||||
|
return {}
|
||||||
|
|
||||||
result: Dict[str, List[str]] = {}
|
if not isinstance(data, dict):
|
||||||
output_limits: Dict[str, Dict[str, int]] = {}
|
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
|
||||||
for provider_id, provider_info in data.items():
|
_catalog_last_failure = time.monotonic()
|
||||||
if not isinstance(provider_info, dict):
|
return {}
|
||||||
continue
|
|
||||||
models_dict = provider_info.get("models")
|
result: Dict[str, List[str]] = {}
|
||||||
if not isinstance(models_dict, dict):
|
output_limits: Dict[str, Dict[str, int]] = {}
|
||||||
continue
|
for provider_id, provider_info in data.items():
|
||||||
model_ids: List[str] = []
|
if not isinstance(provider_info, dict):
|
||||||
provider_limits: Dict[str, int] = {}
|
|
||||||
for mid, model_info in models_dict.items():
|
|
||||||
if not isinstance(mid, str):
|
|
||||||
continue
|
continue
|
||||||
model_ids.append(mid)
|
models_dict = provider_info.get("models")
|
||||||
if isinstance(model_info, dict):
|
if not isinstance(models_dict, dict):
|
||||||
limit = model_info.get("limit")
|
continue
|
||||||
if isinstance(limit, dict):
|
model_ids: List[str] = []
|
||||||
output = limit.get("output")
|
provider_limits: Dict[str, int] = {}
|
||||||
if isinstance(output, (int, float)) and output > 0:
|
for mid, model_info in models_dict.items():
|
||||||
provider_limits[mid] = int(output)
|
if not isinstance(mid, str):
|
||||||
if model_ids:
|
continue
|
||||||
result[provider_id] = model_ids
|
model_ids.append(mid)
|
||||||
if provider_limits:
|
if isinstance(model_info, dict):
|
||||||
output_limits[provider_id] = provider_limits
|
limit = model_info.get("limit")
|
||||||
|
if isinstance(limit, dict):
|
||||||
|
output = limit.get("output")
|
||||||
|
if isinstance(output, (int, float)) and output > 0:
|
||||||
|
provider_limits[mid] = int(output)
|
||||||
|
if model_ids:
|
||||||
|
result[provider_id] = model_ids
|
||||||
|
if provider_limits:
|
||||||
|
output_limits[provider_id] = provider_limits
|
||||||
|
|
||||||
_catalog_cache = result
|
_catalog_cache = result
|
||||||
_model_output_limits = output_limits
|
_model_output_limits = output_limits
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Loaded model catalog: %d providers, %d total models "
|
"Loaded model catalog: %d providers, %d total models "
|
||||||
"(%d providers have output limits)",
|
"(%d providers have output limits)",
|
||||||
len(result),
|
len(result),
|
||||||
sum(len(m) for m in result.values()),
|
sum(len(m) for m in result.values()),
|
||||||
len(output_limits),
|
len(output_limits),
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _get_model_max_output(provider: str, model: str) -> Optional[int]:
|
def _get_model_max_output(provider: str, model: str) -> Optional[int]:
|
||||||
@@ -126,7 +169,7 @@ async def fetch_ollama_models(api_base: str) -> List[str]:
|
|||||||
url = f"{api_base.rstrip('/')}/models"
|
url = f"{api_base.rstrip('/')}/models"
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession(timeout=_OLLAMA_API_TIMEOUT) as session:
|
async with aiohttp.ClientSession(timeout=_OLLAMA_API_TIMEOUT) as session:
|
||||||
async with session.get(url) as resp:
|
async with session.get(url, headers=_NO_BROTLI_HEADERS) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base)
|
logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base)
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -77,9 +77,6 @@ class BaseModelMetadata:
|
|||||||
last_checked_at: float = 0 # Last checked timestamp
|
last_checked_at: float = 0 # Last checked timestamp
|
||||||
hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed
|
hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed
|
||||||
autov3: Optional[str] = None # CivitAI AutoV3 hash (12-char lowercase hex); "" = checked but unavailable, None = not checked
|
autov3: Optional[str] = None # CivitAI AutoV3 hash (12-char lowercase hex); "" = checked but unavailable, None = not checked
|
||||||
trainedWords: List[str] = field(
|
|
||||||
default_factory=list
|
|
||||||
) # Trigger words / activation prompts (source-agnostic)
|
|
||||||
_unknown_fields: Dict[str, Any] = field(
|
_unknown_fields: Dict[str, Any] = field(
|
||||||
default_factory=dict, repr=False, compare=False
|
default_factory=dict, repr=False, compare=False
|
||||||
) # Store unknown fields
|
) # Store unknown fields
|
||||||
@@ -92,9 +89,6 @@ class BaseModelMetadata:
|
|||||||
if self.tags is None:
|
if self.tags is None:
|
||||||
self.tags = []
|
self.tags = []
|
||||||
|
|
||||||
if self.trainedWords is None:
|
|
||||||
self.trainedWords = []
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: Dict[str, Any]) -> "BaseModelMetadata":
|
def from_dict(cls, data: Dict[str, Any]) -> "BaseModelMetadata":
|
||||||
"""Create instance from dictionary"""
|
"""Create instance from dictionary"""
|
||||||
|
|||||||
@@ -44,6 +44,13 @@
|
|||||||
pointer-events: auto !important;
|
pointer-events: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keep the fixed-position sidebar anchored when highlighted, otherwise
|
||||||
|
.onboarding-target-highlight's position: relative would pull it into
|
||||||
|
normal flow and it would move away from the spotlight cutout */
|
||||||
|
.folder-sidebar.onboarding-target-highlight {
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
.onboarding-popup {
|
.onboarding-popup {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ def evaluate_model(
|
|||||||
flagged issues.
|
flagged issues.
|
||||||
"""
|
"""
|
||||||
civitai = metadata.get("civitai") or {}
|
civitai = metadata.get("civitai") or {}
|
||||||
trained_words: List[str] = civitai.get("trainedWords") or metadata.get("trainedWords") or []
|
trained_words: List[str] = civitai.get("trainedWords") or []
|
||||||
short_desc: str = civitai.get("description") or ""
|
short_desc: str = civitai.get("description") or ""
|
||||||
tags: List[str] = metadata.get("tags") or []
|
tags: List[str] = metadata.get("tags") or []
|
||||||
notes: str = metadata.get("notes") or ""
|
notes: str = metadata.get("notes") or ""
|
||||||
|
|||||||
@@ -149,7 +149,6 @@ def create_initial_metadata(
|
|||||||
"metadata_source": "",
|
"metadata_source": "",
|
||||||
"last_checked_at": 0,
|
"last_checked_at": 0,
|
||||||
"hash_status": "completed",
|
"hash_status": "completed",
|
||||||
"trainedWords": [],
|
|
||||||
"hf_url": hf_url,
|
"hf_url": hf_url,
|
||||||
"usage_tips": "{}",
|
"usage_tips": "{}",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
API_MODULE,
|
||||||
|
APP_MODULE,
|
||||||
|
AUTOCOMPLETE_MODULE,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
|
||||||
|
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
|
||||||
|
AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(API_MODULE, () => ({
|
||||||
|
api: {
|
||||||
|
fetchApi: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(APP_MODULE, () => ({
|
||||||
|
app: {
|
||||||
|
canvas: {
|
||||||
|
ds: { scale: 1 },
|
||||||
|
},
|
||||||
|
extensionManager: {
|
||||||
|
setting: {
|
||||||
|
get: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
registerExtension: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('formatAutocompleteTextOnBlur', () => {
|
||||||
|
it('preserves repeated spaces inside LoRA names', async () => {
|
||||||
|
const { formatAutocompleteTextOnBlur } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
|
||||||
|
expect(formatAutocompleteTextOnBlur('<lora:test - 0021:1.00>')).toBe(
|
||||||
|
'<lora:test - 0021:1.00>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves repeated spaces across multiple LoRA entries', async () => {
|
||||||
|
const { formatAutocompleteTextOnBlur } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
formatAutocompleteTextOnBlur('<lora:test - 0021:1.00>,<lora:a b:0.50>')
|
||||||
|
).toBe('<lora:test - 0021:1.00>, <lora:a b:0.50>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still normalizes whitespace outside LoRA tags', async () => {
|
||||||
|
const { formatAutocompleteTextOnBlur } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
|
||||||
|
expect(formatAutocompleteTextOnBlur('masterpiece, best quality')).toBe(
|
||||||
|
'masterpiece, best quality'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,6 +40,15 @@ describe("applyLoraValuesToText", () => {
|
|||||||
|
|
||||||
expect(result).toBe("<lora:Expanded:1.00:1.00>");
|
expect(result).toBe("<lora:Expanded:1.00:1.00>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves repeated spaces inside LoRA names", () => {
|
||||||
|
const original = "<lora:test - 0021:1.00>";
|
||||||
|
const result = applyLoraValuesToText(original, [
|
||||||
|
{ name: "test - 0021", strength: 0.5 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toBe("<lora:test - 0021:0.50>");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("normalizeStrengthValue", () => {
|
describe("normalizeStrengthValue", () => {
|
||||||
@@ -74,6 +83,18 @@ describe("cleanupLoraSyntax", () => {
|
|||||||
it("collapses whitespace and stray commas", () => {
|
it("collapses whitespace and stray commas", () => {
|
||||||
expect(cleanupLoraSyntax(" <lora:A:1.00> , ," )).toBe("<lora:A:1.00>");
|
expect(cleanupLoraSyntax(" <lora:A:1.00> , ," )).toBe("<lora:A:1.00>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves repeated spaces inside LoRA names", () => {
|
||||||
|
expect(cleanupLoraSyntax("<lora:test - 0021:1.00> , ,")).toBe(
|
||||||
|
"<lora:test - 0021:1.00>"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still normalizes whitespace between entries", () => {
|
||||||
|
expect(
|
||||||
|
cleanupLoraSyntax(" <lora:A:1.00> <lora:test - 0021:0.50> ")
|
||||||
|
).toBe("<lora:A:1.00> <lora:test - 0021:0.50>");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("debounce", () => {
|
describe("debounce", () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -322,8 +323,12 @@ class MockGetSession:
|
|||||||
|
|
||||||
def __init__(self, response):
|
def __init__(self, response):
|
||||||
self._response = response
|
self._response = response
|
||||||
|
self.last_url = None
|
||||||
|
self.last_headers = None
|
||||||
|
|
||||||
def get(self, url):
|
def get(self, url, headers=None):
|
||||||
|
self.last_url = url
|
||||||
|
self.last_headers = headers
|
||||||
return self._response
|
return self._response
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
@@ -340,6 +345,14 @@ class CorruptJsonResponse(MockResponse):
|
|||||||
raise UnicodeDecodeError("utf-8", b"\x9a", 0, 1, "invalid start byte")
|
raise UnicodeDecodeError("utf-8", b"\x9a", 0, 1, "invalid start byte")
|
||||||
|
|
||||||
|
|
||||||
|
class SlowResponse(MockResponse):
|
||||||
|
"""Response whose body takes a moment to read, to force contention."""
|
||||||
|
|
||||||
|
async def json(self):
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return self._json_data
|
||||||
|
|
||||||
|
|
||||||
class TestModelCatalog:
|
class TestModelCatalog:
|
||||||
"""Tests for _load_model_catalog / fetch_ollama_models error handling."""
|
"""Tests for _load_model_catalog / fetch_ollama_models error handling."""
|
||||||
|
|
||||||
@@ -348,9 +361,11 @@ class TestModelCatalog:
|
|||||||
"""Reset the module-level catalog cache around each test."""
|
"""Reset the module-level catalog cache around each test."""
|
||||||
llm_module._catalog_cache = None
|
llm_module._catalog_cache = None
|
||||||
llm_module._model_output_limits = {}
|
llm_module._model_output_limits = {}
|
||||||
|
llm_module._catalog_last_failure = None
|
||||||
yield
|
yield
|
||||||
llm_module._catalog_cache = None
|
llm_module._catalog_cache = None
|
||||||
llm_module._model_output_limits = {}
|
llm_module._model_output_limits = {}
|
||||||
|
llm_module._catalog_last_failure = None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_load_model_catalog_falls_back_on_unicode_decode_error(self):
|
async def test_load_model_catalog_falls_back_on_unicode_decode_error(self):
|
||||||
@@ -373,3 +388,86 @@ class TestModelCatalog:
|
|||||||
models = await fetch_ollama_models("http://localhost:11434/v1")
|
models = await fetch_ollama_models("http://localhost:11434/v1")
|
||||||
|
|
||||||
assert models == []
|
assert models == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_catalog_request_disables_brotli_encoding(self):
|
||||||
|
"""The catalog request must not advertise br — a corrupt brotli stream
|
||||||
|
can crash the native decoder (Windows access violation, issue #1099)."""
|
||||||
|
response = MockResponse(200, json_data={})
|
||||||
|
session = MockGetSession(response)
|
||||||
|
|
||||||
|
with mock.patch("aiohttp.ClientSession", return_value=session):
|
||||||
|
await llm_module._load_model_catalog()
|
||||||
|
|
||||||
|
assert session.last_headers == {"Accept-Encoding": "gzip, deflate"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ollama_request_disables_brotli_encoding(self):
|
||||||
|
"""The Ollama models request must not advertise br either."""
|
||||||
|
response = MockResponse(200, json_data={"data": [{"id": "llama3"}]})
|
||||||
|
session = MockGetSession(response)
|
||||||
|
|
||||||
|
with mock.patch("aiohttp.ClientSession", return_value=session):
|
||||||
|
models = await fetch_ollama_models("http://localhost:11434/v1")
|
||||||
|
|
||||||
|
assert models == ["llama3"]
|
||||||
|
assert session.last_headers == {"Accept-Encoding": "gzip, deflate"}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_fetch_is_negatively_cached(self):
|
||||||
|
"""A failed fetch is not retried until the cooldown elapses."""
|
||||||
|
created = []
|
||||||
|
|
||||||
|
def factory(*args, **kwargs):
|
||||||
|
session = MockGetSession(MockResponse(500, text_data="error"))
|
||||||
|
created.append(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
with mock.patch("aiohttp.ClientSession", side_effect=factory):
|
||||||
|
first = await llm_module._load_model_catalog()
|
||||||
|
second = await llm_module._load_model_catalog()
|
||||||
|
|
||||||
|
assert first == {}
|
||||||
|
assert second == {}
|
||||||
|
assert len(created) == 1
|
||||||
|
assert llm_module._catalog_last_failure is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_retries_after_cooldown(self):
|
||||||
|
"""Once the cooldown elapses, the next call fetches again."""
|
||||||
|
bad = MockGetSession(MockResponse(500, text_data="error"))
|
||||||
|
with mock.patch("aiohttp.ClientSession", return_value=bad):
|
||||||
|
assert await llm_module._load_model_catalog() == {}
|
||||||
|
|
||||||
|
# Simulate the cooldown having elapsed.
|
||||||
|
llm_module._catalog_last_failure = (
|
||||||
|
time.monotonic() - llm_module._CATALOG_FAILURE_COOLDOWN - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
good = MockGetSession(
|
||||||
|
MockResponse(200, json_data={"openai": {"models": {"gpt-4o": {}}}})
|
||||||
|
)
|
||||||
|
with mock.patch("aiohttp.ClientSession", return_value=good):
|
||||||
|
catalog = await llm_module._load_model_catalog()
|
||||||
|
|
||||||
|
assert catalog == {"openai": ["gpt-4o"]}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_concurrent_fetches_are_deduplicated(self):
|
||||||
|
"""Concurrent callers share a single in-flight fetch."""
|
||||||
|
created = []
|
||||||
|
|
||||||
|
def factory(*args, **kwargs):
|
||||||
|
session = MockGetSession(
|
||||||
|
SlowResponse(200, json_data={"openai": {"models": {"gpt-4o": {}}}})
|
||||||
|
)
|
||||||
|
created.append(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
with mock.patch("aiohttp.ClientSession", side_effect=factory):
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(llm_module._load_model_catalog() for _ in range(3))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(created) == 1
|
||||||
|
assert all(r == {"openai": ["gpt-4o"]} for r in results)
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ class TestEnrichHfMetadata:
|
|||||||
skill_name="enrich_hf_metadata",
|
skill_name="enrich_hf_metadata",
|
||||||
model_path="/p.safetensors",
|
model_path="/p.safetensors",
|
||||||
llm_output=llm,
|
llm_output=llm,
|
||||||
metadata={"trainedWords": []},
|
metadata={},
|
||||||
)
|
)
|
||||||
applied = mock_apply.call_args[0][1]
|
applied = mock_apply.call_args[0][1]
|
||||||
assert applied["civitai"]["trainedWords"] == ["trigger1", "trigger2"]
|
assert applied["civitai"]["trainedWords"] == ["trigger1", "trigger2"]
|
||||||
|
|||||||
@@ -227,8 +227,20 @@ function formatAutocompleteInsertion(text = '') {
|
|||||||
return getAutocompleteAppendCommaPreference() ? `${trimmed},` : `${trimmed} `;
|
return getAutocompleteAppendCommaPreference() ? `${trimmed},` : `${trimmed} `;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matches a complete <lora:name:strength[:clip_strength]> tag. Kept
|
||||||
|
// permissive on the strength fields (mirrors the backend parser) so tags
|
||||||
|
// are still protected while the user is mid-edit.
|
||||||
|
const LORA_TAG_PATTERN = /(<lora:[^:>]+:[^:>]+(?::[^:>]+)?>)/gi;
|
||||||
|
|
||||||
function normalizeAutocompleteSegment(segment = '') {
|
function normalizeAutocompleteSegment(segment = '') {
|
||||||
return segment.replace(/\s+/g, ' ').trim();
|
// Collapse whitespace only outside <lora:...> tags: names inside the tags
|
||||||
|
// may legitimately contain repeated spaces (e.g. "test - 0021"), and
|
||||||
|
// collapsing them breaks file resolution at runtime.
|
||||||
|
return segment
|
||||||
|
.split(LORA_TAG_PATTERN)
|
||||||
|
.map((part, index) => (index % 2 === 1 ? part : part.replace(/\s+/g, ' ')))
|
||||||
|
.join('')
|
||||||
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAutocompleteTextOnBlur(text = '') {
|
export function formatAutocompleteTextOnBlur(text = '') {
|
||||||
|
|||||||
@@ -38,7 +38,17 @@ function cleanupLoraSyntax(text) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
let cleaned = text
|
// Protect <lora:...> tags with placeholders before cleanup: names inside
|
||||||
|
// the tags may legitimately contain repeated spaces or commas (e.g.
|
||||||
|
// "test - 0021"), and collapsing them breaks file resolution at runtime.
|
||||||
|
const protectedTags = [];
|
||||||
|
LORA_PATTERN.lastIndex = 0;
|
||||||
|
const masked = text.replace(LORA_PATTERN, (match) => {
|
||||||
|
protectedTags.push(match);
|
||||||
|
return `\u0000${protectedTags.length - 1}\u0000`;
|
||||||
|
});
|
||||||
|
|
||||||
|
let cleaned = masked
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.replace(/,\s*,+/g, ",")
|
.replace(/,\s*,+/g, ",")
|
||||||
.replace(/\s*,\s*/g, ",")
|
.replace(/\s*,\s*/g, ",")
|
||||||
@@ -51,7 +61,9 @@ function cleanupLoraSyntax(text) {
|
|||||||
cleaned = cleaned.replace(/(^,)|(,$)/g, "");
|
cleaned = cleaned.replace(/(^,)|(,$)/g, "");
|
||||||
cleaned = cleaned.replace(/,\s*/g, ", ");
|
cleaned = cleaned.replace(/,\s*/g, ", ");
|
||||||
|
|
||||||
return cleaned.trim();
|
return cleaned
|
||||||
|
.trim()
|
||||||
|
.replace(/\u0000(\d+)\u0000/g, (_, index) => protectedTags[Number(index)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyLoraValuesToText(originalText, loras) {
|
export function applyLoraValuesToText(originalText, loras) {
|
||||||
|
|||||||
Reference in New Issue
Block a user