Skip to content

Commit f970236

Browse files
authored
Fix Linux desktop package missing default Live2D model assets (#1452)
* fix(linux): keep pet UI hidden during model manager changes Preserve the model-manager hide state across model reloads and character-card switches so Linux pet overlays do not reveal the chat box, avatar, or floating buttons while the manager is still open. Re-hide recreated model controls after reloads and skip chat/button restoration when model manager owns visibility. * fix: include default Live2D model in Linux desktop builds Use the existing build_frontend.sh entrypoint for Linux desktop workflows so assets/yui-origin.tar.gz is unpacked before packaging. This keeps Windows and macOS on their existing build paths while fixing the Linux package missing static/yui-origin. Also guard page_config against null model_info responses so missing model resources do not crash the config endpoint. * fix: preserve main UI hidden state during model reload Treat preserveHiddenState as an alias for skipHiddenStateUpdate in handleHideMainUI so model reload and character-switch callers do not re-broadcast the model-manager hidden state. * fix: surface default Live2D fallback failures Return an explicit failure from current_live2d_model when the default Live2D fallback is unavailable instead of reporting success with a null model_info. Show that failure in the model manager and assert the Linux desktop build contains the unpacked yui-origin model config before packaging. * ci: share frontend build verification Add a shared frontend build verifier that reports a clear error when the unpacked yui-origin model config is missing after build_frontend.sh. Use the verifier from both Linux desktop build workflows instead of duplicating a bare test command.
1 parent ba0c074 commit f970236

9 files changed

Lines changed: 164 additions & 31 deletions

File tree

.github/workflows/build-desktop-linux.yml

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -210,12 +210,8 @@ jobs:
210210
- name: Build all frontend projects
211211
shell: bash
212212
run: |
213-
cd frontend/plugin-manager
214-
npm ci
215-
npm run build-only
216-
cd ../react-neko-chat
217-
npm ci
218-
npm run build
213+
./build_frontend.sh
214+
bash scripts/verify_frontend_build.sh
219215
220216
# --- Build with Nuitka ---
221217
- name: Build with Nuitka

.github/workflows/build-desktop.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,11 @@ jobs:
249249
- name: Build all frontend projects
250250
shell: bash
251251
run: |
252+
if [[ "$RUNNER_OS" == "Linux" ]]; then
253+
./build_frontend.sh
254+
bash scripts/verify_frontend_build.sh
255+
exit 0
256+
fi
252257
cd frontend/plugin-manager
253258
npm ci
254259
npm run build-only

main_routers/characters_router.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1762,6 +1762,17 @@ async def get_current_live2d_model(catgirl_name: str = "", item_id: str = ""):
17621762
if model_info and isinstance(model_info.get('path'), str):
17631763
model_info['path'] = encode_url_path(model_info['path'])
17641764

1765+
if not model_info or not model_info.get('path'):
1766+
error_message = f"默认Live2D模型 {DEFAULT_LIVE2D_MODEL_NAME} 不可用"
1767+
logger.error(error_message)
1768+
return JSONResponse(content={
1769+
'success': False,
1770+
'catgirl_name': catgirl_name,
1771+
'model_name': live2d_model_name or DEFAULT_LIVE2D_MODEL_NAME,
1772+
'model_info': None,
1773+
'error': error_message,
1774+
})
1775+
17651776
return JSONResponse(content={
17661777
'success': True,
17671778
'catgirl_name': catgirl_name,

main_routers/config_router.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ async def get_page_config(response: Response, lanlan_name: str = ""):
284284
# 提取JSONResponse中的内容
285285
model_data = model_response.body.decode('utf-8')
286286
model_json = json.loads(model_data)
287-
model_info = model_json.get('model_info', {})
287+
model_info = model_json.get('model_info') or {}
288288
model_path = model_info.get('path', '')
289289

290290
result = {

scripts/verify_frontend_build.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env bash
2+
# Verify frontend build outputs that are required by packaged desktop builds.
3+
set -euo pipefail
4+
5+
REQUIRED_MODEL_FILE="static/yui-origin/yui-origin.model3.json"
6+
7+
if [ ! -f "$REQUIRED_MODEL_FILE" ]; then
8+
echo "ERROR: missing $REQUIRED_MODEL_FILE after build_frontend.sh" >&2
9+
exit 1
10+
fi

static/app-character.js

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,30 @@
7373
}
7474
}
7575

76+
function isMainUIHiddenByModelManager() {
77+
if (typeof window.isMainUIHiddenByModelManager === 'function') {
78+
return window.isMainUIHiddenByModelManager();
79+
}
80+
return window.__NEKO_MAIN_UI_HIDDEN_BY_MODEL_MANAGER === true;
81+
}
82+
83+
function rehideMainUIIfModelManagerOwnsVisibility(reason) {
84+
if (!isMainUIHiddenByModelManager()) return false;
85+
if (typeof window.handleHideMainUI === 'function') {
86+
window.handleHideMainUI({ preserveHiddenState: true, reason: reason || 'character-switch' });
87+
}
88+
return true;
89+
}
90+
91+
function restoreChatComposerUnlessModelManagerHidden(chatContainer, textInputArea) {
92+
if (isMainUIHiddenByModelManager()) return;
93+
if (chatContainer) chatContainer.classList.remove('minimized');
94+
if (textInputArea) textInputArea.classList.remove('hidden');
95+
if (typeof window.syncVoiceChatComposerHidden === 'function') {
96+
window.syncVoiceChatComposerHidden(false);
97+
}
98+
}
99+
76100
function supportsLocalModelRuntime() {
77101
return !/^\/chat(?:\/|$)/.test(window.location.pathname || '');
78102
}
@@ -744,7 +768,7 @@
744768
if (!supportsLocalModelRuntime()) {
745769
console.log('[猫娘切换] 当前页面不加载本地模型,跳过模型热切换');
746770
// chat.html 不走模型分支,在此显式恢复 composer(兜底 onclose 路径)
747-
if (typeof window.syncVoiceChatComposerHidden === 'function') {
771+
if (!isMainUIHiddenByModelManager() && typeof window.syncVoiceChatComposerHidden === 'function') {
748772
window.syncVoiceChatComposerHidden(false);
749773
}
750774
} else if (effectiveModelType === 'vrm') {
@@ -1135,17 +1159,14 @@
11351159
const chatContainerVrm = document.getElementById('chat-container');
11361160
const textInputArea = document.getElementById('text-input-area');
11371161
console.log('[猫娘切换] VRM - 恢复对话框 - chatContainer存在:', !!chatContainerVrm, '当前类:', chatContainerVrm ? chatContainerVrm.className : 'N/A');
1138-
if (chatContainerVrm) chatContainerVrm.classList.remove('minimized');
1139-
if (textInputArea) textInputArea.classList.remove('hidden');
1140-
if (typeof window.syncVoiceChatComposerHidden === 'function') {
1141-
window.syncVoiceChatComposerHidden(false);
1142-
}
1162+
restoreChatComposerUnlessModelManagerHidden(chatContainerVrm, textInputArea);
11431163
console.log('[猫娘切换] VRM - 对话框已恢复,当前类:', chatContainerVrm ? chatContainerVrm.className : 'N/A');
11441164

11451165
// 确保 VRM 按钮和锁图标可见
11461166
setTimeout(() => {
11471167
// fire-and-forget 延迟回调:用户在 300ms 内切到别角色时旧回调会重建错类型按钮
11481168
if (!isStillActiveSwitchTarget()) return;
1169+
if (rehideMainUIIfModelManagerOwnsVisibility('character-switch-vrm-delay')) return;
11491170
const vrmButtons = document.getElementById('vrm-floating-buttons');
11501171
console.log('[猫娘切换] VRM按钮检查 - 存在:', !!vrmButtons);
11511172
if (vrmButtons) {
@@ -1368,15 +1389,12 @@
13681389

13691390
const chatContainerMmd = document.getElementById('chat-container');
13701391
const textInputAreaMmd = document.getElementById('text-input-area');
1371-
if (chatContainerMmd) chatContainerMmd.classList.remove('minimized');
1372-
if (textInputAreaMmd) textInputAreaMmd.classList.remove('hidden');
1373-
if (typeof window.syncVoiceChatComposerHidden === 'function') {
1374-
window.syncVoiceChatComposerHidden(false);
1375-
}
1392+
restoreChatComposerUnlessModelManagerHidden(chatContainerMmd, textInputAreaMmd);
13761393

13771394
// 延时显示 MMD 浮动按钮和锁图标
13781395
setTimeout(() => {
13791396
if (!isStillActiveSwitchTarget()) return;
1397+
if (rehideMainUIIfModelManagerOwnsVisibility('character-switch-mmd-delay')) return;
13801398
const mmdButtons = document.getElementById('mmd-floating-buttons');
13811399
if (mmdButtons) {
13821400
mmdButtons.style.removeProperty('display');
@@ -1556,15 +1574,12 @@
15561574

15571575
const chatContainerL2d = document.getElementById('chat-container');
15581576
const textInputAreaL2d = document.getElementById('text-input-area');
1559-
if (chatContainerL2d) chatContainerL2d.classList.remove('minimized');
1560-
if (textInputAreaL2d) textInputAreaL2d.classList.remove('hidden');
1561-
if (typeof window.syncVoiceChatComposerHidden === 'function') {
1562-
window.syncVoiceChatComposerHidden(false);
1563-
}
1577+
restoreChatComposerUnlessModelManagerHidden(chatContainerL2d, textInputAreaL2d);
15641578

15651579
// 延时重启 Ticker 和显示按钮(双重保险)
15661580
setTimeout(() => {
15671581
if (!isStillActiveSwitchTarget()) return;
1582+
if (rehideMainUIIfModelManagerOwnsVisibility('character-switch-live2d-delay')) return;
15681583

15691584
window.dispatchEvent(new Event('resize'));
15701585

@@ -1645,6 +1660,7 @@
16451660
Promise.resolve(window.resetAgentUiForCharacterSwitch(newCatgirl))
16461661
.catch(err => console.warn('[猫娘切换] 刷新猫爪状态失败:', err));
16471662
}
1663+
rehideMainUIIfModelManagerOwnsVisibility('character-switch-commit');
16481664
showStatusToast(window.t ? window.t('app.switchedCatgirl', { name: newCatgirl }) : `已切换到 ${newCatgirl}`, 3000);
16491665

16501666
// 【成就】解锁换肤成就

static/app-interpage.js

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
const mod = {};
2525
const S = window.appState;
2626
// const C = window.appConst; // not used in this module currently
27+
const MAIN_UI_HIDDEN_BY_MODEL_MANAGER_KEY = '__NEKO_MAIN_UI_HIDDEN_BY_MODEL_MANAGER';
2728

2829
// =====================================================================
2930
// Message deduplication (BC + postMessage deliver the same message twice)
@@ -43,6 +44,51 @@
4344
return false;
4445
}
4546

47+
function isMainUIHiddenByModelManager() {
48+
return window[MAIN_UI_HIDDEN_BY_MODEL_MANAGER_KEY] === true;
49+
}
50+
51+
function ensureMainUIHiddenStyle() {
52+
if (document.getElementById('neko-main-ui-hidden-by-model-manager-style')) return;
53+
var style = document.createElement('style');
54+
style.id = 'neko-main-ui-hidden-by-model-manager-style';
55+
style.textContent = [
56+
'body.neko-main-ui-hidden-by-model-manager #live2d-container,',
57+
'body.neko-main-ui-hidden-by-model-manager #vrm-container,',
58+
'body.neko-main-ui-hidden-by-model-manager #mmd-container,',
59+
'body.neko-main-ui-hidden-by-model-manager #live2d-canvas,',
60+
'body.neko-main-ui-hidden-by-model-manager #vrm-canvas,',
61+
'body.neko-main-ui-hidden-by-model-manager #mmd-canvas,',
62+
'body.neko-main-ui-hidden-by-model-manager #live2d-floating-buttons,',
63+
'body.neko-main-ui-hidden-by-model-manager #vrm-floating-buttons,',
64+
'body.neko-main-ui-hidden-by-model-manager #mmd-floating-buttons,',
65+
'body.neko-main-ui-hidden-by-model-manager #live2d-lock-icon,',
66+
'body.neko-main-ui-hidden-by-model-manager #vrm-lock-icon,',
67+
'body.neko-main-ui-hidden-by-model-manager #mmd-lock-icon,',
68+
'body.neko-main-ui-hidden-by-model-manager #live2d-return-button-container,',
69+
'body.neko-main-ui-hidden-by-model-manager #vrm-return-button-container,',
70+
'body.neko-main-ui-hidden-by-model-manager #mmd-return-button-container {',
71+
' display: none !important;',
72+
' visibility: hidden !important;',
73+
' pointer-events: none !important;',
74+
'}'
75+
].join('\n');
76+
(document.head || document.documentElement).appendChild(style);
77+
}
78+
79+
function setMainUIHiddenByModelManager(hidden) {
80+
window[MAIN_UI_HIDDEN_BY_MODEL_MANAGER_KEY] = !!hidden;
81+
ensureMainUIHiddenStyle();
82+
if (document.body) {
83+
document.body.classList.toggle('neko-main-ui-hidden-by-model-manager', !!hidden);
84+
}
85+
try {
86+
window.dispatchEvent(new CustomEvent('neko:main-ui-hidden-by-model-manager-changed', {
87+
detail: { hidden: !!hidden }
88+
}));
89+
} catch (_) {}
90+
}
91+
4692
function applyTutorialChatIdentityOverride(payload) {
4793
var detail = payload || {};
4894
if (detail.active) {
@@ -892,6 +938,13 @@
892938
window._modelReloadInFlight = false;
893939
resolveReload();
894940

941+
// If the model manager is still open, keep the Pet UI hidden even
942+
// though the reload path briefly re-created containers/buttons.
943+
if (isMainUIHiddenByModelManager()) {
944+
console.log('[Model] 主界面处于模型管理隐藏状态,模型重载完成后重新隐藏 UI');
945+
handleHideMainUI({ preserveHiddenState: true });
946+
}
947+
895948
// Process any queued reload request
896949
if (window._pendingModelReload) {
897950
console.log('[Model] 执行待处理的模型重载请求');
@@ -1131,8 +1184,13 @@
11311184
/**
11321185
* Hide main-page model rendering (entering model manager).
11331186
*/
1134-
function handleHideMainUI() {
1187+
function handleHideMainUI(options) {
11351188
if (!_isModelHostPage()) return;
1189+
options = options || {};
1190+
var skipHiddenStateUpdate = options.skipHiddenStateUpdate || options.preserveHiddenState;
1191+
if (!skipHiddenStateUpdate) {
1192+
setMainUIHiddenByModelManager(true);
1193+
}
11361194
console.log('[UI] 隐藏主界面并暂停渲染');
11371195

11381196
try {
@@ -1206,13 +1264,15 @@
12061264
'#live2d-lock-icon, #vrm-lock-icon, #mmd-lock-icon, ' +
12071265
'#live2d-return-button-container, #vrm-return-button-container, #mmd-return-button-container'
12081266
).forEach(function (el) {
1209-
var computedDisplay = '';
1210-
try {
1211-
computedDisplay = window.getComputedStyle(el).display || '';
1212-
} catch (_) {}
1213-
el.dataset.nekoPreHideDisplay = computedDisplay && computedDisplay !== 'none'
1214-
? computedDisplay
1215-
: (el.style.display || 'none');
1267+
if (!el.dataset.nekoPreHideDisplay) {
1268+
var computedDisplay = '';
1269+
try {
1270+
computedDisplay = window.getComputedStyle(el).display || '';
1271+
} catch (_) {}
1272+
el.dataset.nekoPreHideDisplay = computedDisplay && computedDisplay !== 'none'
1273+
? computedDisplay
1274+
: (el.style.display || 'none');
1275+
}
12161276
el.style.display = 'none';
12171277
});
12181278
} catch (error) {
@@ -1225,6 +1285,7 @@
12251285
*/
12261286
function handleShowMainUI() {
12271287
if (!_isModelHostPage()) return;
1288+
setMainUIHiddenByModelManager(false);
12281289
// 模型重载进行中时跳过:handleModelReload 自己会正确切换容器,
12291290
// 此时 lanlan_config.model_type 尚未更新,handleShowMainUI 会
12301291
// 错误地恢复旧模型类型的容器,导致需要切换两次才能成功。
@@ -2277,6 +2338,7 @@
22772338
mod.resetToDefaultModel = resetToDefaultModel;
22782339
mod.handleHideMainUI = handleHideMainUI;
22792340
mod.handleShowMainUI = handleShowMainUI;
2341+
mod.isMainUIHiddenByModelManager = isMainUIHiddenByModelManager;
22802342
mod.handleMemoryEdited = handleMemoryEdited;
22812343
mod.cleanupLive2DOverlayUI = cleanupLive2DOverlayUI;
22822344
mod.cleanupVRMOverlayUI = cleanupVRMOverlayUI;
@@ -2292,6 +2354,7 @@
22922354
window.resetToDefaultModel = resetToDefaultModel;
22932355
window.handleHideMainUI = handleHideMainUI;
22942356
window.handleShowMainUI = handleShowMainUI;
2357+
window.isMainUIHiddenByModelManager = isMainUIHiddenByModelManager;
22952358
window.cleanupLive2DOverlayUI = cleanupLive2DOverlayUI;
22962359
window.cleanupVRMOverlayUI = cleanupVRMOverlayUI;
22972360
window.cleanupMMDOverlayUI = cleanupMMDOverlayUI;

static/js/model_manager.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9490,6 +9490,7 @@ document.addEventListener('DOMContentLoaded', async () => {
94909490
const currentModelData = await RequestHelper.fetchJson(apiUrl);
94919491

94929492
if (!currentModelData.success) {
9493+
showStatus(currentModelData.error || t('live2d.loadCurrentModelFailed', '加载当前角色模型失败'));
94939494
return;
94949495
}
94959496

tests/unit/test_characters_router_model_settings.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,37 @@ async def test_switching_self_created_character_to_workshop_model_marks_current_
226226
assert get_reserved(saved_catgirl, 'character_origin', 'source_id', default='') == ''
227227

228228

229+
@pytest.mark.asyncio
230+
async def test_current_live2d_model_reports_failure_when_default_fallback_is_missing(monkeypatch):
231+
characters = {
232+
'当前猫娘': '测试角色',
233+
'猫娘': {
234+
'测试角色': {
235+
'_reserved': {
236+
'avatar': {
237+
'live2d': {
238+
'model_path': '',
239+
},
240+
},
241+
},
242+
},
243+
},
244+
}
245+
config_manager = DummyConfigManager(characters)
246+
247+
monkeypatch.setattr(characters_router_module, 'get_config_manager', lambda: config_manager)
248+
monkeypatch.setattr(characters_router_module, 'find_models', lambda: [])
249+
monkeypatch.setattr(characters_router_module, 'find_model_directory', lambda _name: (None, ''))
250+
251+
response = await characters_router_module.get_current_live2d_model('测试角色')
252+
body = json.loads(response.body)
253+
254+
assert body['success'] is False
255+
assert body['model_name'] == characters_router_module.DEFAULT_LIVE2D_MODEL_NAME
256+
assert body['model_info'] is None
257+
assert '默认Live2D模型' in body['error']
258+
259+
229260
def test_live3d_sub_type_prefers_persisted_active_sub_type_when_both_paths_exist():
230261
catgirl = _build_characters_fixture()['猫娘']['测试角色']
231262

0 commit comments

Comments
 (0)