合并外部维护版本并同步自动每日功能
This commit is contained in:
+797
-131
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import io
|
||||
import multiprocessing
|
||||
import os
|
||||
@@ -18,6 +19,8 @@ DEFAULT_STARTUP_TIMEOUT_SECONDS = 900.0
|
||||
# 启动失败后再次拉起 worker 的最小间隔:避免"启动超时→立即重启→再超时"的死循环
|
||||
# 在系统高负载时持续加载 torch/CUDA,进一步加剧卡顿。
|
||||
STARTUP_FAILURE_BACKOFF_SECONDS = 60.0
|
||||
# 每 N 次合成才做一次显存回收(empty_cache 会 stall 共用 GPU 的游戏/编码)
|
||||
FREE_GPU_EVERY_N_SYNTH = 50
|
||||
DEFAULT_CPU_THREADS = 4
|
||||
DEFAULT_CPU_AFFINITY_COUNT = 8
|
||||
DEFAULT_PROCESS_PRIORITY = "below_normal"
|
||||
@@ -207,6 +210,68 @@ def _synthesize_wav(runtime: dict[str, Any], text: str) -> bytes:
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _collect_memory_stats(runtime: dict[str, Any]) -> dict[str, float]:
|
||||
"""采集 worker 进程的内存/显存占用,用于定位长跑性能退化(泄漏 vs 碎片化)。"""
|
||||
stats: dict[str, float] = {}
|
||||
torch = runtime.get("torch")
|
||||
try:
|
||||
if torch is not None and torch.cuda.is_available():
|
||||
stats["vram_alloc_mb"] = round(torch.cuda.memory_allocated() / 1048576.0, 1)
|
||||
stats["vram_reserved_mb"] = round(torch.cuda.memory_reserved() / 1048576.0, 1)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import psutil
|
||||
|
||||
stats["rss_mb"] = round(psutil.Process().memory_info().rss / 1048576.0, 1)
|
||||
except Exception:
|
||||
pass
|
||||
return stats
|
||||
|
||||
|
||||
def _free_gpu_memory(runtime: dict[str, Any]) -> None:
|
||||
"""回收碎片化显存与 Python 内存。
|
||||
|
||||
注意:empty_cache 会把缓存显存还给驱动,下次合成需重新申请,
|
||||
在原神/直播姬共用 GPU 时会引发驱动级页表重整,导致 GPU 瞬时停摆
|
||||
(占用掉到 0、直播画面卡顿)。因此不能在每次合成后调用,
|
||||
只能在低频维护窗口(每 FREE_GPU_EVERY_N_SYNTH 次)执行。
|
||||
"""
|
||||
gc.collect()
|
||||
torch = runtime.get("torch")
|
||||
try:
|
||||
if torch is not None and torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _append_memory_sample(
|
||||
log_path: str,
|
||||
duration_ms: int,
|
||||
mem: dict[str, float],
|
||||
status: str,
|
||||
) -> None:
|
||||
"""把每次合成的耗时与内存/显存写入独立日志文件,用于定位长跑退化根因。"""
|
||||
if not log_path:
|
||||
return
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
line = (
|
||||
f"{datetime.now().strftime('%Y-%m-%dT%H:%M:%S')} "
|
||||
f"duration_ms={int(duration_ms)} "
|
||||
f"vram_alloc_mb={mem.get('vram_alloc_mb', '')} "
|
||||
f"vram_reserved_mb={mem.get('vram_reserved_mb', '')} "
|
||||
f"rss_mb={mem.get('rss_mb', '')} "
|
||||
f"status={status}"
|
||||
)
|
||||
with open(log_path, "a", encoding="utf-8") as handle:
|
||||
handle.write(line + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) -> None:
|
||||
def report(stage: str, **details: Any) -> None:
|
||||
try:
|
||||
@@ -219,6 +284,12 @@ def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) ->
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# pythonw 无窗口模式下 sys.stdout/sys.stderr 为 None,
|
||||
# 第三方库(transformers/tqdm 等)写入时会抛 NoneType.write,这里兜底到 devnull。
|
||||
if sys.stdout is None:
|
||||
sys.stdout = open(os.devnull, "w", encoding="utf-8")
|
||||
if sys.stderr is None:
|
||||
sys.stderr = open(os.devnull, "w", encoding="utf-8")
|
||||
try:
|
||||
report("process_started", executable=sys.executable, prefix=sys.prefix)
|
||||
cpu_threads = _configure_worker_environment(settings)
|
||||
@@ -282,6 +353,7 @@ def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) ->
|
||||
connection.close()
|
||||
return
|
||||
|
||||
synth_count = 0
|
||||
while True:
|
||||
try:
|
||||
message = connection.recv()
|
||||
@@ -297,17 +369,36 @@ def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) ->
|
||||
|
||||
request_id = str(message.get("request_id") or "")
|
||||
started = time.monotonic()
|
||||
memory_log_path = str(settings.get("memory_log_path") or "")
|
||||
try:
|
||||
audio = _synthesize_wav(runtime, str(message.get("text") or ""))
|
||||
duration_ms = int((time.monotonic() - started) * 1000)
|
||||
mem = _collect_memory_stats(runtime)
|
||||
synth_count += 1
|
||||
if synth_count % FREE_GPU_EVERY_N_SYNTH == 0:
|
||||
_free_gpu_memory(runtime)
|
||||
_append_memory_sample(memory_log_path, duration_ms, mem, "ok")
|
||||
connection.send({
|
||||
"type": "result",
|
||||
"request_id": request_id,
|
||||
"audio": audio,
|
||||
"duration_ms": int((time.monotonic() - started) * 1000),
|
||||
"duration_ms": duration_ms,
|
||||
"bytes": len(audio),
|
||||
"max_new_tokens": MAX_NEW_TOKENS,
|
||||
"mem": mem,
|
||||
})
|
||||
except BaseException as exc:
|
||||
duration_ms = int((time.monotonic() - started) * 1000)
|
||||
mem = _collect_memory_stats(runtime)
|
||||
synth_count += 1
|
||||
if synth_count % FREE_GPU_EVERY_N_SYNTH == 0:
|
||||
_free_gpu_memory(runtime)
|
||||
_append_memory_sample(
|
||||
memory_log_path,
|
||||
duration_ms,
|
||||
mem,
|
||||
f"error:{type(exc).__name__}",
|
||||
)
|
||||
try:
|
||||
connection.send({
|
||||
"type": "error",
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
"""LLM 客户端抽象层。
|
||||
|
||||
目标:为直播间弹幕回复提供统一的对话接口,屏蔽底层各家协议差异。
|
||||
|
||||
结构:
|
||||
- BaseLLMProvider 抽象基类,定义 async chat(messages) -> str
|
||||
- OpenAICompatProvider OpenAI /chat/completions 兼容协议
|
||||
(DeepSeek / 通义千问DashScope兼容模式 / Kimi / 硅基流动 /
|
||||
Ollama / vLLM / LM Studio 等几乎所有主流服务)
|
||||
- OllamaNativeProvider Ollama 原生 /api/chat 协议
|
||||
- AnthropicProvider Anthropic /v1/messages 协议(Claude 系列)
|
||||
- create_provider() 按 config["llm_reply"]["provider"] 工厂构造
|
||||
- LLMReplyEngine 弹幕回复业务引擎:过滤、限流、上下文、重试
|
||||
|
||||
扩展新协议只需:继承 BaseLLMProvider 实现 chat(),并在 PROVIDERS 注册。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import deque
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError: # pragma: no cover - aiohttp 为运行时必需依赖
|
||||
aiohttp = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider 抽象与实现
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LLMError(Exception):
|
||||
"""LLM 调用失败(网络/协议/内容拒绝等)。"""
|
||||
|
||||
|
||||
class BaseLLMProvider(ABC):
|
||||
"""所有 LLM 协议适配器的公共基类。"""
|
||||
|
||||
name = "base"
|
||||
|
||||
def __init__(self, base_url: str, api_key: str = "", model: str = "",
|
||||
timeout_sec: float = 20.0, logger: logging.Logger | None = None):
|
||||
self.base_url = (base_url or "").rstrip("/")
|
||||
self.api_key = api_key or ""
|
||||
self.model = model or ""
|
||||
self.timeout_sec = max(3.0, float(timeout_sec or 20.0))
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
@abstractmethod
|
||||
async def chat(self, messages: list[dict], *, temperature: float = 0.8,
|
||||
max_tokens: int = 256) -> str:
|
||||
"""发送一轮对话,返回助手回复文本;失败抛 LLMError。"""
|
||||
|
||||
async def _post_json(self, url: str, payload: dict,
|
||||
headers: dict | None = None) -> dict:
|
||||
if aiohttp is None:
|
||||
raise LLMError("缺少 aiohttp 依赖")
|
||||
timeout = aiohttp.ClientTimeout(total=self.timeout_sec)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(url, json=payload,
|
||||
headers=headers or {}) as resp:
|
||||
body = await resp.text()
|
||||
if resp.status != 200:
|
||||
raise LLMError(
|
||||
f"HTTP {resp.status}: {body[:200]}")
|
||||
import json as _json
|
||||
return _json.loads(body)
|
||||
except asyncio.TimeoutError as e:
|
||||
raise LLMError(f"请求超时({self.timeout_sec}s)") from e
|
||||
except LLMError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise LLMError(f"请求失败: {e}") from e
|
||||
|
||||
|
||||
class OpenAICompatProvider(BaseLLMProvider):
|
||||
"""OpenAI /v1/chat/completions 兼容协议。
|
||||
|
||||
覆盖:OpenAI、DeepSeek、Kimi(Moonshot)、通义千问(DashScope兼容模式)、
|
||||
智谱GLM、硅基流动、OpenRouter、Ollama/VoidAI/vLLM/LM Studio 本地端点等。
|
||||
"""
|
||||
|
||||
name = "openai"
|
||||
|
||||
DEFAULT_PATH = "/chat/completions"
|
||||
|
||||
async def chat(self, messages: list[dict], *, temperature: float = 0.8,
|
||||
max_tokens: int = 256) -> str:
|
||||
url = self.base_url + self.DEFAULT_PATH
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": max(0.0, min(2.0, temperature)),
|
||||
"max_tokens": max(16, int(max_tokens)),
|
||||
"stream": False,
|
||||
}
|
||||
data = await self._post_json(url, payload, headers)
|
||||
try:
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
raise LLMError(f"响应格式异常: {str(data)[:200]}") from e
|
||||
if not isinstance(content, str):
|
||||
raise LLMError("响应 content 非文本")
|
||||
return content.strip()
|
||||
|
||||
|
||||
class OllamaNativeProvider(BaseLLMProvider):
|
||||
"""Ollama 原生 /api/chat 协议(无需鉴权)。"""
|
||||
|
||||
name = "ollama"
|
||||
|
||||
async def chat(self, messages: list[dict], *, temperature: float = 0.8,
|
||||
max_tokens: int = 256) -> str:
|
||||
url = self.base_url + "/api/chat"
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": max(0.0, min(2.0, temperature)),
|
||||
"num_predict": max(16, int(max_tokens)),
|
||||
},
|
||||
}
|
||||
data = await self._post_json(url, payload)
|
||||
content = data.get("message", {}).get("content", "")
|
||||
if not isinstance(content, str):
|
||||
raise LLMError("响应 message.content 非文本")
|
||||
return content.strip()
|
||||
|
||||
|
||||
class AnthropicProvider(BaseLLMProvider):
|
||||
"""Anthropic /v1/messages 协议(Claude 系列)。"""
|
||||
|
||||
name = "anthropic"
|
||||
|
||||
ANTHROPIC_VERSION = "2023-06-01"
|
||||
|
||||
async def chat(self, messages: list[dict], *, temperature: float = 0.8,
|
||||
max_tokens: int = 256) -> str:
|
||||
url = self.base_url + "/v1/messages"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": self.api_key,
|
||||
"anthropic-version": self.ANTHROPIC_VERSION,
|
||||
}
|
||||
# Anthropic 协议中 system 是顶层字段,不放在 messages 里。
|
||||
system_text = "\n".join(
|
||||
m["content"] for m in messages if m.get("role") == "system")
|
||||
chat_msgs = [m for m in messages if m.get("role") != "system"]
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"system": system_text or None,
|
||||
"messages": [
|
||||
{"role": m["role"], "content": m["content"]}
|
||||
for m in chat_msgs
|
||||
],
|
||||
"temperature": max(0.0, min(1.0, temperature)),
|
||||
"max_tokens": max(16, int(max_tokens)),
|
||||
}
|
||||
data = await self._post_json(url, payload, headers)
|
||||
try:
|
||||
parts = data["content"]
|
||||
text = "".join(p.get("text", "") for p in parts if isinstance(p, dict))
|
||||
except (KeyError, TypeError) as e:
|
||||
raise LLMError(f"响应格式异常: {str(data)[:200]}") from e
|
||||
return text.strip()
|
||||
|
||||
|
||||
# 协议注册表:新增协议在此注册即可。
|
||||
PROVIDERS: dict[str, type[BaseLLMProvider]] = {
|
||||
OpenAICompatProvider.name: OpenAICompatProvider,
|
||||
OllamaNativeProvider.name: OllamaNativeProvider,
|
||||
AnthropicProvider.name: AnthropicProvider,
|
||||
}
|
||||
|
||||
|
||||
def create_provider(cfg: dict, logger: logging.Logger | None = None) -> BaseLLMProvider:
|
||||
"""根据 llm_reply 配置节构造 provider。"""
|
||||
cfg = cfg or {}
|
||||
name = str(cfg.get("provider", "openai") or "openai").strip().lower()
|
||||
cls = PROVIDERS.get(name)
|
||||
if cls is None:
|
||||
raise LLMError(
|
||||
f"未知 LLM 协议: {name}(可选: {', '.join(sorted(PROVIDERS))})")
|
||||
base_url = str(cfg.get("base_url", "") or "").strip()
|
||||
if not base_url:
|
||||
raise LLMError("未配置 base_url")
|
||||
return cls(
|
||||
base_url=base_url,
|
||||
api_key=str(cfg.get("api_key", "") or ""),
|
||||
model=str(cfg.get("model", "") or "").strip(),
|
||||
timeout_sec=float(cfg.get("timeout_sec", 20) or 20),
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 弹幕回复业务引擎
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LLMReplyEngine:
|
||||
"""把观众弹幕交给 LLM 生成回复。
|
||||
|
||||
职责边界:本类只负责"生成回复文本",不负责发送——
|
||||
由调用方(CommandHandler)决定走弹幕/TTS 通道。
|
||||
内置:
|
||||
- 开关与触发过滤(长度、忽略前缀、黑名单)
|
||||
- 频率限制(全局每分钟上限 + 单人冷却)
|
||||
- 短期上下文(最近 N 条观众发言与机器人回复)
|
||||
- 失败重试(指数退避,最多 retry_attempts 次)
|
||||
- 并发保护(同一时刻只允许一个在途请求,忙时直接跳过该条)
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: dict | None = None, logger: logging.Logger | None = None,
|
||||
config_getter=None):
|
||||
"""config_getter: 可选的可调用对象,返回最新 llm_reply 配置 dict,
|
||||
用于跟随 Config 热更新(reload 后 data 节点可能是新对象)。"""
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self._cfg = cfg or {}
|
||||
self._config_getter = config_getter
|
||||
self._provider: BaseLLMProvider | None = None
|
||||
self._provider_built_at = 0.0
|
||||
self._context: deque[tuple[str, str]] = deque() # (speaker, text)
|
||||
self._reply_times: deque[float] = deque() # 全局滑动窗口
|
||||
self._user_last_reply: dict[int, float] = {}
|
||||
self._inflight = asyncio.Lock()
|
||||
# 配置指纹:热更新后自动重建 provider。
|
||||
self._cfg_fingerprint = ""
|
||||
|
||||
# -- 配置 ---------------------------------------------------------------
|
||||
|
||||
def _config(self) -> dict:
|
||||
if self._config_getter is not None:
|
||||
try:
|
||||
cfg = self._config_getter()
|
||||
if cfg:
|
||||
self._cfg = cfg
|
||||
except Exception:
|
||||
pass
|
||||
return self._cfg or {}
|
||||
|
||||
def update_config(self, cfg: dict):
|
||||
"""配置热更新入口。"""
|
||||
self._cfg = cfg or {}
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self._config().get("enabled", False))
|
||||
|
||||
def _provider_fingerprint(self) -> str:
|
||||
c = self._config()
|
||||
return "|".join(str(c.get(k, "")) for k in
|
||||
("provider", "base_url", "api_key", "model", "timeout_sec"))
|
||||
|
||||
def _get_provider(self) -> BaseLLMProvider:
|
||||
fp = self._provider_fingerprint()
|
||||
if self._provider is None or fp != self._cfg_fingerprint:
|
||||
self._provider = create_provider(self._config(), self.logger)
|
||||
self._cfg_fingerprint = fp
|
||||
self.logger.info(f"[LLM] 使用协议 {self._provider.name} "
|
||||
f"模型 {self._provider.model or '(默认)'}")
|
||||
return self._provider
|
||||
|
||||
# -- 过滤 ---------------------------------------------------------------
|
||||
|
||||
def should_reply(self, text: str, uid: int, uname: str) -> str | None:
|
||||
"""返回需要回复的规范化文本;不需要回复返回 None。"""
|
||||
cfg = self._config()
|
||||
if not self.enabled:
|
||||
return None
|
||||
text = (text or "").strip()
|
||||
raw = cfg.get("min_text_len", 2)
|
||||
min_len = int(raw) if raw is not None else 2
|
||||
if len(text) < min_len:
|
||||
return None
|
||||
for prefix in cfg.get("ignore_prefixes", []) or []:
|
||||
prefix = str(prefix or "").strip()
|
||||
if prefix and text.startswith(prefix):
|
||||
return None
|
||||
blacklist = {str(u) for u in (cfg.get("blacklist_uids", []) or [])}
|
||||
if str(uid) in blacklist:
|
||||
return None
|
||||
# 单人冷却(0 表示禁用冷却)
|
||||
raw = cfg.get("per_user_cooldown_sec", 30)
|
||||
cooldown = float(raw) if raw is not None else 30.0
|
||||
if cooldown > 0:
|
||||
last = self._user_last_reply.get(int(uid), 0.0)
|
||||
if time.time() - last < cooldown:
|
||||
return None
|
||||
# 全局限流(0 表示不限流)
|
||||
raw = cfg.get("replies_per_minute", 6)
|
||||
rpm = int(raw) if raw is not None else 6
|
||||
if rpm > 0:
|
||||
now = time.time()
|
||||
while self._reply_times and now - self._reply_times[0] > 60:
|
||||
self._reply_times.popleft()
|
||||
if len(self._reply_times) >= rpm:
|
||||
return None
|
||||
return text
|
||||
|
||||
# -- 上下文 ---------------------------------------------------------------
|
||||
|
||||
def _build_messages(self, text: str, uname: str) -> list[dict]:
|
||||
cfg = self._config()
|
||||
system_prompt = str(cfg.get("system_prompt", "") or "").strip()
|
||||
messages: list[dict] = []
|
||||
emotion_instruction = (
|
||||
"每次回复必须先输出一个情绪标签,只能从[开心]、[很开心]、[平静]、[生气]、[很生气]中选一个,"
|
||||
"紧接着输出给观众的回复正文。标签只用于桌宠状态,不属于口播内容。"
|
||||
)
|
||||
messages.append({"role": "system", "content": "\n".join(
|
||||
part for part in (system_prompt, emotion_instruction) if part
|
||||
)})
|
||||
for speaker, content in list(self._context):
|
||||
role = "assistant" if speaker == "bot" else "user"
|
||||
content = f"{speaker}: {content}" if role == "user" else content
|
||||
messages.append({"role": role, "content": content})
|
||||
messages.append({"role": "user", "content": f"{uname}: {text}"})
|
||||
return messages
|
||||
|
||||
def _remember(self, uname: str, text: str, reply: str | None):
|
||||
max_turns = int(self._config().get("context_turns", 6) or 6)
|
||||
if max_turns <= 0:
|
||||
return
|
||||
self._context.append((uname, text))
|
||||
if reply:
|
||||
self._context.append(("bot", reply))
|
||||
while len(self._context) > max_turns * 2:
|
||||
self._context.popleft()
|
||||
|
||||
# -- 主入口 ---------------------------------------------------------------
|
||||
|
||||
async def generate_reply(self, text: str, uid: int, uname: str) -> str | None:
|
||||
"""生成回复;被过滤/限流/失败返回 None。非阻塞——忙时立即放弃。"""
|
||||
text = self.should_reply(text, uid, uname)
|
||||
if text is None:
|
||||
return None
|
||||
if self._inflight.locked():
|
||||
# 已有请求在途:丢弃本条,避免弹幕高峰打爆 LLM。
|
||||
return None
|
||||
async with self._inflight:
|
||||
cfg = self._config()
|
||||
provider = self._get_provider()
|
||||
messages = self._build_messages(text, uname or "观众")
|
||||
temperature = float(cfg.get("temperature", 0.8) or 0.8)
|
||||
max_tokens = int(cfg.get("max_tokens", 128) or 128)
|
||||
attempts = max(1, int(cfg.get("retry_attempts", 2) or 1))
|
||||
last_err: Exception | None = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
raw = await provider.chat(
|
||||
messages, temperature=temperature, max_tokens=max_tokens)
|
||||
reply = self._sanitize(raw)
|
||||
if reply:
|
||||
now = time.time()
|
||||
self._reply_times.append(now)
|
||||
self._user_last_reply[int(uid)] = now
|
||||
self._remember(uname or "观众", text, reply)
|
||||
return reply
|
||||
return None # 模型返回空内容,不重试
|
||||
except LLMError as e:
|
||||
last_err = e
|
||||
if i < attempts - 1:
|
||||
await asyncio.sleep(0.8 * (2 ** i) + random.random() * 0.4)
|
||||
self.logger.warning(f"[LLM] 回复失败({last_err})")
|
||||
return None
|
||||
|
||||
def _sanitize(self, raw: str) -> str:
|
||||
"""清洗模型输出:去空白、截断到 max_reply_chars、去 markdown 粗体。
|
||||
|
||||
开头的情绪标签(如 [开心])豁免截断长度,保证不会被截坏;
|
||||
标签本身由调用方(danmu_queue)在弹幕/语音输出前剥离。
|
||||
"""
|
||||
cfg = self._config()
|
||||
text = str(raw or "").strip()
|
||||
# 直播口播场景不需要 markdown 标记
|
||||
for token in ("**", "__", "```"):
|
||||
text = text.replace(token, "")
|
||||
text = " ".join(text.split())
|
||||
m = re.match(r"^(\[[^\[\]]{1,8}\])\s*", text)
|
||||
tag = m.group(1) if m else ""
|
||||
body = text[m.end():] if m else text
|
||||
raw = cfg.get("max_reply_chars", 60)
|
||||
max_chars = int(raw) if raw is not None else 60
|
||||
if max_chars > 0 and len(tag) + len(body) > max_chars:
|
||||
body = body[:max(1, max_chars - len(tag))].rstrip() + "…"
|
||||
return tag + body
|
||||
+6
-12
@@ -165,12 +165,6 @@ async def _run_queue(host: str, port: int):
|
||||
await danmu_queue.main(host=host, port=port)
|
||||
|
||||
|
||||
async def _run_music(port: int):
|
||||
import music_monitor
|
||||
|
||||
await music_monitor.run_monitor(port)
|
||||
|
||||
|
||||
def _run_tts_monitor():
|
||||
import tts_monitor
|
||||
|
||||
@@ -204,8 +198,11 @@ async def _run_all(host: str, port: int):
|
||||
# Refuse stale/conflicting listeners before creating any helper process.
|
||||
_assert_port_available(host, port)
|
||||
queue_task = asyncio.create_task(_run_queue(host, port), name="queue-main")
|
||||
children.append(_spawn_role("music", port, host, visible=False))
|
||||
children.append(_spawn_role("tts", port, host, visible=True))
|
||||
# pythonw 无窗口模式下 TTS 监控窗无法显示内容(stdout 为 None),
|
||||
# 且其每秒的 cls 清屏会不断弹出新控制台窗口,直接跳过。
|
||||
running_windowless = sys.stdout is None or sys.executable.lower().endswith("pythonw.exe")
|
||||
if not running_windowless:
|
||||
children.append(_spawn_role("tts", port, host, visible=True))
|
||||
for child in children:
|
||||
job.assign(child)
|
||||
await queue_task
|
||||
@@ -222,7 +219,7 @@ async def _run_all(host: str, port: int):
|
||||
def main():
|
||||
ensure_runtime_dirs()
|
||||
parser = argparse.ArgumentParser(description="BetterGI 直播联动统一入口")
|
||||
parser.add_argument("--role", choices=["all", "queue", "music", "tts"], default="all")
|
||||
parser.add_argument("--role", choices=["all", "queue", "tts"], default="all")
|
||||
parser.add_argument("--port", type=int, default=8086)
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Web service bind address")
|
||||
args = parser.parse_args()
|
||||
@@ -238,9 +235,6 @@ def main():
|
||||
if args.role == "tts":
|
||||
_run_tts_monitor()
|
||||
return 0
|
||||
if args.role == "music":
|
||||
asyncio.run(_run_music(args.port))
|
||||
return 0
|
||||
if args.role == "queue":
|
||||
asyncio.run(_run_queue(args.host, args.port))
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Persistent Kirara state that decays only during an active live session."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
EMOTION_DELTAS = {
|
||||
"开心": 1,
|
||||
"很开心": 3,
|
||||
"平静": 0,
|
||||
"生气": -1,
|
||||
"很生气": -3,
|
||||
}
|
||||
EMOTION_PREFIX = re.compile(r"^\s*\[(很开心|很生气|开心|平静|生气)\]\s*")
|
||||
GIFT_RECOVERY = {
|
||||
"饭团": (5, 0),
|
||||
"薯条": (5, 0),
|
||||
"人气票": (0, 5),
|
||||
"粉丝团灯牌": (0, 5),
|
||||
"灯牌": (0, 5), # 直播事件中常用的简写
|
||||
}
|
||||
|
||||
|
||||
def clamp(value: int | float) -> int:
|
||||
return max(0, min(100, int(value)))
|
||||
|
||||
|
||||
def gift_recovery(gift_name: str, quantity: int = 1) -> tuple[int, int]:
|
||||
"""Return nominal stamina/cleanliness points for a gift batch."""
|
||||
stamina, cleanliness = GIFT_RECOVERY.get(str(gift_name).strip(), (0, 0))
|
||||
count = max(0, int(quantity))
|
||||
return stamina * count, cleanliness * count
|
||||
|
||||
|
||||
def split_emotion(reply: str) -> tuple[str, str | None]:
|
||||
"""Return the spoken reply and its recognized leading emotion tag."""
|
||||
match = EMOTION_PREFIX.match(reply)
|
||||
if not match:
|
||||
return reply, None
|
||||
return reply[match.end():].strip(), match.group(1)
|
||||
|
||||
|
||||
class PetState:
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = Path(path)
|
||||
self.values = {"stamina": 100, "cleanliness": 100, "mood": 100}
|
||||
# Short-lived events let open frontends play each AI mood reaction once.
|
||||
self.recent_emotions: list[dict] = []
|
||||
self.live_seconds = 0.0
|
||||
# A restart must never count the offline gap as live time.
|
||||
self.live_active = False
|
||||
self.updated_at = time.time()
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
self.values = {key: clamp(data.get(key, 100)) for key in self.values}
|
||||
self.live_seconds = max(0.0, min(3599.999, float(data.get("live_seconds", 0))))
|
||||
self.updated_at = min(self.updated_at, float(data.get("updated_at", self.updated_at)))
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
self._saved_at = self.updated_at
|
||||
|
||||
def _save(self) -> None:
|
||||
"""持久化。保存失败只降级为日志,绝不影响礼物/直播主流程。"""
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = json.dumps({**self.values, "live_seconds": self.live_seconds,
|
||||
"updated_at": self.updated_at}, ensure_ascii=False)
|
||||
try:
|
||||
pending = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
pending.write_text(payload, encoding="utf-8")
|
||||
pending.replace(self.path)
|
||||
except OSError:
|
||||
# 原子替换被拒(杀软锁定/权限)时退回直接覆写
|
||||
self.path.write_text(payload, encoding="utf-8")
|
||||
Path(str(self.path) + ".tmp").unlink(missing_ok=True)
|
||||
self._saved_at = self.updated_at
|
||||
except Exception:
|
||||
logging.getLogger("pet_state").warning(
|
||||
"[桌宠] 状态保存失败(不影响礼物处理): %s", self.path, exc_info=True)
|
||||
|
||||
def _record_emotion(self, delta: int, now: float) -> None:
|
||||
if not delta:
|
||||
return
|
||||
self.recent_emotions.append({
|
||||
"id": uuid.uuid4().hex,
|
||||
"ts": now,
|
||||
"delta": delta,
|
||||
})
|
||||
del self.recent_emotions[:-32]
|
||||
|
||||
def sync(self, now: float | None = None) -> dict[str, int]:
|
||||
now = time.time() if now is None else now
|
||||
if now > self.updated_at:
|
||||
added = now - self.updated_at if self.live_active else 0.0
|
||||
self.live_seconds += added
|
||||
hours = int(self.live_seconds // 3600)
|
||||
self.live_seconds %= 3600
|
||||
for _ in range(hours):
|
||||
self.values["stamina"] = clamp(self.values["stamina"] - 10)
|
||||
self.values["cleanliness"] = clamp(self.values["cleanliness"] - 10)
|
||||
if self.values["stamina"] < 40 and self.values["cleanliness"] < 40:
|
||||
self.values["mood"] = clamp(self.values["mood"] - 5)
|
||||
elif self.values["stamina"] > 80 and self.values["cleanliness"] > 80:
|
||||
self.values["mood"] = clamp(self.values["mood"] + 3)
|
||||
self._record_emotion(3, now)
|
||||
self.updated_at = now
|
||||
if hours or (added and now - self._saved_at >= 60):
|
||||
self._save()
|
||||
return dict(self.values)
|
||||
|
||||
def set_live_active(self, active: bool, now: float | None = None) -> dict[str, int]:
|
||||
"""Settle the previous session state before switching at this instant."""
|
||||
now = time.time() if now is None else now
|
||||
self.sync(now)
|
||||
if self.live_active != active:
|
||||
self.live_active = active
|
||||
self._save()
|
||||
return dict(self.values)
|
||||
|
||||
def apply_emotion(self, emotion: str) -> dict[str, int]:
|
||||
self.sync()
|
||||
if emotion in EMOTION_DELTAS:
|
||||
delta = EMOTION_DELTAS[emotion]
|
||||
self.values["mood"] = clamp(self.values["mood"] + delta)
|
||||
self._record_emotion(delta, time.time())
|
||||
self._save()
|
||||
return dict(self.values)
|
||||
|
||||
def restore_gift(self, stamina: int = 0, cleanliness: int = 0) -> dict[str, int]:
|
||||
"""Apply gift recovery while keeping stored values within 0–100."""
|
||||
self.sync()
|
||||
if stamina or cleanliness:
|
||||
self.values["stamina"] = clamp(self.values["stamina"] + stamina)
|
||||
self.values["cleanliness"] = clamp(self.values["cleanliness"] + cleanliness)
|
||||
self._save()
|
||||
return dict(self.values)
|
||||
@@ -208,6 +208,30 @@ class RedemptionCodeStore:
|
||||
if starts_utc >= ends_utc:
|
||||
raise ValueError("失效时间必须晚于生效时间")
|
||||
now = utc_now()
|
||||
# 同名的"已停用/已删除且从未兑换"旧码会让 UNIQUE 约束挡住重建,
|
||||
# 直接在旧记录上复用(更新内容并重新启用),历史 id 保持连续。
|
||||
stale = connection.execute(
|
||||
"""SELECT id FROM redemption_codes
|
||||
WHERE code_normalized = ? AND redeemed_count = 0
|
||||
AND (deleted_at_utc IS NOT NULL OR enabled = 0)""",
|
||||
(normalized,),
|
||||
).fetchone()
|
||||
if stale is not None:
|
||||
connection.execute(
|
||||
"""UPDATE redemption_codes SET
|
||||
code_display = ?, points = ?, starts_at_utc = ?, ends_at_utc = ?,
|
||||
max_redemptions = ?, enabled = ?, deleted_at_utc = NULL,
|
||||
updated_at_utc = ?
|
||||
WHERE id = ?""",
|
||||
(display, points, starts_utc, ends_utc, max_value,
|
||||
int(bool(enabled)), now, stale["id"]),
|
||||
)
|
||||
connection.commit()
|
||||
row = connection.execute(
|
||||
"SELECT * FROM redemption_codes WHERE id=?", (stale["id"],)
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
return self._serialize_code(row)
|
||||
try:
|
||||
cursor = connection.execute(
|
||||
"""INSERT INTO redemption_codes (
|
||||
|
||||
@@ -20,6 +20,9 @@ from core.runtime_paths import DATA_DIR, ensure_runtime_dirs
|
||||
|
||||
|
||||
def clear():
|
||||
# 无控制台(pythonw)时执行 cls 会不断弹出新 cmd 窗口,直接跳过。
|
||||
if sys.stdout is None:
|
||||
return
|
||||
os.system("cls" if os.name == "nt" else "clear")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user