Skip to content

Commit 756ed81

Browse files
authored
调整vrm注视逻辑、节约系统资源;组织voice_id假变更导致的live2d模型刷新 (#447)
* refactor(plugin): split built-in and user plugin roots Treat plugin.plugins as built-in packaged plugins and move user plugin discovery to the ConfigManager documents plugins directory. This avoids packaged namespace collisions while keeping plugin loading, config resolution, and extension injection working across both roots. Made-with: Cursor * fix(plugin): preserve legacy root semantics and skip builtin sys.path injection Avoid re-exposing built-in plugins through top-level import roots in packaged builds, and restore the old single-root compatibility behavior for legacy callers while steering new code toward explicit multi-root settings. Made-with: Cursor * feat(mllm): add user input validation for message processing - Introduced a new function `_message_has_user_input` to validate if messages contain user input. - Updated `LMMAgent` to raise a `ValueError` if no valid user messages are found before LLM generation. - Enhanced user input handling to support various content types, ensuring robust message processing. This change improves the reliability of message handling in the LLM generation process. * fix(characters_router): improve voice_id update handling and validation - Added checks for missing voice_id in update requests, returning early if absent. - Implemented idempotency to skip unnecessary updates when the voice_id hasn't changed. - Enhanced logging to provide clearer information on voice_id updates. - Updated response structure to include voice_id_changed status for better client-side handling. These changes enhance the robustness of the voice_id update process and improve user experience.
1 parent a3a065b commit 756ed81

14 files changed

Lines changed: 306 additions & 310 deletions

File tree

brain/cua/core/mllm.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,29 @@
1414
)
1515

1616

17+
def _message_has_user_input(message) -> bool:
18+
if not isinstance(message, dict):
19+
return False
20+
if message.get("role") != "user":
21+
return False
22+
23+
content = message.get("content")
24+
if isinstance(content, str):
25+
return bool(content.strip())
26+
if isinstance(content, list):
27+
for item in content:
28+
if isinstance(item, str) and item.strip():
29+
return True
30+
if isinstance(item, dict):
31+
item_type = item.get("type")
32+
if item_type == "text" and str(item.get("text", "")).strip():
33+
return True
34+
if item_type in {"image_url", "image"}:
35+
return True
36+
return False
37+
return bool(content)
38+
39+
1740
class LMMAgent:
1841
def __init__(self, engine_params=None, system_prompt=None, engine=None):
1942
if engine is None:
@@ -288,6 +311,9 @@ def get_response(
288311
{"role": "user", "content": [{"type": "text", "text": user_message}]}
289312
)
290313

