From 46c57a8ac9a4d539ad927786d6fc2700a33bc03e Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Tue, 22 Sep 2026 08:33:48 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(engines):=20=E7=9B=B4=E8=BF=9E=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E5=85=88=E6=B8=85=E5=9C=BA=E5=86=8D=E9=A2=84=E6=A3=80?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=2012GB=20=E5=8D=A1=E7=9B=B4?= =?UTF-8?q?=E5=88=87=E5=BC=95=E6=93=8E=E5=BF=85=20503=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit routes 的 load 端点把 voxcpm2/indextts2/indextts20 直接指到专用加载器, 绕过 switch_engine 的 M-R1 显存记账与卸载阶段,预检又是裸比较: VoxCPM2 已加载时(占用 ~9GB),直切 IndexTTS 2.5 必然 503 INSUFFICIENT_VRAM(issue #84 三段证据)。 修复(issue 选项 B 为主、C 的可诊断性吸收): - load.py 新增 _unload_loaded_engines_for_load():加载前卸载全部驻留 引擎(委托 unload_model(),RLock 同线程重入与 switch_engine 阶段4 同款),随后按基线复用 _wait_vram_freed 核验真实回收(延迟导入避免 与 switch.py 的模块级循环依赖);无驻留引擎时为幂等空操作。 - load_voxcpm2 原先只手工摘 voxcpm 两槽位(漏 enhancer/门面/persona 缓存/整个 IndexTTS),统一替换为该助手。 - load_indextts2 / load_indextts20 新增 keyword-only auto_unload=True; switch_engine 热待机路径显式传 False 保住双引擎常驻语义,传统路径 已卸载、传 True 只是空操作。 - InsufficientVRAMError 文案区分"已清场仍不足"与既有场景,不再误导。 验证:tests/test_engine_switch.py 新增 4 条(清场接线/空操作幂等/ 热待机跳过/报错文案),连同既有用例 32 passed;GPU 真机切换序列 因本机桌面应用占用 8.6/12.2GB 显存暂不可跑,留待空闲时人工复验 (加载 voxcpm2 -> 切 indextts2 -> 切 indextts20 -> 切回,观察 [模型加载] 清场日志与 2xx)。 Signed-off-by: ReSerendipity --- app/integrated_app/model_manager_core/load.py | 122 +++++++++++++++--- .../model_manager_core/switch.py | 6 +- tests/test_engine_switch.py | 96 ++++++++++++++ 3 files changed, 205 insertions(+), 19 deletions(-) diff --git a/app/integrated_app/model_manager_core/load.py b/app/integrated_app/model_manager_core/load.py index b0393d3e..0083d534 100644 --- a/app/integrated_app/model_manager_core/load.py +++ b/app/integrated_app/model_manager_core/load.py @@ -3,13 +3,82 @@ 包含 PersonaWarmupService / warmup_persona_cache、load_voxcpm2 / _do_load_voxcpm2_internal / load_indextts2、PreloadService / preload_model / get_preload_status。 -【边界】不做卸载(unload.py);不做引擎切换(switch.py)。 +【边界】不做引擎切换(switch.py)。卸载动作仍由 unload.py 承担:#84 起 +load_voxcpm2 / load_indextts2 在预检前经 _unload_loaded_engines_for_load +委托 unload_model() 清场卸载驻留引擎(switch_engine 热待机路径以 +``auto_unload=False`` 跳过,保持双引擎常驻语义)。 """ from . import state as _state from .state import * +def _unload_loaded_engines_for_load(target_engine: str) -> str | None: + """加载前清场:卸载已驻留引擎并等待显存回收(#84)。 + + WHY: routes 的 load 端点把 voxcpm2 / indextts2 / indextts20 直接指到 + 专用加载器(routes/model.py 的 ``load_fn`` 分派),这条路径此前没有 + switch_engine 的卸载阶段,预检又是「当前空闲 vs 需求」的裸比较—— + 12GB 级显卡上从 UI 直切引擎必然 503 INSUFFICIENT_VRAM(issue #84 的 + 三段证据:无 [引擎切换] VRAM 检查日志、绕过 M-R1 记账、加载器不卸载)。 + + 与 switch_engine 的关系:传统切换路径已先 ``unload_model()``,此时本 + 函数检测不到驻留引擎、是幂等空操作;热待机路径必须由调用方传 + ``auto_unload=False`` 跳过(见 switch.py 调用点),否则「双引擎常驻」 + 的快切语义会被破坏。 + + 锁约定:专用加载器进入本函数时已持有 ``_model_lock``(RLock), + ``unload_model()`` 内部的 ``with _model_lock`` 依赖同线程可重入, + 与 switch_engine 阶段④的既有用法一致(见 state.py RLock WHY 注释)。 + + Args: + target_engine: 即将加载的引擎名(用于日志与语义标注)。 + + Returns: + str | None: 本次被卸载的驻留引擎名;没有驻留引擎时返回 ``None``。 + """ + from ..gpu_backend import GPUBackend, GPUBackendManager + from ..model_registry import ENGINE_VRAM_REQUIREMENTS + from .unload import unload_model + + resident: str | None = registry.current_engine + has_generic = any(inst is not None for inst in registry.get_all_engine_instances().values()) + if registry.voxcpm_model is None and registry.indextts2_engine is None and not has_generic: + return None + + logger.info(f"[模型加载] 检测到已驻留引擎 {resident or '未知'},先卸载再加载 {target_engine}") + gpu_device: Any = _state.get_gpu_device() + backend: GPUBackend = GPUBackendManager.detect_backend() + baseline_bytes: int = 0 + expected_release_bytes: int = int(ENGINE_VRAM_REQUIREMENTS.get(resident or "", 0.0) * 1024**3) + if backend != GPUBackend.CPU and gpu_device is not None: + with contextlib.suppress(Exception): + baseline_bytes = GPUBackendManager.memory_allocated(gpu_device) + + # RLock 同线程重入:见 state.py WHY 注释与 switch_engine 阶段④先例 + unload_model() + + if backend != GPUBackend.CPU and gpu_device is not None: + # 延迟导入避免循环依赖:switch.py 在模块级导入本模块(load.py) + from .switch import _wait_vram_freed + + with contextlib.suppress(Exception): + GPUBackendManager.empty_cache() + if _wait_vram_freed( + gpu_device, + baseline_allocated=baseline_bytes, + expected_release_bytes=expected_release_bytes, + ): + logger.info("[模型加载] 旧引擎显存已按预期回收") + else: + logger.warning( + "[模型加载] 卸载后显存回收未达预期(期望约 " + f"{expected_release_bytes / 1024**3:.2f}GB);若随后加载失败," + "请排查 [模型卸载] 日志中的强引用告警" + ) + return resident + + def get_persona_cache_stats() -> dict[str, Any]: """获取当前 Persona 嵌入缓存的统计信息。 @@ -448,23 +517,16 @@ def load_voxcpm2( # 模型加载开始时重置显存泄漏检测基线,避免加载期间显存上升导致误报 get_health_monitor().reset_vram_baseline() try: - # Unload current engine if any - old_model: Any = registry.voxcpm_model - old_asr: Any = registry.voxcpm_asr - registry.voxcpm_model = None - registry.voxcpm_asr = None - if old_model is not None: - del old_model - if old_asr is not None: - del old_asr - gc.collect() + # #84: 统一清场——原先这里只手工摘 voxcpm_model / voxcpm_asr 两个槽位 + # (漏掉 enhancer、引擎门面实例与 persona 缓存,也完全不清 IndexTTS 与 + # 通用引擎),现在统一走 _unload_loaded_engines_for_load 委托 + # unload_model() 全量卸载并核验显存回收;无驻留引擎时是快速空操作。 + _unload_loaded_engines_for_load(EngineName.VOXCPM2.value) + time.sleep(_LOAD_RETRY_AFTER_UNLOAD_SECONDS) + from ..gpu_backend import GPUBackend, GPUBackendManager backend: GPUBackend = GPUBackendManager.detect_backend() - if backend != GPUBackend.CPU: - with contextlib.suppress(Exception): - GPUBackendManager.empty_cache() - time.sleep(_LOAD_RETRY_AFTER_UNLOAD_SECONDS) gpu_device: Any = _state.get_gpu_device() @@ -487,12 +549,17 @@ def load_voxcpm2( def load_indextts2( progress_callback: Callable[..., None] | None = None, version: str = "2.5", + *, + auto_unload: bool = True, ) -> Generator[tuple[str, None, None, None], None, None]: """加载 IndexTTS 引擎(2.5 / 2.0 双版本共用,生成器进度事件流)。 Args: progress_callback: 预留回调参数(保持签名兼容;默认 ``None``)。 version: ``"2.5"``(默认)或 ``"2.0"``。决定权重目录、注册名与提示文案。 + auto_unload: 加载前是否卸载已驻留引擎(#84)。routes 直连时保持默认 + ``True``;switch_engine 热待机路径必须传 ``False``,否则 + 「双引擎常驻」的快切语义被破坏。 Yields: tuple[str, None, None, None]: ``(status_text, None, None, None)`` 四元组。 @@ -515,6 +582,14 @@ def load_indextts2( if not os.path.exists(model_path): raise FileNotFoundError(f"{label} 模型文件不存在: {model_path}\n请运行: python {download_script} 下载模型") + # #84: routes 直连本加载器时没有 switch_engine 的卸载阶段。若已有引擎 + # 驻留,先全量卸载并等待显存回收,否则下面的预检是「当前空闲 vs 需求」 + # 的裸比较——12GB 级显卡上从 UI 直切引擎必然 503。热待机路径由 + # switch_engine 显式传 auto_unload=False 跳过(语义是双引擎常驻)。 + unloaded_engine: str | None = None + if auto_unload: + unloaded_engine = _unload_loaded_engines_for_load(engine_name) + # Step 1: VRAM/RAM check from ..model_registry import estimate_engine_vram_need_gb @@ -551,7 +626,14 @@ def load_indextts2( "③ 确认已启用 bf16(fp32 需要约两倍显存);" "④ 最后才调低 config.yaml 的 models.vram_safety_margin_gb" "(会增加推理期显存溢出风险)。" - "已加载的引擎将自动回滚,不会丢失当前可用状态。" + + ( + # #84: 走到这里说明清场卸载已经做过、回收等待也已结束, + # 当前 free 就是卸载后的真实余量——报错必须说清这一点, + # 否则用户会误以为「没卸载旧引擎」(#84 之前的误判路径)。 + f"本次已自动卸载驻留引擎 {unloaded_engine} 并等待回收,当前可用即卸载后的真实余量。" + if unloaded_engine + else "已加载的引擎将自动回滚,不会丢失当前可用状态。" + ) ) except InsufficientVRAMError: raise @@ -666,15 +748,21 @@ def _idx_warmup_progress(msg: str) -> None: def load_indextts20( progress_callback: Callable[..., None] | None = None, + *, + auto_unload: bool = True, ) -> Generator[tuple[str, None, None, None], None, None]: """加载 IndexTTS 2.0 引擎(复用 ``load_indextts2``,version="2.0")。 与 2.5 共用同一推理代码包与引擎槽位(互斥),仅权重目录与入口类不同。 + Args: + auto_unload: 透传给 :func:`load_indextts2`(#84)。switch_engine 的 + 热待机路径必须传 ``False``,routes 直连保持默认 ``True``。 + Yields: 同 :func:`load_indextts2` 的 ``(status_text, None, None, None)`` 四元组。 """ - yield from load_indextts2(progress_callback=progress_callback, version="2.0") + yield from load_indextts2(progress_callback=progress_callback, version="2.0", auto_unload=auto_unload) # ==================================================================== diff --git a/app/integrated_app/model_manager_core/switch.py b/app/integrated_app/model_manager_core/switch.py index 6df742f6..f28f7e3e 100644 --- a/app/integrated_app/model_manager_core/switch.py +++ b/app/integrated_app/model_manager_core/switch.py @@ -536,9 +536,11 @@ def _forward(loader: Any) -> Any: if engine_name == EngineName.VOXCPM2.value: yield from _forward(_load_voxcpm2_engine(gpu_device, backend)) elif engine_name == EngineName.INDEXTTS2.value: - yield from _forward(load_indextts2()) + # #84: 热待机必须跳过加载前清场(语义就是双引擎常驻);传统 + # 路径已先 unload_model(),传 True 只是幂等空操作。 + yield from _forward(load_indextts2(auto_unload=not hot_standby)) elif engine_name == EngineName.INDEXTTS20.value: - yield from _forward(load_indextts20()) + yield from _forward(load_indextts20(auto_unload=not hot_standby)) else: # 通用新式引擎(声明式注册) yield from _forward(_load_generic_engine(engine_name)) diff --git a/tests/test_engine_switch.py b/tests/test_engine_switch.py index 5a1f6864..9d51d346 100644 --- a/tests/test_engine_switch.py +++ b/tests/test_engine_switch.py @@ -183,3 +183,99 @@ def test_vram_need_is_same_number_everywhere(self, caplog): reported = {float(v) for v in re.findall(r"需要 (\d+\.\d+)GB", caplog.text)} assert reported == {9.0} + + +class TestUnloadForDirectLoad: + """回归 #84:routes 直连专用加载器时,加载前必须先清场卸载驻留引擎。 + + 背景:load 端点对 voxcpm2 / indextts2 / indextts20 直接调专用加载器, + 绕过 switch_engine 的 M-R1 记账与卸载阶段,12GB 卡上直切必然 503。 + """ + + def test_helper_unloads_resident_engine_and_waits(self): + """有驻留引擎时:委托 unload_model、清缓存并按基线核验回收。""" + from integrated_app.model_manager_core.load import _unload_loaded_engines_for_load + + gb = 1024**3 + with ( + patch("integrated_app.model_manager_core.unload.unload_model") as mock_unload, + patch("integrated_app.model_manager_core.load.registry") as mock_reg, + patch("integrated_app.model_registry.ENGINE_VRAM_REQUIREMENTS", {"voxcpm2": 6.5}), + patch("integrated_app.gpu_backend.GPUBackendManager.detect_backend", return_value=GPUBackend.CUDA), + patch("integrated_app.gpu_backend.GPUBackendManager.memory_allocated", return_value=int(9.0 * gb)), + patch("integrated_app.gpu_backend.GPUBackendManager.empty_cache") as mock_empty, + patch("integrated_app.model_manager_core.switch._wait_vram_freed", return_value=True) as mock_wait, + patch("integrated_app.model_manager_core.state.get_gpu_device", return_value=0), + ): + mock_reg.current_engine = "voxcpm2" + mock_reg.voxcpm_model = object() + mock_reg.indextts2_engine = None + mock_reg.get_all_engine_instances.return_value = {} + unloaded = _unload_loaded_engines_for_load("indextts2") + + assert unloaded == "voxcpm2" + mock_unload.assert_called_once_with() + mock_empty.assert_called_once_with() + mock_wait.assert_called_once_with(0, baseline_allocated=int(9.0 * gb), expected_release_bytes=int(6.5 * gb)) + + def test_helper_noop_when_nothing_loaded(self): + """无驻留引擎时必须是零成本空操作(传统切换路径已卸载后复入此场景)。""" + from integrated_app.model_manager_core.load import _unload_loaded_engines_for_load + + with ( + patch("integrated_app.model_manager_core.unload.unload_model") as mock_unload, + patch("integrated_app.model_manager_core.load.registry") as mock_reg, + ): + mock_reg.current_engine = None + mock_reg.voxcpm_model = None + mock_reg.indextts2_engine = None + mock_reg.get_all_engine_instances.return_value = {} + unloaded = _unload_loaded_engines_for_load("voxcpm2") + + assert unloaded is None + mock_unload.assert_not_called() + + def test_load_indextts2_unloads_resident_engine_before_precheck(self): + """默认路径:load_indextts2 必须在预检前调用清场助手(接线证明)。""" + from integrated_app.model_manager_core.load import load_indextts2 + + gb = 1024**3 + with ( + patch( + "integrated_app.model_manager_core.load._unload_loaded_engines_for_load", + return_value="voxcpm2", + ) as mock_clear, + patch("integrated_app.model_manager_core.load.get_indextts2_model_path", return_value="fake/model"), + patch("integrated_app.model_manager_core.load.os") as mock_os, + patch("integrated_app.gpu_backend.GPUBackendManager.detect_backend", return_value=GPUBackend.CUDA), + patch( + "integrated_app.gpu_backend.GPUBackendManager.get_memory_info", + return_value=(int(11.94 * gb), int(6.04 * gb), int(6.12 * gb), int(5.9 * gb)), + ), + pytest.raises(InsufficientVRAMError, match="已自动卸载驻留引擎 voxcpm2"), + ): + mock_os.path.exists.return_value = True + list(load_indextts2()) + + mock_clear.assert_called_once_with("indextts2") + + def test_load_indextts2_skips_unload_when_auto_unload_false(self): + """热待机传 auto_unload=False 时不得清场(双引擎常驻语义)。""" + from integrated_app.model_manager_core.load import load_indextts2 + + gb = 1024**3 + with ( + patch("integrated_app.model_manager_core.load._unload_loaded_engines_for_load") as mock_clear, + patch("integrated_app.model_manager_core.load.get_indextts2_model_path", return_value="fake/model"), + patch("integrated_app.model_manager_core.load.os") as mock_os, + patch("integrated_app.gpu_backend.GPUBackendManager.detect_backend", return_value=GPUBackend.CUDA), + patch( + "integrated_app.gpu_backend.GPUBackendManager.get_memory_info", + return_value=(int(11.94 * gb), int(6.04 * gb), int(6.12 * gb), int(5.9 * gb)), + ), + pytest.raises(InsufficientVRAMError, match="显存不足"), + ): + mock_os.path.exists.return_value = True + list(load_indextts2(auto_unload=False)) + + mock_clear.assert_not_called() From e5c4edf1adf455c9af6480e8ca53fb11dbaa6bf8 Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Tue, 22 Sep 2026 08:35:23 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(security):=20LoRA=20=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E4=B8=8E=E5=8E=86=E5=8F=B2=E6=93=8D=E4=BD=9C=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E6=94=B9=E4=BA=8B=E4=BB=B6=E9=97=AD=E5=8C=85=E6=8E=A5=E7=BA=BF?= =?UTF-8?q?=EF=BC=8C=E6=B6=88=E9=99=A4=20onclick=20=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=20(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原先两处把用户可控字符串只做 JS 引号转义就拼进 innerHTML 的 onclick 属性——JS 字符串转义不等于 HTML 属性转义,文件名含双引号即可闭合属性 注入 ;服务化部署(TTS_API_AUTH_ENABLED=1 + 0.0.0.0) 下即存储型 XSS(issue #99 分诊表 3 处)。 - lora_manager.html:卡片改 createElement + textContent + dataset + addEventListener;同步把同源未转义的 name/description/path 插值一并 收进 textContent(超出 issue 点名的 onclick 两处,属同一构造点的 同类注入面);SVG 图标为纯静态字面量经 innerHTML 注入。 - history.html:play/download/hide/delete 四按钮同样改事件闭包传参, escName 拼接路径整体移除;e.currentTarget 替代原先内联 onclick 的 this 语义。 - 行为保持不变:按钮文案/回调/参数/stopPropagation 时序一致; syncLoraCardStates 依赖的 .lora-status-badge 与 dataset.path 保留。 验证:tests/frontend 冒烟链全绿(render_pages 渲染 + jsdom 结构断言 + 31 个模板内联脚本语法解析);加载/卸载 LoRA 与历史播放的真机点验需人工。 Signed-off-by: ReSerendipity --- .../templates/tabs/history.html | 35 +++++++-- .../templates/tabs/lora_manager.html | 72 +++++++++++++++---- 2 files changed, 87 insertions(+), 20 deletions(-) diff --git a/app/integrated_app/templates/tabs/history.html b/app/integrated_app/templates/tabs/history.html index 3f65007b..d82f5ee1 100644 --- a/app/integrated_app/templates/tabs/history.html +++ b/app/integrated_app/templates/tabs/history.html @@ -388,6 +388,24 @@ return div.innerHTML; } + // #99: 动作按钮接线辅助——数据经闭包传递,不再拼进 onclick 属性。 + // (原先把文件名只做 JS 引号转义就拼进 innerHTML 的 onclick, + // 文件名含双引号即可闭合属性注入 HTML;改用 createElement + + // addEventListener 后数据不再进入任何 HTML/属性上下文。) + function _historyIconBtn(title, colorStyle, svgHtml, onClick) { + var btn = document.createElement('button'); + btn.className = 'btn-icon'; + btn.title = title; + if (colorStyle) btn.style.cssText = colorStyle; + btn.innerHTML = svgHtml; // 纯静态字面量,无任何外部输入 + btn.addEventListener('click', onClick); + return btn; + } + var _SVG_HISTORY_PLAY = ''; + var _SVG_HISTORY_DOWNLOAD = ''; + var _SVG_HISTORY_HIDE = ''; + var _SVG_HISTORY_DELETE = ''; + // 根据 filename 推断引擎 function inferEngine(filename) { if (!filename) return 'voxcpm2'; @@ -453,13 +471,16 @@ var tdActions = document.createElement('td'); tdActions.setAttribute('data-label', '{{ "actions"|t(lang, default="操作") }}'); tdActions.style.textAlign = 'right'; - var escName = String(r[1]).replace(/'/g, "\\'"); - tdActions.innerHTML = '
' + - '' + - '' + - '' + - '' + - '
'; + // #99: r[1] 是历史库里的音频文件名(用户可控),原先只做 JS 引号 + // 转义就拼进 onclick 属性——含双引号的文件名即可闭合属性注入 HTML。 + // 改为事件闭包传参,名称不再进入任何 HTML/属性上下文。 + var actionsWrap = document.createElement('div'); + actionsWrap.style.cssText = 'display:inline-flex;gap:4px'; + actionsWrap.appendChild(_historyIconBtn('{{ "play"|t(lang) }}', '', _SVG_HISTORY_PLAY, function(e) { playHistoryAudio(String(r[1]), e.currentTarget); })); + actionsWrap.appendChild(_historyIconBtn('{{ "download"|t(lang, default="下载") }}', '', _SVG_HISTORY_DOWNLOAD, function() { downloadHistoryAudio(String(r[1])); })); + actionsWrap.appendChild(_historyIconBtn('{{ "hide"|t(lang, default="隐藏") }}', '', _SVG_HISTORY_HIDE, function() { hideHistoryRecord(r[0]); })); + actionsWrap.appendChild(_historyIconBtn('{{ "delete"|t(lang) }}', 'color:var(--red)', _SVG_HISTORY_DELETE, function(e) { deleteHistoryAudio(r[0], String(r[1]), e.currentTarget); })); + tdActions.appendChild(actionsWrap); tr.appendChild(tdCheck); tr.appendChild(tdName); diff --git a/app/integrated_app/templates/tabs/lora_manager.html b/app/integrated_app/templates/tabs/lora_manager.html index f29a2372..7f4830d9 100644 --- a/app/integrated_app/templates/tabs/lora_manager.html +++ b/app/integrated_app/templates/tabs/lora_manager.html @@ -190,6 +190,22 @@ }); grid.className = 'persona-grid' + (_loraView === 'list' ? ' list-view' : ''); grid.innerHTML = ''; + // #99: 卡片动作按钮改用 createElement + addEventListener 接线。原先把 + // ckpt.path 只做 JS 引号转义就拼进 innerHTML 的 onclick 属性——文件名 + // 含双引号即可闭合属性注入 HTML(属性上下文 ≠ JS 字符串上下文)。 + // 数据现在经闭包传递,不再进入任何 HTML/属性上下文;SVG 为纯静态字面量。 + function _loraIconBtn(title, colorStyle, svgHtml, onClick) { + var btn = document.createElement('button'); + btn.className = 'btn-icon'; + btn.title = title; + if (colorStyle) btn.style.cssText = colorStyle; + btn.innerHTML = svgHtml; // 纯静态字面量,无任何外部输入 + btn.addEventListener('click', onClick); + return btn; + } + var _SVG_LORA_ENABLE = ''; + var _SVG_LORA_LOAD = ''; + var _SVG_LORA_UNLOAD = ''; filtered.forEach(function(ckpt) { var card = document.createElement('div'); card.className = 'persona-card'; @@ -198,19 +214,49 @@ var r = ckpt.r || '-'; var alpha = ckpt.lora_alpha || '-'; var steps = ckpt.steps || '-'; - card.innerHTML = - '
' + - '
' + (ckpt.name || ckpt.path) + '
' + - '{{ "not_enabled"|t(lang) }}' + - '
' + - '
' + (ckpt.description || ckpt.name || '') + '
' + - '
R: ' + r + 'α: ' + alpha + '' + steps + ' steps
' + - '
{{ "path"|t(lang) }}: ' + ckpt.path + '
' + - '
' + - '' + - '' + - '' + - '
'; + + // #99: 名称/描述/路径同步改用 textContent 插值——它们与 onclick 同源 + // (磁盘文件名),原来也是未转义直拼 innerHTML。 + var head = document.createElement('div'); + head.style.cssText = 'display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:4px'; + var nameEl = document.createElement('div'); + nameEl.className = 'persona-card-name'; + nameEl.textContent = ckpt.name || ckpt.path; + var badge = document.createElement('span'); + badge.className = 'lora-status-badge'; + badge.dataset.path = ckpt.path; + badge.style.cssText = 'font-size:9px;padding:2px 6px;border-radius:8px;background:var(--bg3);color:var(--t3)'; + badge.textContent = '{{ "not_enabled"|t(lang) }}'; + head.appendChild(nameEl); + head.appendChild(badge); + + var desc = document.createElement('div'); + desc.className = 'persona-card-desc'; + desc.textContent = ckpt.description || ckpt.name || ''; + + var tags = document.createElement('div'); + tags.className = 'persona-card-tags'; + ['R: ' + r, 'α: ' + alpha, steps + ' steps'].forEach(function(tagText) { + var tag = document.createElement('span'); + tag.textContent = tagText; + tags.appendChild(tag); + }); + + var pathEl = document.createElement('div'); + pathEl.style.cssText = 'font-size:10px;color:var(--t3);margin-bottom:8px;break-all'; + pathEl.textContent = '{{ "path"|t(lang) }}: ' + ckpt.path; + + var actions = document.createElement('div'); + actions.className = 'persona-card-actions'; + actions.appendChild(_loraIconBtn('{{ "enable"|t(lang) }}', 'color:var(--green)', _SVG_LORA_ENABLE, function(e) { e.stopPropagation(); enableLoraByCard(ckpt.path); })); + actions.appendChild(_loraIconBtn('{{ "load"|t(lang) }}', '', _SVG_LORA_LOAD, function(e) { e.stopPropagation(); loadLoraByPath(ckpt.path); })); + actions.appendChild(_loraIconBtn('{{ "unload"|t(lang) }}', 'color:var(--orange)', _SVG_LORA_UNLOAD, function(e) { e.stopPropagation(); unloadLora(); })); + + card.appendChild(head); + card.appendChild(desc); + card.appendChild(tags); + card.appendChild(pathEl); + card.appendChild(actions); grid.appendChild(card); }); // Refresh active states based on current loaded state From 8ed694202575ffbeb0b3ea702f18aca82a1bbe02 Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Tue, 22 Sep 2026 11:35:20 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(security):=20updateLoraInfo=20=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E9=9D=A2=E6=9D=BF=E5=90=8C=E6=BA=90=E8=A3=B8=E6=8F=92?= =?UTF-8?q?=E5=80=BC=E6=94=B6=E5=8F=A3=EF=BC=88#99=20UI=20=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E4=B8=AD=E5=8F=91=E7=8E=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: ReSerendipity #99 的分诊扫描漏掉了第 4 个注入点:updateLoraInfo() 把选中检查点的 name / path / base_model / r / lora_alpha / steps 裸拼进 innerHTML—— 这些字段源自检查点目录名与 adapter_config.json,均用户可控。 发现过程:PR 分支的 Playwright UI 验证里,对恶意命名检查点点击[启用] 后页面实际弹出了 alert(1)(img src=x onerror 在 info 面板执行), 复现 4/4 次点击。本提交把信息面板改为 DOM API + textContent,与卡片 修法一致;视觉效果不变(label: value 行)。 验证:tests/frontend Playwright(系统 Edge)真页面驱动——mock 恶意 检查点后全文档 img[src=x] 计数 0、info 面板以文本呈现恶意名。 --- .../templates/tabs/lora_manager.html | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/app/integrated_app/templates/tabs/lora_manager.html b/app/integrated_app/templates/tabs/lora_manager.html index 7f4830d9..e40652ff 100644 --- a/app/integrated_app/templates/tabs/lora_manager.html +++ b/app/integrated_app/templates/tabs/lora_manager.html @@ -347,13 +347,23 @@ if (opt && opt.value !== 'None' && opt.dataset.info) { try { var info = JSON.parse(opt.dataset.info); - contentDiv.innerHTML = - '
{{ "name"|t(lang) }}: ' + (info.name || '-') + '
' + - '
{{ "path"|t(lang) }}: ' + info.path + '
' + - (info.base_model ? '
{{ "base_model"|t(lang) }}: ' + info.base_model + '
' : '') + - (info.r ? '
{{ "lora_rank"|t(lang) }}: ' + info.r + '
' : '') + - (info.lora_alpha ? '
{{ "lora_alpha"|t(lang) }}: ' + info.lora_alpha + '
' : '') + - (info.steps ? '
{{ "training_steps"|t(lang) }}: ' + info.steps + '
' : ''); + // #99: info 面板原先把 name/path/base_model 等裸拼进 innerHTML—— + // 它们源自检查点目录名 / adapter_config.json(均用户可控),与 + // 卡片同一类注入面(UI 验证中 alert 实锤)。改 DOM API + textContent。 + contentDiv.textContent = ''; + var rows = [ + ['{{ "name"|t(lang) }}', info.name || '-'], + ['{{ "path"|t(lang) }}', info.path] + ]; + if (info.base_model) rows.push(['{{ "base_model"|t(lang) }}', info.base_model]); + if (info.r) rows.push(['{{ "lora_rank"|t(lang) }}', info.r]); + if (info.lora_alpha) rows.push(['{{ "lora_alpha"|t(lang) }}', info.lora_alpha]); + if (info.steps) rows.push(['{{ "training_steps"|t(lang) }}', info.steps]); + rows.forEach(function(pair) { + var line = document.createElement('div'); + line.textContent = pair[0] + ': ' + pair[1]; + contentDiv.appendChild(line); + }); infoDiv.style.display = ''; } catch(e) { infoDiv.style.display = 'none'; From 4f14a91fa169780d445d4d85d610c2da61caae19 Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Tue, 22 Sep 2026 12:42:19 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test:=20fake=5Floader=20=E6=A1=A9=E9=80=8F?= =?UTF-8?q?=E4=BC=A0=20auto=5Funload=20kwarg=EF=BC=8C=E9=80=82=E9=85=8D=20?= =?UTF-8?q?#84=20=E7=9A=84=20loader=20=E5=A5=91=E7=BA=A6=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit switch_engine 现在按热待机与否向 load_indextts2/load_indextts20 传 auto_unload(#84),TestMissingWeights 的零参 fake_loader 桩会抛 TypeError 并被异常分支误判成需要回滚。桩签名加 **_kwargs 透传, M-R9 升级断言与热待机「不回滚」断言均保持原语义。 验证:test_release_robustness.py + test_engine_switch.py 共 20 passed。 Signed-off-by: ReSerendipity --- tests/test_release_robustness.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_release_robustness.py b/tests/test_release_robustness.py index 360834b3..8fa38fe3 100644 --- a/tests/test_release_robustness.py +++ b/tests/test_release_robustness.py @@ -73,7 +73,7 @@ def test_switch_engine_escalates_swallowed_loader_failure(self, monkeypatch): """ events: list[str] = [] - def fake_loader(): + def fake_loader(**_kwargs): # auto_unload kwarg(#84)加入 loader 契约后桩需透传 yield "正在加载 IndexTTS 2.5 引擎...", None, None, None yield "IndexTTS 2.5 加载失败: FileNotFoundError: 模型文件不存在", None, None, None @@ -105,7 +105,7 @@ def test_switch_engine_escalates_failure_text_on_hot_standby_path(self, monkeypa """热待机路径(未卸载)也必须升级成异常,但不能触发回滚。""" events: list[str] = [] - def fake_loader(): + def fake_loader(**_kwargs): # auto_unload kwarg(#84)加入 loader 契约后桩需透传 yield "IndexTTS 2.5 加载失败: 显存不足", None, None, None with (