diff --git a/AILab_QwenVL_GGUF.py b/AILab_QwenVL_GGUF.py index 0851abf..e6ed41c 100644 --- a/AILab_QwenVL_GGUF.py +++ b/AILab_QwenVL_GGUF.py @@ -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.", diff --git a/AILab_QwenVL_GGUF_PromptEnhancer.py b/AILab_QwenVL_GGUF_PromptEnhancer.py index d565099..46aea59 100644 --- a/AILab_QwenVL_GGUF_PromptEnhancer.py +++ b/AILab_QwenVL_GGUF_PromptEnhancer.py @@ -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"]*>\s*", 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")) diff --git a/README.md b/README.md index 1d79e30..ef8d0b8 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/VIDEO_PROMPT_DURATION.md b/docs/VIDEO_PROMPT_DURATION.md index e8b6e1d..34300db 100644 --- a/docs/VIDEO_PROMPT_DURATION.md +++ b/docs/VIDEO_PROMPT_DURATION.md @@ -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 diff --git a/gguf_models.json b/gguf_models.json index 62c90ee..d42dc4a 100644 --- a/gguf_models.json +++ b/gguf_models.json @@ -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", diff --git a/tests/test_new_features.py b/tests/test_new_features.py index 1b0b27a..8500305 100644 --- a/tests/test_new_features.py +++ b/tests/test_new_features.py @@ -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() diff --git a/thinkingllm_core/hf_models.py b/thinkingllm_core/hf_models.py index 847c255..e4556e1 100644 --- a/thinkingllm_core/hf_models.py +++ b/thinkingllm_core/hf_models.py @@ -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] diff --git a/thinkingllm_core/model_access.py b/thinkingllm_core/model_access.py index 93f4e62..19732a0 100644 --- a/thinkingllm_core/model_access.py +++ b/thinkingllm_core/model_access.py @@ -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: diff --git a/update.md b/update.md index f2b442f..087a56c 100644 --- a/update.md +++ b/update.md @@ -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 diff --git a/web/js/appearance.js b/web/js/appearance.js index 1ab8f87..ec8fae0 100644 --- a/web/js/appearance.js +++ b/web/js/appearance.js @@ -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. diff --git a/web/model_recommendations.json b/web/model_recommendations.json index 678b51e..d8109af 100644 --- a/web/model_recommendations.json +++ b/web/model_recommendations.json @@ -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,