314+
if not any(_message_has_user_input(message) for message in messages):
315+
raise ValueError("At least one non-empty user message is required before LLM generation")
316+
291317
# Thinking enabled for Claude Sonnet 3.7 and Gemini 2.5 Pro
292318
if use_thinking:
293319
return self.engine.generate_with_thinking(

main_routers/characters_router.py

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -757,23 +757,38 @@ async def update_catgirl_voice_id(name: str, request: Request):
757757
data = await request.json()
758758
if not data:
759759
return JSONResponse({'success': False, 'error': '无数据'}, status_code=400)
760+
if 'voice_id' not in data:
761+
logger.debug("猫娘 %s 的 voice_id 更新请求缺少字段,按无变更处理", name)
762+
return {"success": True, "session_restarted": False, "voice_id_changed": False}
760763
_config_manager = get_config_manager()
761764
session_manager = get_session_manager()
762765
characters = _config_manager.load_characters()
763766
if name not in characters.get('猫娘', {}):
764767
return JSONResponse({'success': False, 'error': '猫娘不存在'}, status_code=404)
765-
if 'voice_id' in data:
766-
voice_id = data['voice_id']
767-
# 验证voice_id是否在voice_storage中
768-
if not _config_manager.validate_voice_id(voice_id):
769-
voices = _config_manager.get_voices_for_current_api()
770-
available_voices = list(voices.keys())
771-
return JSONResponse({
772-
'success': False,
773-
'error': f'voice_id "{voice_id}" 在当前API的音色库中不存在',
774-
'available_voices': available_voices
775-
}, status_code=400)
776-
set_reserved(characters['猫娘'][name], 'voice_id', voice_id)
768+
voice_id = str(data.get('voice_id') or '').strip()
769+
old_voice_id = str(get_reserved(
770+
characters['猫娘'][name],
771+
'voice_id',
772+
default='',
773+
legacy_keys=('voice_id',)
774+
) or '').strip()
775+
776+
# 幂等保护:提交同值时直接返回,避免无实际变更触发 reload_page。
777+
if old_voice_id == voice_id:
778+
logger.info("猫娘 %s 的 voice_id 未变化,跳过刷新流程", name)
779+
return {"success": True, "session_restarted": False, "voice_id_changed": False}
780+
781+
# 验证voice_id是否在voice_storage中
782+
if not _config_manager.validate_voice_id(voice_id):
783+
voices = _config_manager.get_voices_for_current_api()
784+
available_voices = list(voices.keys())
785+
return JSONResponse({
786+
'success': False,
787+
'error': f'voice_id "{voice_id}" 在当前API的音色库中不存在',
788+
'available_voices': available_voices
789+
}, status_code=400)
790+
791+
set_reserved(characters['猫娘'][name], 'voice_id', voice_id)
777792
_config_manager.save_characters(characters)
778793

779794
# 如果是当前活跃的猫娘,需要先通知前端,再关闭session
@@ -783,7 +798,7 @@ async def update_catgirl_voice_id(name: str, request: Request):
783798
if is_current_catgirl and name in session_manager:
784799
# 检查是否有活跃的session
785800
if session_manager[name].is_active:
786-
logger.info(f"检测到 {name} 的voice_id已更新,准备刷新...")
801+
logger.info(f"检测到 {name} 的voice_id已更新{old_voice_id} -> {voice_id},准备刷新...")
787802

788803
# 1. 先发送刷新消息(WebSocket还连着)
789804
await send_reload_page_notice(session_manager[name])
@@ -806,7 +821,7 @@ async def update_catgirl_voice_id(name: str, request: Request):
806821
# 不是当前猫娘,跳过重新加载,避免影响当前猫娘的session
807822
logger.info(f"切换的是其他猫娘 {name} 的音色,跳过重新加载以避免影响当前猫娘的session")
808823

809-
return {"success": True, "session_restarted": session_ended}
824+
return {"success": True, "session_restarted": session_ended, "voice_id_changed": True}
810825

811826
@router.get('/catgirl/{name}/voice_mode_status')
812827
async def get_catgirl_voice_mode_status(name: str):

plugin/message_plane/runner.py

Lines changed: 2 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
import concurrent.futures
55
import os
66
import socket
7-
import subprocess
8-
import sys
97
import threading
108
import time
119
from dataclasses import dataclass
@@ -94,16 +92,14 @@ def _check_once() -> bool:
9492

9593

9694
class PythonMessagePlaneRunner(MessagePlaneRunner):
97-
def __init__(self, *, run_mode: str, endpoints: MessagePlaneEndpoints) -> None:
98-
self._run_mode = str(run_mode)
95+
def __init__(self, *, endpoints: MessagePlaneEndpoints) -> None:
9996
self._endpoints = endpoints
10097

10198
self._thread: threading.Thread | None = None
10299
self._ingest_thread: threading.Thread | None = None
103100
self._rpc = None
104101
self._ingest = None
105102
self._pub = None
106-
self._proc: subprocess.Popen | None = None
107103

108104
def _cleanup_embedded(
109105
self,
@@ -156,36 +152,7 @@ def _cleanup_embedded(
156152
self._thread = None
157153
self._ingest_thread = None
158154

159-
def _terminate_process(self, proc: subprocess.Popen | None) -> None:
160-
if proc is None:
161-
return
162-
try:
163-
if proc.poll() is None:
164-
proc.terminate()
165-
except Exception:
166-
pass
167-
try:
168-
proc.wait(timeout=1.0)
169-
return
170-
except subprocess.TimeoutExpired:
171-
pass
172-
except Exception:
173-
return
174-
try:
175-
if proc.poll() is None:
176-
proc.kill()
177-
except Exception:
178-
return
179-
try:
180-
proc.wait(timeout=1.0)
181-
except subprocess.TimeoutExpired:
182-
logger.warning("message_plane process did not exit after kill pid={}", getattr(proc, "pid", "?"))
183-
except Exception:
184-
pass
185-
186155
def start(self) -> MessagePlaneEndpoints:
187-
if self._run_mode == "external":
188-
return self._start_external()
189156
return self._start_embedded()
190157

191158
def _start_embedded(self) -> MessagePlaneEndpoints:
@@ -245,45 +212,7 @@ def _run_rpc() -> None:
245212
raise
246213
return self._endpoints
247214

248-
def _start_external(self) -> MessagePlaneEndpoints:
249-
if self._proc is not None and self._proc.poll() is None:
250-
return self._endpoints
251-
252-
env = dict(os.environ)
253-
env["NEKO_MESSAGE_PLANE_ZMQ_RPC_ENDPOINT"] = str(self._endpoints.rpc)
254-
env["NEKO_MESSAGE_PLANE_ZMQ_PUB_ENDPOINT"] = str(self._endpoints.pub)
255-
env["NEKO_MESSAGE_PLANE_ZMQ_INGEST_ENDPOINT"] = str(self._endpoints.ingest)
256-
257-
try:
258-
cmd = [sys.executable, "-m", "plugin.message_plane.main"]
259-
self._proc = subprocess.Popen(
260-
cmd,
261-
stdin=subprocess.DEVNULL,
262-
stdout=None,
263-
stderr=None,
264-
close_fds=True,
265-
env=env,
266-
)
267-
logger.info("message_plane external process started pid={}", int(self._proc.pid))
268-
except Exception as e:
269-
self._proc = None
270-
logger.warning("message_plane external process start failed: {}", e)
271-
raise
272-
273-
for label, ep in [("rpc", self._endpoints.rpc), ("ingest", self._endpoints.ingest), ("pub", self._endpoints.pub)]:
274-
if not _wait_tcp_ready(str(ep), timeout_s=3.0):
275-
logger.warning("message_plane {} endpoint not ready: {}", label, ep)
276-
return self._endpoints
277-
278215
def stop(self) -> None:
279-
if self._run_mode == "external":
280-
p = self._proc
281-
self._proc = None
282-
if p is None:
283-
return
284-
self._terminate_process(p)
285-
return
286-
287216
rpc_srv = self._rpc
288217
ingest_srv = self._ingest
289218
pub_srv = self._pub
@@ -368,16 +297,11 @@ def _resolve_endpoint_with_fallback(endpoint: str, used_ports: set[tuple[str, in
368297

369298
def build_message_plane_runner() -> MessagePlaneRunner:
370299
from plugin.settings import (
371-
MESSAGE_PLANE_RUN_MODE,
372300
MESSAGE_PLANE_ZMQ_INGEST_ENDPOINT,
373301
MESSAGE_PLANE_ZMQ_PUB_ENDPOINT,
374302
MESSAGE_PLANE_ZMQ_RPC_ENDPOINT,
375303
)
376304

377-
run_mode = os.getenv("NEKO_MESSAGE_PLANE_RUN_MODE", str(MESSAGE_PLANE_RUN_MODE)).strip().lower()
378-
if run_mode not in ("embedded", "external"):
379-
run_mode = str(MESSAGE_PLANE_RUN_MODE)
380-
381305
rpc_env = os.getenv("NEKO_MESSAGE_PLANE_ZMQ_RPC_ENDPOINT", str(MESSAGE_PLANE_ZMQ_RPC_ENDPOINT))
382306
pub_env = os.getenv("NEKO_MESSAGE_PLANE_ZMQ_PUB_ENDPOINT", str(MESSAGE_PLANE_ZMQ_PUB_ENDPOINT))
383307
ingest_env = os.getenv("NEKO_MESSAGE_PLANE_ZMQ_INGEST_ENDPOINT", str(MESSAGE_PLANE_ZMQ_INGEST_ENDPOINT))
@@ -397,4 +321,4 @@ def build_message_plane_runner() -> MessagePlaneRunner:
397321
ingest=ingest_ep,
398322
)
399323

400-
return PythonMessagePlaneRunner(run_mode=run_mode, endpoints=endpoints)
324+
return PythonMessagePlaneRunner(endpoints=endpoints)

plugin/settings.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -301,13 +301,8 @@ def get_plugin_config_roots() -> tuple[Path, ...]:
301301
os.getenv("NEKO_MESSAGE_PLANE_PUB", "tcp://127.0.0.1:38866"),
302302
)
303303

304-
# Message plane 运行模式
305-
# - embedded: 作为主进程的后台线程启动(默认)
306-
# - external: 由主进程启动独立子进程(独立解释器),用于隔离控制面与数据面负载
307-
# Env: NEKO_MESSAGE_PLANE_RUN_MODE, default="external"
308-
MESSAGE_PLANE_RUN_MODE = os.getenv("NEKO_MESSAGE_PLANE_RUN_MODE", "external").strip().lower()
309-
if MESSAGE_PLANE_RUN_MODE not in ("embedded", "external"):
310-
MESSAGE_PLANE_RUN_MODE = "external"
304+
# Message plane 始终以内嵌线程方式运行。
305+
# 保留端点配置,但不再支持 external 独立子进程模式。
311306

312307
MESSAGE_PLANE_VALIDATE_MODE = os.getenv("NEKO_MESSAGE_PLANE_VALIDATE_MODE", "strict").lower()
313308
if MESSAGE_PLANE_VALIDATE_MODE not in ("off", "warn", "strict"):
@@ -581,7 +576,6 @@ def validate_config() -> None:
581576
"SYNC_CALL_IN_HANDLER_POLICY",
582577

583578
# Message plane
584-
"MESSAGE_PLANE_RUN_MODE",
585579
"MESSAGE_PLANE_ZMQ_RPC_ENDPOINT",
586580
"MESSAGE_PLANE_ZMQ_PUB_ENDPOINT",
587581
"MESSAGE_PLANE_ZMQ_INGEST_ENDPOINT",
@@ -648,7 +642,6 @@ def validate_config() -> None:
648642
"MESSAGE_PLANE_BACKEND",
649643
"MESSAGE_PLANE_RUST_BIN",
650644
"MESSAGE_PLANE_WORKERS",
651-
"MESSAGE_PLANE_RUN_MODE",
652645
"MESSAGE_PLANE_ZMQ_RPC_ENDPOINT",
653646
"MESSAGE_PLANE_ZMQ_PUB_ENDPOINT",
654647
"MESSAGE_PLANE_ZMQ_INGEST_ENDPOINT",

static/app.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -577,8 +577,11 @@ function init_app() {
577577
if (hasRunning && !agentTaskTimeUpdateInterval) {
578578
agentTaskTimeUpdateInterval = setInterval(updateTaskRunningTimes, 1000);
579579
}
580+
} else if (typeof window.checkAndToggleTaskHUD === 'function') {
581+
// Re-evaluate HUD visibility from enablement state instead of
582+
// force-hiding on an empty task snapshot.
583+
window.checkAndToggleTaskHUD();
580584
} else if (window.AgentHUD && typeof window.AgentHUD.hideAgentTaskHUD === 'function') {
581-
// No tasks at all, hide HUD
582585
window.AgentHUD.hideAgentTaskHUD();
583586
}
584587
}
@@ -1050,8 +1053,11 @@ function init_app() {
10501053
timestamp: new Date().toISOString()
10511054
});
10521055
}
1056+
} else if (typeof window.checkAndToggleTaskHUD === 'function') {
1057+
// An empty snapshot should not hide the HUD while any
1058+
// agent capability is still enabled.
1059+
window.checkAndToggleTaskHUD();
10531060
} else if (window.AgentHUD && typeof window.AgentHUD.hideAgentTaskHUD === 'function') {
1054-
// No active tasks, hide HUD
10551061
window.AgentHUD.hideAgentTaskHUD();
10561062
}
10571063
} catch (_e) { /* ignore */ }

0 commit comments

Comments
 (0)