feat: Enhance Qwen3.8 model integration and duration handling
- Added Qwen3.8-27B-ABLITERATED-GGUF model to the model catalog with detailed specifications and context length. - Improved duration widget behavior to ensure it remains hidden for non-video presets and preserves last valid values. - Updated documentation to clarify duration handling and widget visibility. - Expanded test coverage for model catalog labels and Qwen3.8 model presence in both text and vision catalogs. - Refactored model access functions to better manage installed models and local paths. - Enhanced tooltips and recommendations for model selection, emphasizing installed and local models. - Fixed issues with dynamic duration-widget serialization and visibility in the frontend.
This commit is contained in:
+5
-14
@@ -60,6 +60,7 @@ from thinkingllm_core.media import (
|
||||
from thinkingllm_core.model_access import (
|
||||
download_hf_file_to_path,
|
||||
enforce_model_access,
|
||||
mark_catalog_files_installed,
|
||||
model_catalog_options,
|
||||
normalize_commercial_status,
|
||||
resolve_model_catalog_name,
|
||||
@@ -181,7 +182,7 @@ def _quant_from_filename(filename: str) -> str | None:
|
||||
"""Extract quantization level from a GGUF filename stem.
|
||||
E.g. 'gemma-4-E4B-it-Q4_K_M.gguf' -> 'Q4'."""
|
||||
name = Path(filename).stem.upper()
|
||||
for q in ('BF16', 'F16', 'F32', 'Q8_0', 'Q6_K', 'Q5_K_M', 'Q5_K_S', 'Q4_K_M', 'Q4_K_S', 'Q3_K_M', 'Q2_K'):
|
||||
for q in ('BF16', 'F16', 'F32', 'Q8_0', 'Q6_K', 'Q5_K_M', 'Q5_K_S', 'Q4_K_M', 'Q4_K_S', 'Q3_K_M', 'Q3_K_S', 'Q2_K'):
|
||||
if q in name:
|
||||
return _QUANT_CANONICAL.get(q, q)
|
||||
return None
|
||||
@@ -191,7 +192,7 @@ _QUANT_CANONICAL = {
|
||||
'Q8_0': 'Q8', 'Q6_K': 'Q6',
|
||||
'Q5_K_M': 'Q5', 'Q5_K_S': 'Q5',
|
||||
'Q4_K_M': 'Q4', 'Q4_K_S': 'Q4',
|
||||
'Q3_K_M': 'Q3', 'Q2_K': 'Q2',
|
||||
'Q3_K_M': 'Q3', 'Q3_K_S': 'Q3', 'Q2_K': 'Q2',
|
||||
}
|
||||
|
||||
import re
|
||||
@@ -582,17 +583,7 @@ def _load_gguf_vl_catalog():
|
||||
|
||||
# Mark catalog entries already present on disk, then add uncatalogued local models.
|
||||
search_dirs = _gguf_search_dirs(base_dir)
|
||||
installed_filenames: set[str] = set()
|
||||
for directory in search_dirs:
|
||||
try:
|
||||
installed_filenames.update(path.name for path in directory.rglob("*.gguf") if path.is_file())
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
for display, entry in list(flattened.items()):
|
||||
filenames = _filename_search_candidates(entry.get("filename", ""), entry.get("local_filenames"))
|
||||
if any(filename in installed_filenames for filename in filenames):
|
||||
flattened.pop(display)
|
||||
flattened[f"{display} [installed]"] = {**entry, "catalog_display_name": display}
|
||||
mark_catalog_files_installed(flattened, search_dirs)
|
||||
|
||||
existing_filenames = {Path(e.get("filename", "")).name for e in flattened.values() if e.get("filename")}
|
||||
for scan_dir in search_dirs:
|
||||
@@ -607,7 +598,7 @@ def _load_gguf_vl_catalog():
|
||||
GGUF_VL_CATALOG = _load_gguf_vl_catalog()
|
||||
|
||||
GGUF_TOOLTIPS = {
|
||||
"model_name": "GGUF vision model from gguf_models.json or auto-detected local files. [installed] means the catalog model file was found on disk; [local] means an uncatalogued local model. Missing GGUF or mmproj files are downloaded on first use.",
|
||||
"model_name": "GGUF vision model from gguf_models.json or auto-detected local files. [installed] means the catalog model was found in a configured GGUF/LLM location; [local] means an uncatalogued local model. Missing GGUF or mmproj files are downloaded on first use.",
|
||||
"audio_model_name": "Gemma 4 audio-capable GGUF model. Only Gemma 4 E2B, E4B, and 12B are listed; 26B/31B variants are image/text-only for this purpose.",
|
||||
"audio_file_path": "Optional local audio file path. M4A, MP3, WAV, FLAC, and other FFmpeg-readable files are decoded to 16 kHz mono WAV before inference.",
|
||||
"device": "auto prefers CUDA when PyTorch sees an NVIDIA GPU. If RAW_TRACE says GPU offload is no or unknown, verify your llama-cpp-python CUDA wheel before blaming the model.",
|
||||
|
||||
@@ -46,6 +46,7 @@ from thinkingllm_core.hf_models import ensure_cuda_vram_headroom
|
||||
from thinkingllm_core.model_access import (
|
||||
download_hf_file_to_path,
|
||||
enforce_model_access,
|
||||
mark_catalog_files_installed,
|
||||
model_catalog_options,
|
||||
resolve_model_catalog_name,
|
||||
)
|
||||
@@ -85,7 +86,7 @@ from AILab_QwenVL_GGUF import (
|
||||
|
||||
|
||||
GGUF_PROMPT_TOOLTIPS = {
|
||||
"model_name": "GGUF text model from config or auto-detected local files. First run downloads the selected GGUF when it is not already on disk.",
|
||||
"model_name": "GGUF text model from config or auto-detected local files. [installed] means the catalog model was found in a configured GGUF/LLM location; [local] means an uncatalogued local model. Missing GGUF files download on first use.",
|
||||
"prompt_text": "Prompt text to enhance. Leave blank to emit the selected preset instruction as the base prompt.",
|
||||
"preset_system_prompt": "Preset enhancement style. Use Custom Only when you want custom_system_prompt to fully control the instruction.",
|
||||
"custom_system_prompt": "Optional extra instruction. Required when using Custom Only; otherwise it is prepended to the selected style.",
|
||||
@@ -126,7 +127,7 @@ def _quant_from_filename(filename: str) -> str | None:
|
||||
E.g. 'gemma-4-E4B-it-Q4_K_M.gguf' -> 'Q4'."""
|
||||
from pathlib import Path
|
||||
name = Path(filename).stem.upper()
|
||||
for q in ('BF16', 'F16', 'F32', 'Q8_0', 'Q6_K', 'Q5_K_M', 'Q5_K_S', 'Q4_K_M', 'Q4_K_S', 'Q3_K_M', 'Q2_K'):
|
||||
for q in ('BF16', 'F16', 'F32', 'Q8_0', 'Q6_K', 'Q5_K_M', 'Q5_K_S', 'Q4_K_M', 'Q4_K_S', 'Q3_K_M', 'Q3_K_S', 'Q2_K'):
|
||||
if q in name:
|
||||
return _QUANT_CANONICAL.get(q, q)
|
||||
return None
|
||||
@@ -136,7 +137,7 @@ _QUANT_CANONICAL = {
|
||||
'Q8_0': 'Q8', 'Q6_K': 'Q6',
|
||||
'Q5_K_M': 'Q5', 'Q5_K_S': 'Q5',
|
||||
'Q4_K_M': 'Q4', 'Q4_K_S': 'Q4',
|
||||
'Q3_K_M': 'Q3', 'Q2_K': 'Q2',
|
||||
'Q3_K_M': 'Q3', 'Q3_K_S': 'Q3', 'Q2_K': 'Q2',
|
||||
}
|
||||
|
||||
_EMPTY_THINK_RE = re.compile(r"<think[^>]*>\s*</think>", flags=re.IGNORECASE | re.DOTALL)
|
||||
@@ -531,8 +532,10 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
|
||||
|
||||
# Scan filesystem for locally available models not in JSON config
|
||||
# Collect all directories to scan: the configured base_dir + any extra LLM paths from ComfyUI
|
||||
search_dirs = _gguf_search_dirs(base_dir)
|
||||
mark_catalog_files_installed(models, search_dirs)
|
||||
existing_filenames = {Path(e.get("filename", "")).name for e in models.values() if e.get("filename")}
|
||||
for scan_dir in _gguf_search_dirs(base_dir):
|
||||
for scan_dir in search_dirs:
|
||||
local_models = ThinkingLLM_QwenVL_GGUF_PromptEnhancer._scan_local_gguf_text_models(scan_dir, existing_filenames)
|
||||
models.update(local_models)
|
||||
existing_filenames.update(Path(e.get("filename", "")).name for e in local_models.values() if e.get("filename"))
|
||||
|
||||
@@ -118,7 +118,7 @@ HF tokenizer note: some Gemma and Qwen variants need `sentencepiece` or `tiktoke
|
||||
|
||||
#### Shared ComfyUI text encoders
|
||||
|
||||
Compatible single-file checkpoints already in `ComfyUI/models/text_encoders` appear in the model selector with a `[ComfyUI]` prefix. ThinkingLLM loads them through ComfyUI, so it does not download or duplicate a Transformers snapshot.
|
||||
Compatible single-file checkpoints already in `ComfyUI/models/text_encoders` appear as `[local] filename (text_encoders)` in every applicable model selector. ThinkingLLM loads them through ComfyUI, so it does not download or duplicate a Transformers snapshot. Older workflows saved with the former `[ComfyUI]` label remain compatible.
|
||||
|
||||
- Qwen3-VL 4B and 8B `.safetensors`, including `qwen3vl_8b_fp8_scaled.safetensors`
|
||||
- Gemma 3 12B `.safetensors`, including `gemma-3-12b-it-heretic-v2_int8.safetensors`
|
||||
@@ -134,6 +134,8 @@ The MiniMax H3/Qwen3-VL 32B conditioning checkpoint is intentionally excluded be
|
||||
|
||||
The GGUF path requires a multimodal-capable `llama-cpp-python` build. The normal PyPI package may not include the chat handlers needed for Qwen vision, Gemma 4, or MTMD audio. On Linux, ThinkingLLM auto-checks this at first GGUF use and attempts an automatic install of a matching JamePeng backend. Use the setup notes in [docs/LLAMA_CPP_PYTHON_VISION_INSTALL.md](docs/LLAMA_CPP_PYTHON_VISION_INSTALL.md) if you need to override the wheel/package source.
|
||||
|
||||
Model labels are consistent across the standard, Advanced, audio, and Prompt Enhancer selectors: `[installed]` marks a catalog model found in any configured `LLM`, `GGUF`, or `Qwen-VL` location, while `[local]` marks an uncatalogued local model. These labels are display-only and do not change saved model identities.
|
||||
|
||||
## Workflow tips
|
||||
|
||||
- **Pre-process input images** — use a resize or scale node before ThinkingLLM so large images don't blow up the context window.
|
||||
@@ -145,6 +147,8 @@ The GGUF path requires a multimodal-capable `llama-cpp-python` build. The normal
|
||||
|
||||
The four vision nodes and both Prompt Enhancer nodes expose an optional `duration_seconds` input. It becomes visible only for registered LTX 2.3 and MiniMax H3 video presets; all other presets ignore it. Connect the same requested duration value to ThinkingLLM and your downstream video generator so prompt timing and generated length stay aligned.
|
||||
|
||||
Switching from a duration-aware preset back to `Custom Only` or another non-video preset hides the control without changing its ComfyUI serialization slot. The last valid duration is preserved; if an older workflow contains a corrupted non-numeric value in that slot, the frontend repairs it to the last valid value or `5.0` seconds. After updating ThinkingLLM, restart ComfyUI and use `Ctrl+F5` once so the corrected frontend code is loaded.
|
||||
|
||||
- LTX 2.3 uses the requested value directly and returns one chronological paragraph with a natural total-duration phrase.
|
||||
- MiniMax H3 mirrors ComfyUI's 24 fps `17k+5` frame grid. For example, `5.0` seconds becomes 124 frames (`5.17` seconds), `8.0` stays 192 frames (`8.00` seconds), and `12.0` becomes 294 frames (`12.25` seconds). The effective duration is internal planning context: T2VA, I2VA, and full-reference outputs do not get an invented `Target duration:` field. FL2VA and L2VA use it only in the official image-alignment line.
|
||||
- The duration input overrides conflicting duration wording in the free prompt for duration-aware presets. Older saved nodes that do not contain the optional input use `5.0` seconds.
|
||||
@@ -180,6 +184,7 @@ Commercial-release model dropdowns group entries by rights status and accept onl
|
||||
- Qwen2.5-VL 3B / 7B
|
||||
|
||||
### Qwen — Vision-language (GGUF)
|
||||
- Qwen3.8 27B Blackfrost Abliterated (Q2 through Q8, image/video)
|
||||
- Qwen3.5 4B / 9B / 27B (Q4 K M through BF16)
|
||||
- Qwen3-VL 4B / 8B / 32B (Instruct, abliterated, thinking)
|
||||
- Qwen2.5-VL 3B / 7B
|
||||
@@ -189,6 +194,7 @@ Commercial-release model dropdowns group entries by rights status and accept onl
|
||||
- Qwen3.5 4B / 9B (base + heretic + uncensored)
|
||||
|
||||
### Qwen — Text-only (GGUF)
|
||||
- Qwen3.8 27B Blackfrost Abliterated (Q2 through Q8)
|
||||
- Qwen3 4B / 8B (abliterated, Josiefied, base)
|
||||
- Qwen3.5 4B / 9B / 27B (base + uncensored HauhauCS)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ The optional `duration_seconds` input is available on:
|
||||
- `ThinkingLLM Prompt Enhancer`
|
||||
- `ThinkingLLM Prompt Enhancer (GGUF)`
|
||||
|
||||
The widget is shown only for registered LTX 2.3 and MiniMax H3 video presets. It remains hidden and has no effect for image, analysis, tagging, and other non-video presets. Older workflows without the input use `5.0` seconds for a duration-aware preset.
|
||||
The widget is shown only for registered LTX 2.3 and MiniMax H3 video presets. It remains hidden and has no effect for `Custom Only`, image, analysis, tagging, and other non-video presets. Hiding the widget does not change its type or serialization position, so switching presets cannot move the read-only recommendation text into `duration_seconds`. The last valid duration is preserved. Older workflows without the input use `5.0` seconds for a duration-aware preset, and workflows containing a corrupted non-numeric duration are repaired to the last valid value or `5.0` seconds by the frontend.
|
||||
|
||||
Connect the same requested duration to ThinkingLLM and the downstream video generator. No separate enable switch is required.
|
||||
|
||||
@@ -99,6 +99,7 @@ ThinkingLLM does **not** enforce exact field count or order, speaker IDs, dialog
|
||||
## Recommended settings
|
||||
|
||||
- The read-only info box shows the active video preset's recommendation without changing node values. See the [complete video preset settings table](VIDEO_PRESET_SETTINGS.md).
|
||||
- You can switch from LTX or MiniMax back to `Custom Only` and queue immediately; the hidden duration remains numeric and is ignored by the non-video preset.
|
||||
- Connect the same requested duration to ThinkingLLM and the H3 generator.
|
||||
- Start with `enable_thinking=false` for direct prompt conversion.
|
||||
- Start with `max_tokens=2048` for Base/T2VA, I2VA, FL2VA, and L2VA prompts.
|
||||
@@ -107,7 +108,7 @@ ThinkingLLM does **not** enforce exact field count or order, speaker IDs, dialog
|
||||
- Increase the token limit only when a useful response is genuinely truncated.
|
||||
- Inspect `RAW_TRACE` for model reasoning and connect the main output to Show Text or Show Anything to inspect the exact downstream string.
|
||||
|
||||
After updating the custom node, restart ComfyUI completely and refresh the browser with `Ctrl+F5` so Python node definitions and frontend widget visibility are both reloaded.
|
||||
After updating the custom node, restart ComfyUI completely and refresh the browser with `Ctrl+F5` so Python node definitions and frontend widget visibility are both reloaded. If an old browser tab still reports `Failed to convert ... duration_seconds` followed by recommendation text, that tab is still using the stale frontend script; close it or perform another hard refresh.
|
||||
|
||||
## Official references
|
||||
|
||||
|
||||
@@ -224,6 +224,26 @@
|
||||
"context_length": 131072
|
||||
}
|
||||
},
|
||||
"Qwen3.8-27B-ABLITERATED-GGUF [Q2:10.7GB|Q3:12.1-13.3GB|Q4:15.6-16.5GB|Q5:18.7-19.2GB|Q6:22.1GB|Q8:28.6GB|VRAM:~20GB+]": {
|
||||
"author": "Blackfrost-AI",
|
||||
"repo_name": "Qwen3.8-27B-ABLITERATED-GGUF",
|
||||
"repo_id": "Blackfrost-AI/Qwen3.8-27B-ABLITERATED-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"model_files": [
|
||||
"Qwen3.8-27B-ABLITERATED-Q2_K.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q3_K_S.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q3_K_M.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q4_K_S.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q4_K_M.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q5_K_S.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q5_K_M.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q6_K.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q8_0.gguf"
|
||||
],
|
||||
"defaults": {
|
||||
"context_length": 262144
|
||||
}
|
||||
},
|
||||
"Gemma-4-E2B-it-GGUF [Q4:3.2GB|Q8:4.7GB|BF16:8.7GB|VRAM:~5GB]": {
|
||||
"author": "bartowski",
|
||||
"repo_name": "google_gemma-4-E2B-it-GGUF",
|
||||
@@ -440,6 +460,37 @@
|
||||
"pool_size": 4194304
|
||||
}
|
||||
},
|
||||
"Qwen3.8-27B-ABLITERATED-GGUF [Q2:10.7GB|Q3:12.1-13.3GB|Q4:15.6-16.5GB|Q5:18.7-19.2GB|Q6:22.1GB|Q8:28.6GB|VRAM:~20GB+]": {
|
||||
"author": "Blackfrost-AI",
|
||||
"repo_name": "Qwen3.8-27B-ABLITERATED-GGUF",
|
||||
"repo_id": "Blackfrost-AI/Qwen3.8-27B-ABLITERATED-GGUF",
|
||||
"commercial_status": "unclear",
|
||||
"mmproj_file": "mmproj-Qwen3.8-27B-ABLITERATED-Q8_0.gguf",
|
||||
"mmproj_local_filenames": {
|
||||
"mmproj-Qwen3.8-27B-ABLITERATED-Q8_0.gguf": [
|
||||
"mmproj-Qwen3.8-27B-ABLITERATED-F16.gguf"
|
||||
]
|
||||
},
|
||||
"model_files": [
|
||||
"Qwen3.8-27B-ABLITERATED-Q2_K.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q3_K_S.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q3_K_M.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q4_K_S.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q4_K_M.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q5_K_S.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q5_K_M.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q6_K.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q8_0.gguf"
|
||||
],
|
||||
"defaults": {
|
||||
"context_length": 262144,
|
||||
"image_max_tokens": 4096,
|
||||
"n_batch": 512,
|
||||
"gpu_layers": -1,
|
||||
"top_k": 20,
|
||||
"pool_size": 4194304
|
||||
}
|
||||
},
|
||||
"Qwen3.5-4B [Q4:2.4GB|Q6:3.2GB|Q8:4.2GB|BF16:8GB|VRAM:~4GB]": {
|
||||
"author": "unsloth",
|
||||
"repo_name": "Qwen3.5-4B-GGUF",
|
||||
|
||||
+214
-28
@@ -519,6 +519,48 @@ class TestModelRecommendations(unittest.TestCase):
|
||||
self.assertEqual("unclear", module.normalize_commercial_status({}))
|
||||
self.assertEqual("unclear", module.normalize_commercial_status({"commercial_status": "invalid"}))
|
||||
|
||||
def test_model_catalog_labels_share_installed_and_local_conventions(self):
|
||||
module = load_module_from_file(
|
||||
"thinkingllm_core/model_access.py",
|
||||
"thinkingllm_shared_model_label_test",
|
||||
)
|
||||
catalog = {
|
||||
"catalog model": {"is_installed": True},
|
||||
"remote model": {},
|
||||
"[local] custom.gguf": {"is_local": True},
|
||||
"[ComfyUI] encoder.safetensors": {
|
||||
"is_local": True,
|
||||
"local_display_name": "encoder.safetensors",
|
||||
"storage_label": "text_encoders",
|
||||
},
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
"catalog model [installed]",
|
||||
"remote model",
|
||||
"[local] custom.gguf",
|
||||
"[local] encoder.safetensors (text_encoders)",
|
||||
],
|
||||
module.model_catalog_options(catalog),
|
||||
)
|
||||
self.assertEqual(
|
||||
"catalog model",
|
||||
module.resolve_model_catalog_name(catalog, "catalog model [installed]"),
|
||||
)
|
||||
self.assertEqual(
|
||||
"catalog model",
|
||||
module.resolve_model_catalog_name(catalog, "catalog model"),
|
||||
)
|
||||
self.assertEqual(
|
||||
"[ComfyUI] encoder.safetensors",
|
||||
module.resolve_model_catalog_name(catalog, "[local] encoder.safetensors (text_encoders)"),
|
||||
)
|
||||
self.assertEqual(
|
||||
"[ComfyUI] encoder.safetensors",
|
||||
module.resolve_model_catalog_name(catalog, "[ComfyUI] encoder.safetensors"),
|
||||
)
|
||||
|
||||
def test_qwenvl_facade_preserves_exports_and_shared_object_identity(self):
|
||||
with mock.patch.dict(sys.modules, build_loader_test_stubs(), clear=False):
|
||||
module = load_module_from_file("AILab_QwenVL.py", "thinkingllm_facade_compatibility_test")
|
||||
@@ -585,7 +627,8 @@ class TestModelRecommendations(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("only permits preinstalled, hash-locked models", access_source)
|
||||
self.assertIn("if custom.exists() and not COMMERCIAL_RELEASE", hf_models_source)
|
||||
self.assertIn("if not COMMERCIAL_RELEASE:\n _scan_local_hf_models()", hf_models_source)
|
||||
self.assertIn("if COMMERCIAL_RELEASE:\n _mark_installed_hf_catalog_models()", hf_models_source)
|
||||
self.assertIn("else:\n _scan_local_hf_models()", hf_models_source)
|
||||
self.assertIn('"local_files_only": COMMERCIAL_RELEASE', hf_runtime_source)
|
||||
self.assertIn("trust_remote_code=not COMMERCIAL_RELEASE", hf_runtime_source)
|
||||
self.assertIn('"local_files_only": COMMERCIAL_RELEASE', enhancer_source)
|
||||
@@ -631,6 +674,71 @@ class TestModelRecommendations(unittest.TestCase):
|
||||
self.assertGreaterEqual(len(text_catalog[text_key].get("model_files") or []), 11)
|
||||
self.assertGreaterEqual(len(vision_catalog[vision_key].get("model_files") or []), 11)
|
||||
|
||||
def test_qwen38_blackfrost_exists_in_text_and_vision_catalogs(self):
|
||||
payload = json.loads((PKG / "gguf_models.json").read_text(encoding="utf-8"))
|
||||
text_catalog = payload.get("Qwen_model") or {}
|
||||
vision_catalog = payload.get("qwenVL_model") or {}
|
||||
expected_files = [
|
||||
"Qwen3.8-27B-ABLITERATED-Q2_K.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q3_K_S.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q3_K_M.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q4_K_S.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q4_K_M.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q5_K_S.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q5_K_M.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q6_K.gguf",
|
||||
"Qwen3.8-27B-ABLITERATED-Q8_0.gguf",
|
||||
]
|
||||
|
||||
text_key = next((key for key in text_catalog if "Qwen3.8-27B-ABLITERATED" in key), None)
|
||||
vision_key = next((key for key in vision_catalog if "Qwen3.8-27B-ABLITERATED" in key), None)
|
||||
|
||||
self.assertIsNotNone(text_key)
|
||||
self.assertIsNotNone(vision_key)
|
||||
for catalog, key in ((text_catalog, text_key), (vision_catalog, vision_key)):
|
||||
entry = catalog[key]
|
||||
self.assertEqual(entry["repo_id"], "Blackfrost-AI/Qwen3.8-27B-ABLITERATED-GGUF")
|
||||
self.assertEqual(entry["model_files"], expected_files)
|
||||
self.assertEqual(entry["defaults"]["context_length"], 262144)
|
||||
self.assertEqual(
|
||||
vision_catalog[vision_key]["mmproj_file"],
|
||||
"mmproj-Qwen3.8-27B-ABLITERATED-Q8_0.gguf",
|
||||
)
|
||||
self.assertEqual(
|
||||
vision_catalog[vision_key]["mmproj_local_filenames"],
|
||||
{
|
||||
"mmproj-Qwen3.8-27B-ABLITERATED-Q8_0.gguf": [
|
||||
"mmproj-Qwen3.8-27B-ABLITERATED-F16.gguf"
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
def test_qwen38_blackfrost_expands_in_both_gguf_node_catalogs(self):
|
||||
stubs = build_loader_test_stubs()
|
||||
with mock.patch.dict(sys.modules, stubs, clear=False):
|
||||
vision_module = load_module_from_file(
|
||||
"AILab_QwenVL_GGUF.py",
|
||||
"thinkingllm_qwen38_vision_catalog_test",
|
||||
)
|
||||
with mock.patch.dict(sys.modules, {"AILab_QwenVL_GGUF": vision_module}, clear=False):
|
||||
prompt_module = load_module_from_file(
|
||||
"AILab_QwenVL_GGUF_PromptEnhancer.py",
|
||||
"thinkingllm_qwen38_prompt_catalog_test",
|
||||
)
|
||||
|
||||
vision_models = vision_module.GGUF_VL_CATALOG.get("models") or {}
|
||||
prompt_models = prompt_module.ThinkingLLM_QwenVL_GGUF_PromptEnhancer.load_gguf_models().get("models") or {}
|
||||
q3_name = "Qwen3.8-27B-ABLITERATED-Q3_K_S.gguf [~12.1-13.3GB]"
|
||||
q4_name = "Qwen3.8-27B-ABLITERATED-Q4_K_M.gguf [~15.6-16.5GB]"
|
||||
|
||||
self.assertIn(q3_name, vision_models)
|
||||
self.assertIn(q4_name, vision_models)
|
||||
self.assertIn(q3_name, prompt_models)
|
||||
self.assertIn(q4_name, prompt_models)
|
||||
resolved = vision_module._resolve_model_entry(q4_name)
|
||||
self.assertEqual(resolved.mmproj_filename, "mmproj-Qwen3.8-27B-ABLITERATED-Q8_0.gguf")
|
||||
self.assertIn("mmproj-Qwen3.8-27B-ABLITERATED-F16.gguf", resolved.mmproj_local_filenames)
|
||||
|
||||
def test_gemma4_12b_is_available_for_hf_and_gguf_vision_and_text(self):
|
||||
hf_payload = json.loads((PKG / "hf_models.json").read_text(encoding="utf-8"))
|
||||
gguf_payload = json.loads((PKG / "gguf_models.json").read_text(encoding="utf-8"))
|
||||
@@ -1401,6 +1509,20 @@ class TestModelRecommendations(unittest.TestCase):
|
||||
self.assertIn("Info only: your saved widget values are not changed.", source)
|
||||
self.assertIn('["model_name", "enable_thinking", ...PRESET_WIDGET_NAMES]', source)
|
||||
|
||||
def test_duration_visibility_preserves_widget_type_and_serialization_slot(self):
|
||||
source = read_source("web/js/appearance.js")
|
||||
visibility_block = source[
|
||||
source.index("function setWidgetVisible("):
|
||||
source.index("function readComboValue(")
|
||||
]
|
||||
|
||||
self.assertNotIn("widget.type =", visibility_block)
|
||||
self.assertNotIn("widget.draw =", visibility_block)
|
||||
self.assertIn("widget.computedHeight = 0", visibility_block)
|
||||
self.assertIn("delete widget.computedHeight", visibility_block)
|
||||
self.assertIn("repairDurationWidgetValue(durationWidget)", source)
|
||||
self.assertIn("DEFAULT_DURATION_SECONDS", source)
|
||||
|
||||
def test_hf_default_flag_controls_default_model(self):
|
||||
with mock.patch.dict(sys.modules, build_loader_test_stubs(), clear=False):
|
||||
package = load_thinkingllm_loader_subset(["AILab_QwenVL.py"])
|
||||
@@ -1666,28 +1788,39 @@ class TestSharedComfyUITextEncoders(unittest.TestCase):
|
||||
|
||||
with mock.patch.dict(sys.modules, stub_modules, clear=False):
|
||||
module = load_module_from_file("AILab_QwenVL.py", "thinkingllm_native_text_encoder_test")
|
||||
qwen_name = "[ComfyUI] qwen3vl_8b_fp8_scaled.safetensors"
|
||||
gemma_name = "[ComfyUI] gemma-3-12b-it-heretic-v2_int8.safetensors"
|
||||
hf_folder_paths = module.load_model_configs.__globals__["folder_paths"]
|
||||
with mock.patch.object(
|
||||
hf_folder_paths,
|
||||
"get_filename_list",
|
||||
side_effect=lambda category: filenames if category == "text_encoders" else [],
|
||||
), mock.patch.object(
|
||||
hf_folder_paths,
|
||||
"get_full_path_or_raise",
|
||||
side_effect=lambda category, filename: str(PKG / filename),
|
||||
):
|
||||
module.load_model_configs()
|
||||
qwen_name = "[ComfyUI] qwen3vl_8b_fp8_scaled.safetensors"
|
||||
gemma_name = "[ComfyUI] gemma-3-12b-it-heretic-v2_int8.safetensors"
|
||||
|
||||
self.assertIn("[ComfyUI] qwen3vl_4b_convrot.safetensors", module.HF_VL_MODELS)
|
||||
self.assertIn(qwen_name, module.HF_VL_MODELS)
|
||||
self.assertIn(gemma_name, module.HF_VL_MODELS)
|
||||
self.assertNotIn("[ComfyUI] qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", module.HF_VL_MODELS)
|
||||
self.assertNotIn("[ComfyUI] qwen3vl_32b_minimax_h3_nvfp4_awq.safetensor", module.HF_VL_MODELS)
|
||||
self.assertIn("[ComfyUI] qwen3vl_4b_convrot.safetensors", module.HF_VL_MODELS)
|
||||
self.assertIn(qwen_name, module.HF_VL_MODELS)
|
||||
self.assertIn(gemma_name, module.HF_VL_MODELS)
|
||||
self.assertNotIn("[ComfyUI] qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", module.HF_VL_MODELS)
|
||||
self.assertNotIn("[ComfyUI] qwen3vl_32b_minimax_h3_nvfp4_awq.safetensor", module.HF_VL_MODELS)
|
||||
|
||||
node = module.QwenVLBase()
|
||||
node.load_model(qwen_name, "unused", "auto", False, "auto", True)
|
||||
text, raw = node.generate("hello", None, None, 4, 32, 0.7, 0.9, 1, 1.1, model_name=qwen_name, seed=7)
|
||||
node = module.QwenVLBase()
|
||||
node.load_model(qwen_name, "unused", "auto", False, "auto", True)
|
||||
text, raw = node.generate("hello", None, None, 4, 32, 0.7, 0.9, 1, 1.1, model_name=qwen_name, seed=7)
|
||||
|
||||
self.assertEqual(("native answer", "native answer"), (text, raw))
|
||||
self.assertEqual([str(PKG / "qwen3vl_8b_fp8_scaled.safetensors")], calls["load"]["ckpt_paths"])
|
||||
self.assertTrue(calls["tokenize"][1]["thinking"])
|
||||
self.assertEqual(7, calls["generate"][1]["seed"])
|
||||
calls["decoded_text"] = " "
|
||||
with self.assertRaisesRegex(RuntimeError, "without producing text"):
|
||||
node.generate("hello", None, None, 4, 32, 0.7, 0.9, 1, 1.1, model_name=qwen_name, seed=8)
|
||||
with self.assertRaisesRegex(ValueError, "not video"):
|
||||
node.generate("hello", None, object(), 4, 32, 0.7, 0.9, 1, 1.1, model_name=gemma_name)
|
||||
self.assertEqual(("native answer", "native answer"), (text, raw))
|
||||
self.assertEqual([str(PKG / "qwen3vl_8b_fp8_scaled.safetensors")], calls["load"]["ckpt_paths"])
|
||||
self.assertTrue(calls["tokenize"][1]["thinking"])
|
||||
self.assertEqual(7, calls["generate"][1]["seed"])
|
||||
calls["decoded_text"] = " "
|
||||
with self.assertRaisesRegex(RuntimeError, "without producing text"):
|
||||
node.generate("hello", None, None, 4, 32, 0.7, 0.9, 1, 1.1, model_name=qwen_name, seed=8)
|
||||
with self.assertRaisesRegex(ValueError, "not video"):
|
||||
node.generate("hello", None, object(), 4, 32, 0.7, 0.9, 1, 1.1, model_name=gemma_name)
|
||||
|
||||
|
||||
class TestHuggingFaceTokenSupport(unittest.TestCase):
|
||||
@@ -1794,6 +1927,44 @@ class TestHuggingFaceTokenSupport(unittest.TestCase):
|
||||
|
||||
self.assertEqual([("private/repo", "hf_test_secret")], captured)
|
||||
|
||||
def test_hf_catalog_model_is_labeled_and_reused_from_extra_llm_path(self):
|
||||
module = self._load_qwenvl_with_stubs()
|
||||
models_globals = module.ensure_model.__globals__
|
||||
model_folder_paths = models_globals["folder_paths"]
|
||||
entry = {"repo_id": "example/example-model", "commercial_status": "unclear"}
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_root = Path(temp_dir)
|
||||
shared_llm = temp_root / "shared" / "LLM"
|
||||
target = shared_llm / "Qwen-VL" / "example-model"
|
||||
target.mkdir(parents=True)
|
||||
(target / "config.json").write_text("{}", encoding="utf-8")
|
||||
(target / "model.safetensors").write_bytes(b"weights")
|
||||
|
||||
with mock.patch.dict(models_globals, {"HF_ALL_MODELS": {"Example model": entry}}), mock.patch.object(
|
||||
model_folder_paths,
|
||||
"models_dir",
|
||||
str(temp_root / "dev" / "models"),
|
||||
), mock.patch.object(
|
||||
model_folder_paths,
|
||||
"folder_names_and_paths",
|
||||
{"LLM": ([str(shared_llm)], set())},
|
||||
), mock.patch.object(
|
||||
model_folder_paths,
|
||||
"get_folder_paths",
|
||||
side_effect=lambda name: [str(shared_llm)] if name == "LLM" else [],
|
||||
), mock.patch.dict(
|
||||
models_globals,
|
||||
{"_download_model_snapshot": mock.Mock(side_effect=AssertionError("installed model must not download"))},
|
||||
):
|
||||
models_globals["_mark_installed_hf_catalog_models"]()
|
||||
self.assertTrue(entry["is_installed"])
|
||||
self.assertEqual(
|
||||
["Example model [installed]"],
|
||||
module.model_catalog_options({"Example model": entry}),
|
||||
)
|
||||
self.assertEqual(str(target), module.ensure_model("Example model [installed]"))
|
||||
|
||||
def test_gated_gguf_stops_before_download_when_token_is_missing(self):
|
||||
hf_module = self._load_qwenvl_with_stubs()
|
||||
stubs = build_loader_test_stubs()
|
||||
@@ -2365,12 +2536,16 @@ class TestGGUFAdvancedWorkflowCompatibility(unittest.TestCase):
|
||||
with mock.patch.object(module.folder_paths, "models_dir", str(models_dir)):
|
||||
catalog = module._load_gguf_vl_catalog()
|
||||
|
||||
self.assertIn("model.gguf [installed]", catalog["models"])
|
||||
self.assertIn("model.gguf", catalog["models"])
|
||||
self.assertTrue(catalog["models"]["model.gguf"]["is_installed"])
|
||||
self.assertNotIn("[local] model.gguf", catalog["models"])
|
||||
self.assertIn("model.gguf [installed]", module.model_catalog_options(catalog["models"]))
|
||||
|
||||
with mock.patch.object(module, "GGUF_VL_CATALOG", catalog):
|
||||
resolved = module._resolve_model_entry("model.gguf")
|
||||
resolved_from_label = module._resolve_model_entry("model.gguf [installed]")
|
||||
self.assertEqual(resolved.model_filename, "model.gguf")
|
||||
self.assertEqual(resolved_from_label.model_filename, "model.gguf")
|
||||
|
||||
def test_gguf_vision_advanced_allows_larger_reasoning_output_budget(self):
|
||||
node_cls = self._load_gguf_advanced_class()
|
||||
@@ -2934,16 +3109,22 @@ class TestGGUFWindllRuntimeFallback(unittest.TestCase):
|
||||
)
|
||||
|
||||
with mock.patch.dict(sys.modules, stubs, clear=False):
|
||||
module = load_module_from_file(
|
||||
"AILab_QwenVL_GGUF_PromptEnhancer.py",
|
||||
"thinkingllm_gguf_prompt_extra_llm_reuse_test",
|
||||
vision_module = load_module_from_file(
|
||||
"AILab_QwenVL_GGUF.py",
|
||||
"thinkingllm_gguf_prompt_extra_llm_vision_dependency_test",
|
||||
)
|
||||
with mock.patch.dict(sys.modules, {"AILab_QwenVL_GGUF": vision_module}, clear=False):
|
||||
module = load_module_from_file(
|
||||
"AILab_QwenVL_GGUF_PromptEnhancer.py",
|
||||
"thinkingllm_gguf_prompt_extra_llm_reuse_test",
|
||||
)
|
||||
|
||||
model_name = next(
|
||||
name
|
||||
for name in module.ThinkingLLM_QwenVL_GGUF_PromptEnhancer.load_gguf_models()["models"]
|
||||
for name in module.ThinkingLLM_QwenVL_GGUF_PromptEnhancer.INPUT_TYPES()["required"]["model_name"][0]
|
||||
if name.startswith(model_filename)
|
||||
)
|
||||
self.assertTrue(model_name.endswith(" [installed]"))
|
||||
loader = module.ThinkingLLM_QwenVL_GGUF_PromptEnhancer()
|
||||
|
||||
with mock.patch.dict(sys.modules, stubs, clear=False), mock.patch.object(
|
||||
@@ -3206,10 +3387,15 @@ class TestGGUFWindllRuntimeFallback(unittest.TestCase):
|
||||
)
|
||||
|
||||
with mock.patch.dict(sys.modules, stubs, clear=False):
|
||||
module = load_module_from_file(
|
||||
"AILab_QwenVL_GGUF_PromptEnhancer.py",
|
||||
"thinkingllm_gguf_prompt_google_gemma_alias_reuse_test",
|
||||
vision_module = load_module_from_file(
|
||||
"AILab_QwenVL_GGUF.py",
|
||||
"thinkingllm_gguf_prompt_google_gemma_vision_dependency_test",
|
||||
)
|
||||
with mock.patch.dict(sys.modules, {"AILab_QwenVL_GGUF": vision_module}, clear=False):
|
||||
module = load_module_from_file(
|
||||
"AILab_QwenVL_GGUF_PromptEnhancer.py",
|
||||
"thinkingllm_gguf_prompt_google_gemma_alias_reuse_test",
|
||||
)
|
||||
|
||||
model_name = "google_gemma-4-E4B-it-Q8_0.gguf [~8GB]"
|
||||
loader = module.ThinkingLLM_QwenVL_GGUF_PromptEnhancer()
|
||||
|
||||
@@ -51,7 +51,7 @@ PRESET_PROMPTS: list[str] = [NO_PRESET_PROMPT, "Describe this image in detail."]
|
||||
|
||||
|
||||
TOOLTIPS = {
|
||||
"model_name": "Pick the checkpoint. [ComfyUI] entries reuse compatible models/text_encoders files; other entries download into models/LLM/Qwen-VL on first use.",
|
||||
"model_name": "Pick the checkpoint. [installed] means a catalog model is available in a configured LLM location; [local] means an uncatalogued local model, including compatible ComfyUI text_encoders. Missing catalog models download on first use.",
|
||||
"quantization": "Precision vs VRAM. FP16 gives the best quality if memory allows; 8-bit suits 8–16 GB GPUs; 4-bit fits 6 GB or lower but is slower.",
|
||||
"attention_mode": "auto tries SageAttention → FlashAttention 2 → SDPA in order. SDPA is stable and recommended. Only override when debugging attention backends.",
|
||||
"preset_prompt": "Built-in instruction describing how Qwen-VL should analyze the media input.",
|
||||
@@ -70,6 +70,63 @@ TOOLTIPS = {
|
||||
}
|
||||
|
||||
|
||||
def _configured_llm_roots() -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
try:
|
||||
if "LLM" in folder_paths.folder_names_and_paths:
|
||||
roots.extend(Path(path) for path in folder_paths.get_folder_paths("LLM"))
|
||||
except (AttributeError, KeyError, TypeError):
|
||||
pass
|
||||
default_root = Path(folder_paths.models_dir) / "LLM"
|
||||
if default_root not in roots:
|
||||
roots.append(default_root)
|
||||
return roots
|
||||
|
||||
|
||||
def _hf_qwen_vl_dirs() -> list[Path]:
|
||||
return [root / "Qwen-VL" for root in _configured_llm_roots()]
|
||||
|
||||
|
||||
def _hf_catalog_search_dirs() -> list[Path]:
|
||||
search_dirs: list[Path] = []
|
||||
for root in _configured_llm_roots():
|
||||
for directory in (root / "Qwen-VL", root):
|
||||
if directory not in search_dirs:
|
||||
search_dirs.append(directory)
|
||||
return search_dirs
|
||||
|
||||
|
||||
def _find_installed_hf_model_dir(info: dict, *, require_processor: bool = False) -> Path | None:
|
||||
repo_id = info.get("repo_id") if isinstance(info, dict) else None
|
||||
if not isinstance(repo_id, str) or not repo_id.strip():
|
||||
return None
|
||||
directory_name = repo_id.rstrip("/").rsplit("/", 1)[-1]
|
||||
installed_path = info.get("installed_path")
|
||||
candidates = [Path(installed_path)] if installed_path else []
|
||||
candidates.extend(directory / directory_name for directory in _hf_catalog_search_dirs())
|
||||
seen: set[Path] = set()
|
||||
for candidate in candidates:
|
||||
if candidate in seen:
|
||||
continue
|
||||
seen.add(candidate)
|
||||
if _model_snapshot_has_required_files(candidate, require_processor=require_processor):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _mark_installed_hf_catalog_models() -> None:
|
||||
for info in HF_ALL_MODELS.values():
|
||||
if not isinstance(info, dict) or info.get("is_local"):
|
||||
continue
|
||||
installed_path = _find_installed_hf_model_dir(info)
|
||||
if installed_path is None:
|
||||
info.pop("is_installed", None)
|
||||
info.pop("installed_path", None)
|
||||
continue
|
||||
info["is_installed"] = True
|
||||
info["installed_path"] = str(installed_path)
|
||||
|
||||
|
||||
def ensure_cuda_vram_headroom(module_name="QwenVL", min_free_gb=1.0, min_free_ratio=0.08):
|
||||
if not torch.cuda.is_available():
|
||||
return True
|
||||
@@ -226,24 +283,18 @@ def load_model_configs():
|
||||
SYSTEM_PROMPTS.update(next_system_prompts)
|
||||
PRESET_PROMPTS[:] = next_preset_prompts
|
||||
|
||||
# Commercial releases expose only the two entries in the reviewed catalog.
|
||||
if not COMMERCIAL_RELEASE:
|
||||
# Commercial releases expose only reviewed catalog entries, but still show
|
||||
# whether those entries are available in any configured LLM location.
|
||||
if COMMERCIAL_RELEASE:
|
||||
_mark_installed_hf_catalog_models()
|
||||
else:
|
||||
_scan_local_hf_models()
|
||||
|
||||
|
||||
def _scan_local_hf_models():
|
||||
"""Scan local HF directories and compatible ComfyUI text encoders."""
|
||||
# Collect all Qwen-VL directories to scan from ComfyUI's multi-path system
|
||||
scan_dirs: list[Path] = []
|
||||
llm_paths = folder_paths.get_folder_paths("LLM") if "LLM" in folder_paths.folder_names_and_paths else []
|
||||
for llm_path in llm_paths:
|
||||
qwen_dir = Path(llm_path) / "Qwen-VL"
|
||||
if qwen_dir not in scan_dirs:
|
||||
scan_dirs.append(qwen_dir)
|
||||
# Always include the default location as fallback
|
||||
default_dir = Path(folder_paths.models_dir) / "LLM" / "Qwen-VL"
|
||||
if default_dir not in scan_dirs:
|
||||
scan_dirs.append(default_dir)
|
||||
_mark_installed_hf_catalog_models()
|
||||
scan_dirs = _hf_qwen_vl_dirs()
|
||||
|
||||
# Collect known local directory names from JSON config
|
||||
known_dirs = set()
|
||||
@@ -302,6 +353,8 @@ def _scan_local_hf_models():
|
||||
"comfy_text_encoder": filename,
|
||||
"repo_id": None,
|
||||
"is_local": True,
|
||||
"local_display_name": filename,
|
||||
"storage_label": "text_encoders",
|
||||
"commercial_status": "local",
|
||||
"quantized": True,
|
||||
"native_family": family,
|
||||
@@ -599,6 +652,13 @@ def ensure_model(model_name, require_processor=False, node_id=None, progress_lab
|
||||
if not repo_id:
|
||||
raise ValueError(f"Model '{model_name}' has no repo_id or local_path")
|
||||
|
||||
installed_target = _find_installed_hf_model_dir(info, require_processor=require_processor)
|
||||
if installed_target is not None:
|
||||
info["is_installed"] = True
|
||||
info["installed_path"] = str(installed_target)
|
||||
enforce_model_access(info, model_name, local_exists=True, hf_token=hf_token)
|
||||
return str(installed_target)
|
||||
|
||||
if COMMERCIAL_RELEASE:
|
||||
enforce_model_access(info, model_name, local_exists=False, hf_token=hf_token)
|
||||
target = Path(folder_paths.models_dir) / "LLM" / repo_id.split("/")[-1]
|
||||
@@ -609,13 +669,8 @@ def ensure_model(model_name, require_processor=False, node_id=None, progress_lab
|
||||
f"[QwenVL] Locked commercial model is missing or incomplete: {target}"
|
||||
)
|
||||
|
||||
# Use ComfyUI's multi-path system if available
|
||||
llm_paths = folder_paths.get_folder_paths("LLM") if "LLM" in folder_paths.folder_names_and_paths else []
|
||||
if llm_paths:
|
||||
models_dir = Path(llm_paths[0]) / "Qwen-VL"
|
||||
else:
|
||||
# Fallback to default behavior
|
||||
models_dir = Path(folder_paths.models_dir) / "LLM" / "Qwen-VL"
|
||||
# Download into ComfyUI's first configured LLM location.
|
||||
models_dir = _hf_qwen_vl_dirs()[0]
|
||||
|
||||
models_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = models_dir / repo_id.split("/")[-1]
|
||||
|
||||
@@ -35,6 +35,8 @@ MODEL_STATUS_PRESENTATION = {
|
||||
"unclear": (3, "⚠ Rights unclear | "),
|
||||
"noncommercial": (4, "🚫 Non-commercial | "),
|
||||
}
|
||||
MODEL_INSTALLED_SUFFIX = " [installed]"
|
||||
MODEL_LOCAL_PREFIX = "[local] "
|
||||
|
||||
|
||||
def _format_download_size(num_bytes) -> str:
|
||||
@@ -196,10 +198,25 @@ def normalize_commercial_status(info: dict | None) -> str:
|
||||
return status if status in VALID_COMMERCIAL_STATUSES else "unclear"
|
||||
|
||||
|
||||
def _model_display_name(name: str, info: dict | None) -> str:
|
||||
info = info if isinstance(info, dict) else {}
|
||||
local_display_name = str(info.get("local_display_name") or "").strip()
|
||||
if info.get("is_local") and local_display_name:
|
||||
label = f"{MODEL_LOCAL_PREFIX}{local_display_name}"
|
||||
storage_label = str(info.get("storage_label") or "").strip()
|
||||
if storage_label:
|
||||
label = f"{label} ({storage_label})"
|
||||
return label
|
||||
if info.get("is_installed") and not info.get("is_local") and not name.endswith(MODEL_INSTALLED_SUFFIX):
|
||||
return f"{name}{MODEL_INSTALLED_SUFFIX}"
|
||||
return name
|
||||
|
||||
|
||||
def model_catalog_label(name: str, info: dict | None) -> str:
|
||||
display_name = _model_display_name(name, info)
|
||||
if not COMMERCIAL_RELEASE:
|
||||
return name
|
||||
return f"{MODEL_STATUS_PRESENTATION[normalize_commercial_status(info)][1]}{name}"
|
||||
return display_name
|
||||
return f"{MODEL_STATUS_PRESENTATION[normalize_commercial_status(info)][1]}{display_name}"
|
||||
|
||||
|
||||
def model_catalog_options(models: dict[str, dict], predicate=None) -> list[str]:
|
||||
@@ -222,11 +239,74 @@ def resolve_model_catalog_name(models: dict[str, dict], selected: str) -> str:
|
||||
if selected in models:
|
||||
return selected
|
||||
for name, info in models.items():
|
||||
if selected == f"{MODEL_STATUS_PRESENTATION[normalize_commercial_status(info)][1]}{name}":
|
||||
prefix = MODEL_STATUS_PRESENTATION[normalize_commercial_status(info)][1]
|
||||
if selected in {model_catalog_label(name, info), f"{prefix}{name}"}:
|
||||
return name
|
||||
undecorated = selected
|
||||
for _order, prefix in MODEL_STATUS_PRESENTATION.values():
|
||||
if undecorated.startswith(prefix):
|
||||
undecorated = undecorated[len(prefix):]
|
||||
break
|
||||
if undecorated.endswith(MODEL_INSTALLED_SUFFIX):
|
||||
legacy_name = undecorated[:-len(MODEL_INSTALLED_SUFFIX)]
|
||||
if legacy_name in models:
|
||||
return legacy_name
|
||||
return selected
|
||||
|
||||
|
||||
def _catalog_filename_candidates(info: dict) -> set[str]:
|
||||
candidates: set[str] = set()
|
||||
for key in ("filename", "local_filenames", "local_model_files"):
|
||||
value = info.get(key)
|
||||
if isinstance(value, str):
|
||||
values = [value]
|
||||
elif isinstance(value, (list, tuple, set)):
|
||||
values = value
|
||||
elif isinstance(value, dict):
|
||||
values = [*value.keys(), *value.values()]
|
||||
else:
|
||||
values = []
|
||||
for candidate in values:
|
||||
if isinstance(candidate, (list, tuple, set)):
|
||||
nested = candidate
|
||||
else:
|
||||
nested = [candidate]
|
||||
for item in nested:
|
||||
filename = Path(str(item)).name.casefold()
|
||||
if filename:
|
||||
candidates.add(filename)
|
||||
return candidates
|
||||
|
||||
|
||||
def mark_catalog_files_installed(models: dict[str, dict], search_dirs) -> None:
|
||||
"""Mark catalog entries found in any configured directory without changing their keys."""
|
||||
installed_files: dict[str, Path] = {}
|
||||
for directory in search_dirs:
|
||||
directory = Path(directory)
|
||||
if not directory.exists() or not directory.is_dir():
|
||||
continue
|
||||
try:
|
||||
for path in directory.rglob("*.gguf"):
|
||||
if path.is_file():
|
||||
installed_files.setdefault(path.name.casefold(), path)
|
||||
except (OSError, PermissionError):
|
||||
continue
|
||||
|
||||
for info in models.values():
|
||||
if not isinstance(info, dict) or info.get("is_local"):
|
||||
continue
|
||||
installed_path = next(
|
||||
(installed_files[name] for name in _catalog_filename_candidates(info) if name in installed_files),
|
||||
None,
|
||||
)
|
||||
if installed_path is None:
|
||||
info.pop("is_installed", None)
|
||||
info.pop("installed_path", None)
|
||||
continue
|
||||
info["is_installed"] = True
|
||||
info["installed_path"] = str(installed_path)
|
||||
|
||||
|
||||
def enforce_model_access(info: dict | None, model_name: str, *, local_exists: bool, hf_token: str | None = None) -> None:
|
||||
status = normalize_commercial_status(info)
|
||||
if COMMERCIAL_RELEASE:
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
- Refactored the QwenVL implementation into focused shared modules for model access, runtime configuration, prompt state, prompt contracts, and media handling while preserving compatibility exports.
|
||||
- Improved streaming so reasoning stays separate from final content, unfinished thinking traces trigger a direct finalization retry, and terminal-only punctuation remains safe on legacy Windows code pages.
|
||||
- Expanded preset recommendations, tooltips, system prompts, and documentation for video token budgets, thinking behavior, reference roles, dialogue fidelity, and duration handling.
|
||||
- Fixed dynamic duration-widget hiding so switching from LTX or MiniMax back to `Custom Only` cannot serialize the read-only recommendation text as `duration_seconds`; existing corrupted values are repaired to the last valid duration or `5.0` seconds.
|
||||
|
||||
### 2.4.0 Validation
|
||||
|
||||
- Expanded regression coverage for compatibility, downloads, caching, duration-aware prompting, MiniMax structures, streaming, and node registration.
|
||||
- All 131 isolated regression tests pass in the bundled ComfyUI Python runtime.
|
||||
- All 136 isolated regression tests pass in the bundled ComfyUI Python runtime, including duration-widget serialization coverage.
|
||||
|
||||
## Version 2.3.24 (2026/08/08) - System Prompt Preset Utility
|
||||
|
||||
|
||||
+21
-7
@@ -84,6 +84,7 @@ const RECOMMENDATIONS_URL = new URL("../model_recommendations.json", import.meta
|
||||
const PRESET_TOOLTIPS_URL = new URL("../preset_tooltips.json", import.meta.url);
|
||||
const RECOMMENDATION_WIDGET_NAME = "recommended_settings";
|
||||
const RECOMMENDATION_PLACEHOLDER = "Select a model to see provider/community recommended settings. This note never changes your saved widget values.";
|
||||
const DEFAULT_DURATION_SECONDS = 5.0;
|
||||
const RECOMMENDATION_FIELDS = ["temperature", "top_p", "top_k", "max_tokens", "ctx_min", "n_batch", "image_max_tokens"];
|
||||
const AUDIO_UNSUPPORTED_MESSAGE = "No curated audio support for the selected model. Use Gemma 4 Audio for audio understanding or Whisper ASR for transcription.";
|
||||
const PRESET_WIDGET_NAMES = ["preset_prompt", "enhancement_style", "preset_system_prompt"];
|
||||
@@ -159,27 +160,39 @@ function setWidgetVisible(node, widget, visible) {
|
||||
if (!widget) { return; }
|
||||
if (!widget._thinkingllmVisibilityState) {
|
||||
widget._thinkingllmVisibilityState = {
|
||||
type: widget.type,
|
||||
computeSize: widget.computeSize,
|
||||
draw: widget.draw,
|
||||
hidden: widget.hidden,
|
||||
};
|
||||
}
|
||||
const original = widget._thinkingllmVisibilityState;
|
||||
if (visible) {
|
||||
widget.type = original.type;
|
||||
widget.computeSize = original.computeSize;
|
||||
widget.draw = original.draw;
|
||||
widget.hidden = original.hidden;
|
||||
delete widget.computedHeight;
|
||||
} else {
|
||||
widget.hidden = true;
|
||||
widget.type = `thinkingllm_hidden_${original.type || "widget"}`;
|
||||
widget.computeSize = () => [0, 0];
|
||||
widget.draw = () => {};
|
||||
widget.computeSize = () => [0, -3.3];
|
||||
widget.computedHeight = 0;
|
||||
}
|
||||
resizeNode(node);
|
||||
}
|
||||
|
||||
function repairDurationWidgetValue(widget) {
|
||||
if (!widget) { return; }
|
||||
const numericValue = typeof widget.value === "number"
|
||||
? widget.value
|
||||
: Number(String(widget.value ?? "").trim());
|
||||
const minimum = Number(widget.options?.min ?? 0.2);
|
||||
const maximum = Number(widget.options?.max ?? 150.0);
|
||||
if (Number.isFinite(numericValue) && numericValue >= minimum && numericValue <= maximum) {
|
||||
widget.value = numericValue;
|
||||
widget._thinkingllmLastValidDuration = numericValue;
|
||||
return;
|
||||
}
|
||||
const previousValue = Number(widget._thinkingllmLastValidDuration);
|
||||
widget.value = Number.isFinite(previousValue) ? previousValue : DEFAULT_DURATION_SECONDS;
|
||||
}
|
||||
|
||||
function readComboValue(widget) {
|
||||
const value = widget?.value;
|
||||
if (typeof value === "string") {
|
||||
@@ -228,6 +241,7 @@ function updateDurationVisibility(node, payload) {
|
||||
}
|
||||
const selectedPreset = selectedPresetName(node);
|
||||
const durationWidget = findWidget(node, "duration_seconds");
|
||||
repairDurationWidgetValue(durationWidget);
|
||||
const { metadataAvailable, metadata } = findVideoPresetMetadata(payload, selectedPreset);
|
||||
if (!metadataAvailable) {
|
||||
// Keep the backend-provided control usable if the auxiliary payload is stale or unavailable.
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
{
|
||||
"version": 1,
|
||||
"rules": [
|
||||
{
|
||||
"id": "qwen38_blackfrost_27b",
|
||||
"priority": 110,
|
||||
"match_any": [
|
||||
"Qwen3.8-27B-ABLITERATED"
|
||||
],
|
||||
"family_label": "Qwen3.8 27B Blackfrost abliterated",
|
||||
"sources": [
|
||||
"Blackfrost-AI Qwen3.8 27B Abliterated GGUF model card",
|
||||
"Qwen3.8 27B official model card"
|
||||
],
|
||||
"support_notes": {
|
||||
"hf": "This catalog entry is GGUF-only. Use a GGUF node for this repository.",
|
||||
"gguf": "Hard toggle via enable_thinking in the Qwen3.8 chat template. Vision requires the matching mmproj file and a current llama.cpp build.",
|
||||
"soft_switch": "Qwen3.8 uses chat-template thinking controls; no /think or /no_think soft switch is documented."
|
||||
},
|
||||
"thinking_on": {
|
||||
"temperature": 1.0,
|
||||
"top_p": 0.95,
|
||||
"top_k": 20,
|
||||
"max_tokens": 32768,
|
||||
"ctx_min": 262144
|
||||
},
|
||||
"thinking_off": {
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.8,
|
||||
"top_k": 20,
|
||||
"max_tokens": 32768,
|
||||
"ctx_min": 262144
|
||||
},
|
||||
"notes": [
|
||||
"Thinking is enabled by default; disable it for direct prompt rewrites and keep it enabled for deeper scene planning.",
|
||||
"Q4_K_M is the model author's balanced default; use the compact Q8 projector for image or video input.",
|
||||
"The model supports 262K context architecturally, but large llama.cpp contexts consume substantial KV-cache memory; lower ctx for ordinary prompt work."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "qwen36_hauhau_multimodal",
|
||||
"priority": 100,
|
||||
|
||||
Reference in New Issue
Block a user