Compare commits

...
5 Commits
76 changed files with 7216 additions and 315 deletions
+9
View File
@@ -2,6 +2,7 @@
__pycache__/
*.py[cod]
.venv/
.venv-tts/
runtime/
config/.venv_python_path.txt
@@ -21,6 +22,13 @@ data/queue_state.json
data/song_requests.json
data/tts_state.json
data/bilibili_credentials.json
data/admin_audit.log
data/admin_auth.json
data/statistics.sqlite3*
data/users.json
data/kirara_pet.json
data/startup_tasks_state.json
data/ref_audio.wav
web/music_cover.jpg
# IDE
@@ -30,3 +38,4 @@ web/music_cover.jpg
# Large binaries (kept local only)
vendor/mpv/mpv.exe
vendor/mpv/mpv.7z
vendor/tts-model/
+52 -9
View File
@@ -1,19 +1,19 @@
# BetterGI 直播联动
监听 B 站直播间弹幕,管理观众排队、积分、扫码上号、BetterGI 配置组执行、点歌和 TTS 播报。
监听 B 站直播间弹幕,管理观众排队、积分、扫码上号、BetterGI 配置组与自动每日一条龙、队伍管理、点歌和 TTS 播报。
## 环境
- **Python**: conda 环境 `Live-streaming`(`E:\Programs\Anaconda3\envs\Live-streaming\python.exe`)
- **Python**: 项目虚拟环境 `.venv` 或 `.venv-tts`(Python 3.11+)
- **Node.js**: 构建前端用
- **BetterGI**: 自动化脚本引擎,路径在 `config/config.json` 中配置
```powershell
# 安装 Python 依赖
E:\Programs\Anaconda3\envs\Live-streaming\python.exe -m pip install -r requirements.txt
.venv\Scripts\python.exe -m pip install -r requirements.txt
# 下载 TTS 模型(约 1.2GB,仅需一次)
E:\Programs\Anaconda3\envs\Live-streaming\python.exe -c "from huggingface_hub import snapshot_download; snapshot_download('Qwen/Qwen3-TTS-12Hz-0.6B-Base')"
.venv-tts\Scripts\python.exe -c "from huggingface_hub import snapshot_download; snapshot_download('Qwen/Qwen3-TTS-12Hz-0.6B-Base')"
# 安装前端依赖并构建
cd frontend\admin
@@ -29,7 +29,7 @@ npm run build
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `--role` | `all` | `queue` 仅排队/Web / `music` 仅音乐监听 / `tts` TTS 状态窗口 / `all` 同时启动全部(推荐,否则音乐页面不会随 SMTC 更新) |
| `--role` | `all` | `queue` 仅排队/Web / `tts` TTS 状态窗口 / `all` 启动排队/Web 和内置 mpv 点歌(推荐) |
| `--host` | `0.0.0.0` | 绑定地址 |
| `--port` | `8086` | 端口 |
@@ -69,6 +69,20 @@ npm run build
| `admin_uids` | 管理员 UID 列表(一级用户,可用重置等特权指令) |
| `log_level` | 日志级别:`DEBUG` / `INFO` / `WARNING` |
### daily — 自动每日
| 字段 | 默认值 | 说明 |
|------|--------|------|
| `one_dragon_template` | 默认配置 | BGI 一条龙模板名称 |
| `managed_one_dragon_name` | 直播系统自动每日 | 每次覆盖生成并启动的托管配置名称 |
| `ley_line_craft_resin_before` | true | 地脉模式执行前是否合成树脂 |
| `commission_use_current_party` | true | 委托前读取游戏当前队伍名并写入 AutoCommissionNova 的战斗与元素采集队伍配置 |
| `current_party_read_timeout_sec` | 45 | 当前队伍 OCR 读取超时秒数 |
自动每日流程为“领取邮件 → 合成树脂 → 可选其他任务 → 领取尘歌壶奖励 → 领取每日奖励”。秘境俗称维护在 `config/domain_aliases.json`,修改后无需重启。
完整的 BGI 模板、战斗策略和三个配置组配置方法见 [自动每日与队伍管理](docs/自动每日.md)。
### broadcast — 弹幕播报与 TTS
| 字段 | 默认值 | 说明 |
@@ -81,7 +95,7 @@ npm run build
### commands — 内置指令别名
11 个内置指令,每个可单独启用/禁用、自定义别名:
14 个内置指令,每个可单独启用/禁用、自定义别名和允许角色:
| key | 默认别名 | 功能 |
|-----|----------|------|
@@ -91,6 +105,9 @@ npm run build
| `confirm_yes` | 是 | 确认账号正确 |
| `confirm_no` | 不是 | 确认账号不正确,重新扫码 |
| `run` | 执行, 跑, 开始 | 执行配置组(需带参数) |
| `daily` | 自动每日, 每日, 日常, 做每日, 做日常 | 启动托管的一条龙每日任务,可选秘境、地脉或委托模式 |
| `switch_party` | 切换队伍, 更换队伍, 换队伍, 换队 | 修改并执行配置组“切换队伍” |
| `edit_party` | 修改队员, 更换队员, 换队员, 换角色 | 校验四名角色后修改并执行配置组“修改队员” |
| `leave` | 退出 | 退出队列 |
| `reset` | 重置 | 一级用户重启原神和 BGI |
| `points` | 积分 | 查询积分 |
@@ -115,10 +132,34 @@ npm run build
### music_monitor — 音乐监听与点歌
监听 Windows 系统媒体会话(SMTC),观众可通过弹幕点歌。
观众可通过弹幕点歌,内置 mpv 播放器负责播放、续播和状态维护;旧版 `music_monitor.py` 仍保留用于兼容历史配置,但不再作为独立进程启动。
`request_player.commands`:点歌触发词,默认 `["点歌", "dg"]`。
### llm_reply — AI 弹幕回复(默认关闭)
把观众弹幕交给 LLM 自动回复,回复同时走弹幕+TTS 通道(后台「AI回复」页可配置,支持测试)。
直播前台 `/` 现显示绮良良像素桌宠。`web/uploads/kirara_walk_front.png`、`kirara_walk_back.png`、`kirara_walk_left.png`、`kirara_walk_right.png` 分别提供正面、背面、左侧、右侧四帧无尾巴走路动画;桌宠只沿水平或垂直方向游走,步态帧随移动距离切换,停下时切回正面静止帧。每隔 60–90 秒开始一次移动,持续 10–15 秒,移动速度为 21–35 像素/秒。旁边的黄、蓝、红进度环按体力、清洁、心情从外到内套叠,尺寸为 40×40 像素,以方格绘制阶梯状轮廓,不显示文字。角色与状态环一起沿游戏镂空区外的上、右、下区域游走。体力、清洁和心情均为 0–100,首次为 100;每 15 秒查询一次 B 站直播间状态,只有确认直播中才累计时间,每累计一小时体力和清洁各减 10。查询短暂失败时沿用最近状态最多 60 秒;服务停机期间不补扣。两项同时低于 40 时心情每小时减 5,同时高于 80 时每小时加 3。AI 回复应以 `[开心]`、`[很开心]`、`[平静]`、`[生气]` 或 `[很生气]` 开头,分别令心情 +1、+3、0、-1、-3;标签不会发到弹幕或 TTS。状态保存在 `data/kirara_pet.json`(若 `queue.data_dir` 被修改,则保存在对应数据目录)。
走路结束后绮良良转为正面静止姿势。每隔 120–150 秒随机触发一次送快递、看书或看手机待机动作,持续 15–20 秒。三种待机和睡觉动作各使用一套由 ImageGen 生成的独立四帧完整角色 PNG 素材(`web/uploads/kirara_idle_*.png`)。心情低于 50 时立即停止行走和普通待机并进入睡眠;进食和洗澡礼物动画优先于睡觉,播放完再回到睡觉姿势,心情恢复后重新安排游走和待机。
体力或清洁降到 60 及以下时,桌宠每 60 秒显示一次跟随角色的像素气泡。体力从饭团、薯条提示中随机选一句,清洁从人气票、粉丝团灯牌提示中随机选一句;两项都低时轮流提示,数值为 0 的需求优先。气泡在数值 60/40/20 时分别显示 5/10/30 秒,中间线性过渡,数值 0 时常驻,恢复到 60 以上即停止该项提示。
礼物恢复规则固定为:每个饭团或薯条增加 5 点体力(桌宠的饱食度);每个人气票或粉丝团灯牌增加 5 点清洁。直播事件中的“灯牌”简写也按粉丝团灯牌处理。其他礼物不改变这两项数值。礼物数量乘以每个礼物的 5 点恢复量;数值仍限制在 0–100。
进食和洗澡动画按礼物的名义恢复量排播,每 1 点播放 5 秒;即使数值已达 100,单个上述礼物仍完整播放 25 秒。进食有饭团和团子两种素材,洗澡有海绵擦洗和花洒冲洗两种素材,播放时各自轮流使用。动画期间桌宠暂停游走,结束后重新安排下一次移动;刷新前台不会重放已有礼物,但打开页面后的首件新礼物会正常播放。四组透明像素素材位于 `web/uploads/kirara_eat_rice.png`、`kirara_eat_dango.png`、`kirara_bath_sponge.png` 和 `kirara_bath_shower.png`。
- **协议抽象**(`app/llm_client.py`):`provider` 支持
- `openai` — OpenAI `/chat/completions` 兼容协议,覆盖 DeepSeek、通义千问(DashScope 兼容模式)、Kimi、智谱 GLM、硅基流动、OpenRouter、vLLM、LM Studio 及 Ollama 的 OpenAI 兼容端点
- `ollama` — Ollama 原生 `/api/chat`(本地部署免鉴权)
- `anthropic` — Anthropic `/v1/messages`(Claude 系列)
- 新增协议:继承 `BaseLLMProvider` 实现 `chat()` 后在 `PROVIDERS` 注册即可
- **指令自动忽略**:排队/上号/点歌/自定义规则等会触发指令的弹幕在指令分发层被识别,不会发送给 LLM;`ignore_prefixes`(默认 `//`)开头的弹幕同样忽略
- **安全与限流**:`replies_per_minute` 全局每分钟上限、`per_user_cooldown_sec` 单人冷却、`blacklist_uids` 黑名单、`max_reply_chars` 回复截断、并发保护(在途请求未完成时新弹幕直接跳过)
主要字段:`enabled`(开关)、`base_url`/`api_key`/`model`、`system_prompt`(人设)、`context_turns`(上下文轮数)。
### system — 系统定时任务
| 字段 | 说明 |
@@ -162,7 +203,7 @@ npm run build # 构建到 web/admin/
```powershell
# 编译检查
E:\Programs\Anaconda3\envs\Live-streaming\python.exe -m py_compile app/danmu_queue.py
.venv-tts\Scripts\python.exe -m py_compile app/danmu_queue.py
```
核心文件:
@@ -170,7 +211,9 @@ E:\Programs\Anaconda3\envs\Live-streaming\python.exe -m py_compile app/danmu_que
|------|------|
| `app/main.py` | 入口,启动 Web 服务和子模块 |
| `app/danmu_queue.py` | 弹幕监听、指令处理、队列管理、BGI 控制、TTS、Web API |
| `app/music_monitor.py` | SMTC 音乐监听与点歌调度 |
| `app/bettergi_daily.py` | 自动每日配置生成、秘境别名和队伍参数更新 |
| `app/bettergi_current_party.py` | 当前队伍读取托管配置组、状态协议和防串读校验 |
| `app/music_monitor.py` | 旧版 SMTC 音乐监听兼容模块;当前点歌由 `app/mpv_player.py` 和排队调度负责 |
| `app/core/runtime_paths.py` | 运行时路径解析 |
## 打包
+181
View File
@@ -0,0 +1,181 @@
"""BetterGI adapter helpers for reading the active party preset name."""
from __future__ import annotations
import copy
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
try:
from .bettergi_daily import DailyAutomationError, JsonUpdate
except ImportError:
from bettergi_daily import DailyAutomationError, JsonUpdate
CURRENT_PARTY_SCRIPT_NAME = "LiveCurrentParty"
CURRENT_PARTY_GROUP_NAME = "直播系统读取当前队伍"
CURRENT_PARTY_STATUS_FILE = "status.json"
_REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
_INVALID_BGI_NAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
class CurrentPartyReadError(DailyAutomationError):
"""Raised when the managed current-party reader cannot complete safely."""
@dataclass(frozen=True)
class PreparedCurrentPartyRead:
request_id: str
group_name: str
status_path: Path
updates: tuple[JsonUpdate, ...]
@dataclass(frozen=True)
class CurrentPartyReadResult:
party_name: str
candidates: tuple[str, ...] = ()
def _read_json_object(path: Path, label: str) -> dict[str, Any]:
if not path.is_file():
raise CurrentPartyReadError(f"未找到{label}: {path}")
try:
data = json.loads(path.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError) as exc:
raise CurrentPartyReadError(f"读取{label}失败: {exc}") from exc
if not isinstance(data, dict):
raise CurrentPartyReadError(f"{label}必须是 JSON 对象: {path}")
return data
def _safe_group_name(value: str) -> str:
name = str(value or "").strip()
if not name:
raise CurrentPartyReadError("当前队伍读取配置组名称不能为空")
if name in {".", ".."} or _INVALID_BGI_NAME.search(name):
raise CurrentPartyReadError(f"当前队伍读取配置组名称包含非法字符: {name}")
return name
def _next_group_index(group_dir: Path) -> int:
indexes: list[int] = []
for path in group_dir.glob("*.json"):
try:
data = json.loads(path.read_text(encoding="utf-8-sig"))
value = data.get("index") if isinstance(data, dict) else None
if isinstance(value, int):
indexes.append(value)
except (OSError, json.JSONDecodeError):
continue
return max(indexes, default=0) + 1
def _load_group_template(group_dir: Path, managed_path: Path) -> dict[str, Any]:
if managed_path.is_file():
return _read_json_object(managed_path, "托管当前队伍读取配置组")
for name in ("切换队伍", "修改队员", "每日委托"):
candidate = group_dir / f"{name}.json"
if candidate.is_file():
template = _read_json_object(candidate, f"配置组“{name}”")
template["index"] = _next_group_index(group_dir)
return template
raise CurrentPartyReadError(
"无法生成当前队伍读取配置组:请先在 BGI 创建“切换队伍”“修改队员”或“每日委托”中的任意一个配置组"
)
def prepare_current_party_read(
work_dir: str | Path,
request_id: str,
*,
group_name: str = CURRENT_PARTY_GROUP_NAME,
) -> PreparedCurrentPartyRead:
work_path = Path(work_dir)
if not work_path.is_dir():
raise CurrentPartyReadError(f"BetterGI 工作目录不存在: {work_path}")
request = str(request_id or "").strip()
if not _REQUEST_ID_PATTERN.fullmatch(request):
raise CurrentPartyReadError("当前队伍读取请求 ID 格式无效")
managed_name = _safe_group_name(group_name)
group_dir = work_path / "User" / "ScriptGroup"
if not group_dir.is_dir():
raise CurrentPartyReadError(f"BGI 配置组目录不存在: {group_dir}")
managed_path = group_dir / f"{managed_name}.json"
managed = copy.deepcopy(_load_group_template(group_dir, managed_path))
managed["name"] = managed_name
managed["projects"] = [
{
"name": "读取当前队伍名称",
"folderName": CURRENT_PARTY_SCRIPT_NAME,
"jsScriptSettingsObject": {"requestId": request},
"index": 1,
"type": "Javascript",
"status": "Enabled",
"schedule": "Daily",
"runNum": 1,
"allowJsNotification": True,
"allowJsHTTPHash": "",
}
]
status_path = (
work_path
/ "User"
/ "JsScript"
/ CURRENT_PARTY_SCRIPT_NAME
/ CURRENT_PARTY_STATUS_FILE
)
update = JsonUpdate(managed_path, managed, "生成当前队伍读取配置组")
return PreparedCurrentPartyRead(
request_id=request,
group_name=managed_name,
status_path=status_path,
updates=(update,),
)
def clear_current_party_status(status_path: str | Path) -> None:
path = Path(status_path)
try:
path.unlink(missing_ok=True)
except OSError as exc:
raise CurrentPartyReadError(f"清理当前队伍读取状态失败: {exc}") from exc
def read_current_party_status(
status_path: str | Path,
request_id: str,
) -> CurrentPartyReadResult | None:
path = Path(status_path)
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(data, dict) or str(data.get("request_id") or "") != request_id:
return None
state = str(data.get("state") or "").strip().casefold()
if state in {"", "running"}:
return None
candidates = tuple(
str(value).strip()
for value in data.get("candidates", [])
if str(value).strip()
) if isinstance(data.get("candidates"), list) else ()
if state == "success":
party_name = str(data.get("party_name") or "").strip()
if not party_name:
raise CurrentPartyReadError("当前队伍读取脚本返回成功,但队伍名称为空")
return CurrentPartyReadResult(party_name=party_name, candidates=candidates)
if state == "error":
message = str(data.get("message") or "读取当前队伍失败").strip()
raise CurrentPartyReadError(message)
raise CurrentPartyReadError(f"当前队伍读取脚本返回未知状态: {state}")
+828
View File
@@ -0,0 +1,828 @@
"""BetterGI managed configuration helpers for daily and party commands."""
from __future__ import annotations
import copy
import difflib
import json
import os
import re
import tempfile
import uuid
from dataclasses import dataclass, replace
from functools import lru_cache
from pathlib import Path
from typing import Any, Iterable
class DailyAutomationError(ValueError):
"""Raised when a managed BetterGI command cannot be prepared safely."""
DAILY_MODE_NONE = "none"
DAILY_MODE_DOMAIN = "domain"
DAILY_MODE_LEY_LINE = "ley_line"
DAILY_MODE_COMMISSION = "commission"
LEY_LINE_COUNTRIES = ("蒙德", "璃月", "稻妻", "须弥", "枫丹", "纳塔", "挪德卡莱")
LEY_LINE_TYPE_ALIASES = {
"经验": "启示之花",
"经验花": "启示之花",
"蓝花": "启示之花",
"启示": "启示之花",
"启示之花": "启示之花",
"摩拉": "藏金之花",
"摩拉花": "藏金之花",
"金币": "藏金之花",
"金币花": "藏金之花",
"黄花": "藏金之花",
"藏金": "藏金之花",
"藏金之花": "藏金之花",
}
DAILY_TASK_NAMES = {
"mail": "领取邮件",
"craft_resin": "合成树脂",
"domain": "自动秘境",
"ley_line": "自动地脉花",
"commission": "每日委托",
"serenitea": "领取尘歌壶奖励",
"daily_reward": "领取每日奖励",
}
_INVALID_BGI_NAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
_NAME_NORMALIZE = re.compile(r"[\s\-_—-·.。,::,、/|]+")
_MEMBER_SEPARATOR = re.compile(r"[\s,,、/|]+")
@dataclass(frozen=True)
class DailyRequest:
mode: str = DAILY_MODE_NONE
domain_name: str = ""
ley_line_type: str = ""
ley_line_country: str = ""
@property
def task_name(self) -> str:
if self.mode == DAILY_MODE_DOMAIN:
return f"自动每日(秘境:{self.domain_name})"
if self.mode == DAILY_MODE_LEY_LINE:
return f"自动每日(地脉:{self.ley_line_type}/{self.ley_line_country})"
if self.mode == DAILY_MODE_COMMISSION:
return "自动每日(委托)"
return "自动每日"
@property
def summary(self) -> str:
if self.mode == DAILY_MODE_DOMAIN:
return f"秘境 {self.domain_name}"
if self.mode == DAILY_MODE_LEY_LINE:
return f"地脉 {self.ley_line_type} {self.ley_line_country}"
if self.mode == DAILY_MODE_COMMISSION:
return "每日委托"
return "跳过其他任务"
@dataclass(frozen=True)
class JsonUpdate:
path: Path
data: dict[str, Any]
reason: str
@dataclass(frozen=True)
class PreparedDailyRun:
request: DailyRequest
config_name: str
updates: tuple[JsonUpdate, ...]
requires_current_party: bool = False
@property
def task_name(self) -> str:
return self.request.task_name
def _normalize_name(value: str) -> str:
return _NAME_NORMALIZE.sub("", str(value or "").strip().casefold())
def _safe_bgi_name(value: str, label: str) -> str:
name = str(value or "").strip()
if not name:
raise DailyAutomationError(f"{label}不能为空")
if name in {".", ".."} or _INVALID_BGI_NAME.search(name):
raise DailyAutomationError(f"{label}包含非法文件名字符: {name}")
return name
def _read_json_object(path: Path, label: str) -> dict[str, Any]:
if not path.exists():
raise DailyAutomationError(f"未找到{label}: {path}")
try:
data = json.loads(path.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError) as exc:
raise DailyAutomationError(f"读取{label}失败: {exc}") from exc
if not isinstance(data, dict):
raise DailyAutomationError(f"{label}必须是 JSON 对象: {path}")
return data
def parse_daily_request(argument: str) -> DailyRequest:
text = str(argument or "").strip()
if not text:
return DailyRequest()
parts = text.split()
mode = parts[0]
if mode == "秘境":
domain_name = " ".join(parts[1:]).strip()
if not domain_name:
raise DailyAutomationError("秘境模式需要指定秘境,例如:自动每日 秘境 风本")
return DailyRequest(mode=DAILY_MODE_DOMAIN, domain_name=domain_name)
if mode in {"地脉", "地脉花"}:
if len(parts) != 3:
raise DailyAutomationError("地脉模式格式:自动每日 地脉 <经验|摩拉> <国家>")
type_name = LEY_LINE_TYPE_ALIASES.get(_normalize_name(parts[1]))
if not type_name:
raise DailyAutomationError("地脉花类型仅支持经验或摩拉")
country = parts[2].strip()
if country not in LEY_LINE_COUNTRIES:
raise DailyAutomationError(
f"不支持的地脉国家'{country}',可用:{'、'.join(LEY_LINE_COUNTRIES)}"
)
return DailyRequest(
mode=DAILY_MODE_LEY_LINE,
ley_line_type=type_name,
ley_line_country=country,
)
if mode in {"委托", "每日委托"}:
if len(parts) != 1:
raise DailyAutomationError("委托模式不接受额外参数,格式:自动每日 委托")
return DailyRequest(mode=DAILY_MODE_COMMISSION)
raise DailyAutomationError(
"每日模式仅支持:秘境、地脉、委托;不指定模式时直接发送“自动每日”"
)
class DomainAliasResolver:
def __init__(self, alias_path: Path, bettergi_work_dir: Path):
self.alias_path = Path(alias_path)
self.bettergi_work_dir = Path(bettergi_work_dir)
def _available_domains(self) -> list[str]:
settings_path = (
self.bettergi_work_dir
/ "User"
/ "JsScript"
/ "AutoDomain"
/ "settings.json"
)
if not settings_path.exists():
raise DailyAutomationError(
f"未找到 BGI 自动秘境设置文件,请安装或更新 AutoDomain 脚本: {settings_path}"
)
try:
settings = json.loads(settings_path.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError) as exc:
raise DailyAutomationError(f"读取 BGI 秘境列表失败: {exc}") from exc
if not isinstance(settings, list):
raise DailyAutomationError("BGI AutoDomain/settings.json 格式无效")
for item in settings:
if isinstance(item, dict) and item.get("name") == "domainName":
options = item.get("options")
if isinstance(options, list):
domains = [str(value).strip() for value in options if str(value).strip()]
if domains:
return domains
raise DailyAutomationError("BGI AutoDomain/settings.json 中没有秘境名称列表")
def resolve(self, raw_name: str) -> str:
available = self._available_domains()
available_set = set(available)
alias_data = _read_json_object(self.alias_path, "秘境俗称文件")
lookup: dict[str, str] = {}
display_terms: dict[str, str] = {}
def add(term: str, canonical: str) -> None:
normalized = _normalize_name(term)
if not normalized:
return
previous = lookup.get(normalized)
if previous and previous != canonical:
raise DailyAutomationError(
f"秘境俗称'{term}'同时指向'{previous}'和'{canonical}'"
)
lookup[normalized] = canonical
display_terms[normalized] = str(term).strip()
for canonical in available:
add(canonical, canonical)
for canonical, aliases in alias_data.items():
if str(canonical).startswith("_"):
continue
canonical_name = str(canonical).strip()
if canonical_name not in available_set:
raise DailyAutomationError(
f"秘境俗称文件中的正式名称不受当前 BGI 支持: {canonical_name}"
)
if not isinstance(aliases, list):
raise DailyAutomationError(f"秘境'{canonical_name}'的俗称必须是数组")
add(canonical_name, canonical_name)
for alias in aliases:
add(str(alias), canonical_name)
normalized_input = _normalize_name(raw_name)
resolved = lookup.get(normalized_input)
if resolved:
return resolved
matches = difflib.get_close_matches(normalized_input, list(lookup), n=3, cutoff=0.45)
if matches:
suggestions = []
for match in matches:
canonical = lookup[match]
display = display_terms.get(match, canonical)
suggestion = canonical if display == canonical else f"{display}({canonical})"
if suggestion not in suggestions:
suggestions.append(suggestion)
raise DailyAutomationError(
f"未知秘境'{raw_name}',可能是:{'、'.join(suggestions)}"
)
raise DailyAutomationError(f"未知秘境'{raw_name}',请检查 config/domain_aliases.json")
def _validate_strategy(work_dir: Path, strategy_name: str, label: str) -> None:
strategy = str(strategy_name or "").strip()
if not strategy:
raise DailyAutomationError(f"{label}未配置战斗策略")
auto_fight_dir = work_dir / "User" / "AutoFight"
if strategy == "根据队伍自动选择":
if not auto_fight_dir.is_dir():
raise DailyAutomationError(f"{label}战斗策略目录不存在: {auto_fight_dir}")
return
json_path = auto_fight_dir / f"{strategy}.json"
txt_path = auto_fight_dir / f"{strategy}.txt"
if not json_path.is_file() and not txt_path.is_file():
raise DailyAutomationError(f"{label}战斗策略文件不存在: {strategy}")
def _template_task_id_candidates(template: dict[str, Any]) -> dict[str, list[str]]:
enabled = template.get("TaskEnabledList")
order = template.get("TaskOrder")
definitions = template.get("TaskDefinitions")
if not isinstance(enabled, dict):
raise DailyAutomationError("一条龙模板缺少 TaskEnabledList 对象")
if order is None:
order = []
if not isinstance(order, list):
raise DailyAutomationError("一条龙模板缺少 TaskOrder 数组")
if definitions is None:
definitions = {}
if not isinstance(definitions, dict):
# BetterGI 为每个一条龙配置独立生成任务 ID,只能按任务名复用模板 ID。
raise DailyAutomationError("一条龙模板的 TaskDefinitions 必须是对象")
ordered_ids: list[str] = []
for raw_id in [*order, *definitions.keys(), *enabled.keys()]:
task_id = str(raw_id or "").strip()
if task_id and task_id not in ordered_ids:
ordered_ids.append(task_id)
candidates: dict[str, list[str]] = {}
old_format = not definitions
for task_id in ordered_ids:
raw_name = task_id if old_format else definitions.get(task_id)
task_name = str(raw_name or "").strip()
if not task_name:
continue
candidates.setdefault(task_name, []).append(task_id)
return candidates
def _build_task_entries(
template: dict[str, Any],
task_keys: list[str],
) -> list[tuple[str, str]]:
candidates = _template_task_id_candidates(template)
reserved_ids = {
task_id
for ids in candidates.values()
for task_id in ids
}
used_ids: set[str] = set()
entries: list[tuple[str, str]] = []
for task_key in task_keys:
task_name = DAILY_TASK_NAMES[task_key]
task_id = next(
(candidate for candidate in candidates.get(task_name, []) if candidate not in used_ids),
"",
)
while not task_id:
candidate = str(uuid.uuid4())
if candidate not in reserved_ids and candidate not in used_ids:
task_id = candidate
used_ids.add(task_id)
reserved_ids.add(task_id)
entries.append((task_id, task_name))
return entries
def _build_one_dragon_config(
template: dict[str, Any],
request: DailyRequest,
managed_name: str,
ley_line_craft_resin_before: bool,
) -> dict[str, Any]:
config = copy.deepcopy(template)
task_keys = ["mail"]
if request.mode != DAILY_MODE_LEY_LINE or ley_line_craft_resin_before:
task_keys.append("craft_resin")
if request.mode == DAILY_MODE_DOMAIN:
task_keys.append("domain")
elif request.mode == DAILY_MODE_LEY_LINE:
task_keys.append("ley_line")
elif request.mode == DAILY_MODE_COMMISSION:
task_keys.append("commission")
task_keys.extend(["serenitea", "daily_reward"])
task_entries = _build_task_entries(template, task_keys)
task_order = [task_id for task_id, _ in task_entries]
config["TaskEnabledList"] = {task_id: True for task_id in task_order}
config["TaskOrder"] = task_order
config["TaskDefinitions"] = dict(task_entries)
config["Name"] = managed_name
config["NextTaskId"] = ""
config["CompletionAction"] = "无"
if request.mode == DAILY_MODE_DOMAIN:
config["WeeklyDomainEnabled"] = False
config["DomainName"] = request.domain_name
if request.mode == DAILY_MODE_LEY_LINE:
config["LeyLineOneDragonMode"] = True
config["LeyLineResinExhaustionMode"] = True
config["LeyLineOpenModeCountMin"] = False
config["LeyLineRunCount"] = 1
for day in (
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"
):
config[f"LeyLineRun{day}"] = True
config[f"LeyLine{day}Type"] = request.ley_line_type
config[f"LeyLine{day}Country"] = request.ley_line_country
return config
def _validate_commission_group(
work_path: Path,
*,
use_current_party: bool,
) -> bool:
group_path = work_path / "User" / "ScriptGroup" / "每日委托.json"
group = _read_json_object(group_path, "每日委托配置组")
projects = group.get("projects")
if not isinstance(projects, list):
raise DailyAutomationError("BGI 配置组“每日委托”缺少 projects 数组")
enabled_projects = [
project
for project in projects
if isinstance(project, dict)
and str(project.get("status", "Enabled")).casefold() != "disabled"
]
if not enabled_projects:
raise DailyAutomationError("BGI 配置组“每日委托”没有启用的可执行项目")
uses_auto_commission_nova = any(
str(project.get("folderName") or "") == "AutoCommissionNova"
for project in enabled_projects
)
if not uses_auto_commission_nova:
return False
user_config_path = (
work_path
/ "User"
/ "JsScript"
/ "AutoCommissionNova"
/ "Data"
/ "user-config.json"
)
if not user_config_path.is_file():
raise DailyAutomationError(
"AutoCommissionNova 尚未完成首次配置:缺少 Data/user-config.json;"
"请先在 BGI 中手动运行脚本并保存用户配置和战斗策略"
)
user_config = _read_json_object(user_config_path, "AutoCommissionNova 用户配置")
party = user_config.get("party")
global_party = party.get("global") if isinstance(party, dict) else None
if not isinstance(global_party, dict):
raise DailyAutomationError("AutoCommissionNova 用户配置缺少 party.global 对象")
missing = []
if not use_current_party:
if not str(global_party.get("battleTeamName") or "").strip():
missing.append("战斗队伍")
if not str(global_party.get("elementTeamName") or "").strip():
missing.append("元素采集队伍")
if missing:
raise DailyAutomationError(
f"AutoCommissionNova 首次配置不完整,缺少:{'、'.join(missing)}"
)
strategy_name = str(
global_party.get("battleStrategy") or "根据队伍自动选择"
).strip()
_validate_strategy(work_path, strategy_name, "每日委托")
return bool(use_current_party)
def prepare_daily_run(
work_dir: str | Path,
alias_path: str | Path,
argument: str,
*,
template_name: str,
managed_name: str,
ley_line_craft_resin_before: bool,
commission_use_current_party: bool = False,
) -> PreparedDailyRun:
work_path = Path(work_dir)
if not work_path.is_dir():
raise DailyAutomationError(f"BetterGI 工作目录不存在: {work_path}")
template = _safe_bgi_name(template_name, "一条龙模板名称")
managed = _safe_bgi_name(managed_name, "托管一条龙名称")
if template == managed:
raise DailyAutomationError("一条龙模板名称不能与托管配置名称相同")
request = parse_daily_request(argument)
updates: list[JsonUpdate] = []
requires_current_party = False
user_config_path = work_path / "User" / "config.json"
if request.mode == DAILY_MODE_DOMAIN:
resolved = DomainAliasResolver(Path(alias_path), work_path).resolve(request.domain_name)
request = replace(request, domain_name=resolved)
user_config = _read_json_object(user_config_path, "BGI User/config.json")
auto_fight = user_config.get("autoFightConfig")
auto_domain = user_config.get("autoDomainConfig")
if not isinstance(auto_fight, dict) or not isinstance(auto_domain, dict):
raise DailyAutomationError("当前 BGI 缺少自动战斗或自动秘境配置,请升级到 0.63.0+")
_validate_strategy(work_path, auto_fight.get("strategyName", ""), "自动秘境")
corrected = copy.deepcopy(user_config)
corrected["autoDomainConfig"]["specifyResinUse"] = False
if corrected != user_config:
updates.append(JsonUpdate(user_config_path, corrected, "关闭自动秘境指定树脂次数"))
elif request.mode == DAILY_MODE_LEY_LINE:
user_config = _read_json_object(user_config_path, "BGI User/config.json")
auto_fight = user_config.get("autoFightConfig")
ley_line = user_config.get("autoLeyLineOutcropConfig")
if not isinstance(auto_fight, dict) or not isinstance(ley_line, dict):
raise DailyAutomationError("当前 BGI 缺少自动战斗或自动地脉花配置,请升级到 0.63.0+")
fight_config = ley_line.get("fightConfig")
strategy_name = ""
if isinstance(fight_config, dict):
strategy_name = str(fight_config.get("strategyName") or "").strip()
if not strategy_name:
strategy_name = str(auto_fight.get("strategyName") or "").strip()
_validate_strategy(work_path, strategy_name, "自动地脉花")
if ley_line.get("friendshipTeam") and not ley_line.get("team"):
raise DailyAutomationError("BGI 自动地脉花配置了好感队,但未配置战斗队伍")
corrected = copy.deepcopy(user_config)
corrected["autoLeyLineOutcropConfig"]["isGoToSynthesizer"] = False
if corrected != user_config:
updates.append(JsonUpdate(user_config_path, corrected, "关闭地脉花内部合成树脂"))
elif request.mode == DAILY_MODE_COMMISSION:
requires_current_party = _validate_commission_group(
work_path,
use_current_party=bool(commission_use_current_party),
)
template_path = work_path / "User" / "OneDragon" / f"{template}.json"
template_config = _read_json_object(template_path, "一条龙模板")
managed_config = _build_one_dragon_config(
template_config,
request,
managed,
bool(ley_line_craft_resin_before),
)
managed_path = work_path / "User" / "OneDragon" / f"{managed}.json"
updates.append(JsonUpdate(managed_path, managed_config, "生成直播自动每日一条龙"))
return PreparedDailyRun(
request=request,
config_name=managed,
updates=tuple(updates),
requires_current_party=requires_current_party,
)
def prepare_commission_current_party_update(
work_dir: str | Path,
party_name: str,
) -> JsonUpdate:
work_path = Path(work_dir)
name = str(party_name or "").strip()
if not name:
raise DailyAutomationError("当前队伍名称为空")
if len(name) > 20 or any(ord(char) < 32 for char in name):
raise DailyAutomationError(f"当前队伍名称格式无效: {name}")
user_config_path = (
work_path
/ "User"
/ "JsScript"
/ "AutoCommissionNova"
/ "Data"
/ "user-config.json"
)
user_config = _read_json_object(user_config_path, "AutoCommissionNova 用户配置")
corrected = copy.deepcopy(user_config)
party = corrected.get("party")
global_party = party.get("global") if isinstance(party, dict) else None
if not isinstance(global_party, dict):
raise DailyAutomationError("AutoCommissionNova 用户配置缺少 party.global 对象")
global_party["battleTeamName"] = name
global_party["elementTeamName"] = name
return JsonUpdate(
user_config_path,
corrected,
f"将 AutoCommissionNova 战斗及元素采集队伍更新为当前队伍“{name}”",
)
def _prepare_script_group_update(
work_dir: str | Path,
group_name: str,
folder_name: str,
settings_patch: dict[str, Any],
) -> JsonUpdate:
work_path = Path(work_dir)
safe_group = _safe_bgi_name(group_name, "配置组名称")
group_path = work_path / "User" / "ScriptGroup" / f"{safe_group}.json"
group = _read_json_object(group_path, f"配置组“{safe_group}”")
projects = group.get("projects")
if not isinstance(projects, list):
raise DailyAutomationError(f"配置组“{safe_group}”缺少 projects 数组")
matches = [
project
for project in projects
if isinstance(project, dict)
and str(project.get("folderName") or "") == folder_name
and str(project.get("status", "Enabled")).casefold() != "disabled"
]
if not matches:
raise DailyAutomationError(
f"配置组“{safe_group}”中没有启用的 {folder_name} JavaScript 项目"
)
if len(matches) > 1:
raise DailyAutomationError(
f"配置组“{safe_group}”包含多个启用的 {folder_name} 项目,请只保留一个"
)
settings = matches[0].get("jsScriptSettingsObject")
if not isinstance(settings, dict):
settings = {}
matches[0]["jsScriptSettingsObject"] = settings
settings.update(settings_patch)
return JsonUpdate(group_path, group, f"更新配置组“{safe_group}”参数")
def prepare_switch_party_update(work_dir: str | Path, party_name: str) -> JsonUpdate:
party = str(party_name or "").strip()
if not party:
raise DailyAutomationError("队伍名称不能为空")
return _prepare_script_group_update(
work_dir,
"切换队伍",
"AcceleratedEditionSwitchParty",
{"partyName": party},
)
def _add_character_lookup(
lookup: dict[str, str],
ambiguous: set[str],
raw_name: str,
canonical: str,
) -> None:
key = _normalize_name(raw_name)
if not key or key in ambiguous:
return
previous = lookup.get(key)
if previous and previous != canonical:
lookup.pop(key, None)
ambiguous.add(key)
return
lookup[key] = canonical
def _character_lookups_from_settings(
data: Any,
) -> tuple[dict[str, str], dict[str, str]] | None:
if not isinstance(data, list):
return None
position_options: dict[str, list[str]] = {}
for item in data:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "")
if name not in {"position1", "position2", "position3", "position4"}:
continue
options = item.get("options")
if isinstance(options, list):
position_options[name] = [
str(option).strip()
for option in options
if str(option).strip()
]
if len(position_options) != 4 or not position_options.get("position1"):
return None
full_lookup: dict[str, str] = {}
simple_lookup: dict[str, str] = {}
ambiguous_full: set[str] = set()
ambiguous_simple: set[str] = set()
for option in position_options["position1"]:
_add_character_lookup(full_lookup, ambiguous_full, option, option)
simple_name = option.rsplit("-", 1)[-1].strip()
_add_character_lookup(simple_lookup, ambiguous_simple, simple_name, option)
if not simple_lookup:
return None
return full_lookup, simple_lookup
def _character_lookups_from_combat_avatar(
data: Any,
) -> tuple[dict[str, str], dict[str, str]] | None:
if not isinstance(data, list):
return None
full_lookup: dict[str, str] = {}
simple_lookup: dict[str, str] = {}
ambiguous_full: set[str] = set()
ambiguous_simple: set[str] = set()
for item in data:
if not isinstance(item, dict):
continue
canonical = str(item.get("name") or "").strip()
if not canonical:
continue
terms = [canonical]
aliases = item.get("alias")
if isinstance(aliases, list):
terms.extend(str(alias).strip() for alias in aliases if str(alias).strip())
for term in terms:
_add_character_lookup(full_lookup, ambiguous_full, term, canonical)
_add_character_lookup(simple_lookup, ambiguous_simple, term, canonical)
if not simple_lookup:
return None
return full_lookup, simple_lookup
def _load_character_options(work_dir: Path) -> tuple[dict[str, str], dict[str, str]]:
script_dir = work_dir / "User" / "JsScript" / "AutoSwitchRoles"
settings_path = script_dir / "settings.json"
avatar_path = script_dir / "combat_avatar.json"
failures: list[str] = []
if settings_path.exists():
try:
settings_data = json.loads(settings_path.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError) as exc:
failures.append(f"settings.json 读取失败: {exc}")
else:
lookups = _character_lookups_from_settings(settings_data)
if lookups:
return lookups
failures.append("settings.json 未提供四个队员位置的 options")
else:
failures.append("缺少 settings.json")
if avatar_path.exists():
try:
avatar_data = json.loads(avatar_path.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError) as exc:
failures.append(f"combat_avatar.json 读取失败: {exc}")
else:
lookups = _character_lookups_from_combat_avatar(avatar_data)
if lookups:
return lookups
failures.append("combat_avatar.json 中没有可用角色")
else:
failures.append("缺少 combat_avatar.json")
raise DailyAutomationError(
"AutoSwitchRoles 角色数据不可用,请安装或更新“配对界面切换角色”脚本: "
+ ";".join(failures)
)
def _resolve_member_token(
token: str,
full_lookup: dict[str, str],
simple_lookup: dict[str, str],
) -> str:
key = _normalize_name(token)
resolved = full_lookup.get(key) or simple_lookup.get(key)
if not resolved:
raise DailyAutomationError(f"未知或有歧义的角色名称: {token}")
return resolved
def _split_contiguous_members(text: str, simple_lookup: dict[str, str]) -> list[str]:
normalized = _normalize_name(text)
candidates = sorted(simple_lookup, key=len, reverse=True)
@lru_cache(maxsize=None)
def walk(offset: int, slots: int) -> tuple[tuple[str, ...], ...]:
if slots == 4:
return ((),) if offset == len(normalized) else ()
if offset >= len(normalized):
return ()
results: list[tuple[str, ...]] = []
for candidate in candidates:
if not normalized.startswith(candidate, offset):
continue
for remainder in walk(offset + len(candidate), slots + 1):
results.append((candidate, *remainder))
if len(results) >= 2:
return tuple(results)
return tuple(results)
segmentations = walk(0, 0)
if not segmentations:
raise DailyAutomationError("队员必须是4人,请使用空格、逗号、顿号或斜杠分隔")
if len(segmentations) > 1:
raise DailyAutomationError("连续角色名存在多种拆分方式,请使用空格分隔四名角色")
return [simple_lookup[key] for key in segmentations[0]]
def resolve_party_members(work_dir: str | Path, argument: str) -> tuple[list[str], list[str]]:
text = str(argument or "").strip()
if not text:
raise DailyAutomationError("队员必须是4人")
full_lookup, simple_lookup = _load_character_options(Path(work_dir))
parts = [part for part in _MEMBER_SEPARATOR.split(text) if part]
if len(parts) == 4:
resolved = [
_resolve_member_token(part, full_lookup, simple_lookup)
for part in parts
]
else:
resolved = _split_contiguous_members(text, simple_lookup)
if len(resolved) != 4:
raise DailyAutomationError("队员必须是4人")
if len(set(resolved)) != 4:
raise DailyAutomationError("四名队员不能重复")
display_names = [value.rsplit("-", 1)[-1] for value in resolved]
return resolved, display_names
def prepare_edit_party_update(
work_dir: str | Path,
argument: str,
) -> tuple[JsonUpdate, tuple[str, ...]]:
resolved, display_names = resolve_party_members(work_dir, argument)
update = _prepare_script_group_update(
work_dir,
"修改队员",
"AutoSwitchRoles",
{f"position{index + 1}": value for index, value in enumerate(resolved)},
)
return update, tuple(display_names)
def _write_json_atomic(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(
prefix=f".{path.name}.",
suffix=".tmp",
dir=str(path.parent),
)
temp_path = Path(temp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_path, path)
finally:
if temp_path.exists():
temp_path.unlink()
def apply_json_updates(updates: Iterable[JsonUpdate]) -> None:
seen: set[Path] = set()
for update in updates:
path = Path(update.path)
resolved = path.resolve()
if resolved in seen:
raise DailyAutomationError(f"同一配置文件被重复更新: {path}")
seen.add(resolved)
_write_json_atomic(path, update.data)
+1586 -203
View File
File diff suppressed because it is too large Load Diff
+196 -11
View File
@@ -1,8 +1,10 @@
from __future__ import annotations
import gc
import io
import multiprocessing
import os
import sys
import threading
import time
import traceback
@@ -13,13 +15,16 @@ from typing import Any, Callable
MAX_NEW_TOKENS = 384
DEFAULT_SYNTHESIS_TIMEOUT_SECONDS = 120.0
DEFAULT_STARTUP_TIMEOUT_SECONDS = 480.0
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"
STARTUP_PROGRESS_POLL_SECONDS = 1.0
class FasterQwenWorkerError(RuntimeError):
@@ -122,15 +127,26 @@ def _apply_worker_process_limits(settings: dict[str, Any]) -> dict[str, Any]:
}
def _load_runtime(settings: dict[str, Any]) -> dict[str, Any]:
def _load_runtime(
settings: dict[str, Any],
progress: Callable[[str], None] | None = None,
) -> dict[str, Any]:
def report(stage: str) -> None:
if progress:
progress(stage)
device = str(settings.get("device") or "cuda")
if device == "cpu" and "CUDA_VISIBLE_DEVICES" not in os.environ:
os.environ["CUDA_VISIBLE_DEVICES"] = ""
cpu_threads = _configure_worker_environment(settings)
report("torch_import_started")
import torch
report("torch_import_complete")
_configure_torch_threads(torch, cpu_threads)
report("tts_library_import_started")
from faster_qwen3_tts import FasterQwen3TTS
report("tts_library_import_complete")
if device == "cpu":
torch.cuda.is_available = lambda: False
@@ -138,10 +154,12 @@ def _load_runtime(settings: dict[str, Any]) -> dict[str, Any]:
load_kwargs: dict[str, Any] = {}
if device == "cpu":
load_kwargs["device"] = "cpu"
report("from_pretrained_started")
model = FasterQwen3TTS.from_pretrained(
str(settings.get("model_name_or_path") or "Qwen/Qwen3-TTS-12Hz-0.6B-Base"),
**load_kwargs,
)
report("from_pretrained_complete")
# voice_clone_prompt 不再预计算:当前 faster_qwen3_tts 的 FasterQwen3TTS 没有
# create_voice_clone_prompt 方法,预计算只会失败。改为每次合成时在
@@ -192,22 +210,132 @@ def _synthesize_wav(runtime: dict[str, Any], text: str) -> bytes:
return output.getvalue()
def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) -> None:
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:
connection.send({
"type": "progress",
"stage": stage,
"pid": os.getpid(),
**details,
})
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)
process_limits = _apply_worker_process_limits(settings)
report("environment_ready", cpu_threads=cpu_threads)
load_started = time.monotonic()
runtime = _load_runtime(settings)
report("model_loading", model_name=str(settings.get("model_name_or_path") or ""))
runtime = _load_runtime(settings, progress=report)
load_ms = int((time.monotonic() - load_started) * 1000)
report("model_loaded", load_ms=load_ms)
warmup_started = time.monotonic()
graph_warmup_ms = 0
report("warmup_started")
warmup = getattr(runtime["model"], "warmup", None)
if callable(warmup):
graph_warmup_started = time.monotonic()
warmup()
graph_warmup_ms = int((time.monotonic() - graph_warmup_started) * 1000)
report("graph_warmup_complete", graph_warmup_ms=graph_warmup_ms)
# model.warmup() 只捕获模型内部 CUDA Graph,不会初始化参考音频和
# voice-clone 生成路径。若把这一步留到首条播报,后台会在近一分钟内
# 看起来毫无响应,因此在降低进程优先级之前完成一次真实短句合成。
voice_clone_warmup_started = time.monotonic()
report("voice_clone_warmup_started")
_synthesize_wav(runtime, "系统启动")
voice_clone_warmup_ms = int((time.monotonic() - voice_clone_warmup_started) * 1000)
report("voice_clone_warmup_complete", voice_clone_warmup_ms=voice_clone_warmup_ms)
warmup_ms = int((time.monotonic() - warmup_started) * 1000)
report(
"warmup_complete",
warmup_ms=warmup_ms,
graph_warmup_ms=graph_warmup_ms,
voice_clone_warmup_ms=voice_clone_warmup_ms,
)
# 加载和 CUDA Graph 捕获需要完整 CPU 调度能力。仅在预热完成后降低
# worker 优先级和亲和性,避免繁忙直播环境下启动时间被放大到超时。
process_limits = _apply_worker_process_limits(settings)
connection.send({
"type": "ready",
"pid": os.getpid(),
"load_ms": load_ms,
"warmup_ms": warmup_ms,
"graph_warmup_ms": graph_warmup_ms,
"voice_clone_warmup_ms": voice_clone_warmup_ms,
"max_new_tokens": MAX_NEW_TOKENS,
"cpu_threads": cpu_threads,
**process_limits,
@@ -225,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()
@@ -240,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",
@@ -278,7 +426,7 @@ class FasterQwenWorkerClient:
self.settings = dict(settings)
self.logger = logger
self.synthesis_timeout_seconds = max(1.0, float(synthesis_timeout_seconds))
self.startup_timeout_seconds = max(10.0, float(startup_timeout_seconds))
self.startup_timeout_seconds = max(0.1, float(startup_timeout_seconds))
self._context = context or multiprocessing.get_context("spawn")
self._process_target = process_target or faster_qwen_worker_main
self._lock = threading.RLock()
@@ -330,21 +478,55 @@ class FasterQwenWorkerClient:
self._worker_pid = int(getattr(process, "pid", 0) or 0)
self._log("info", "[FasterQwenTTS] worker 已启动, pid=%s,正在加载和预热", self.worker_pid)
if not parent_connection.poll(self.startup_timeout_seconds):
started = time.monotonic()
deadline = started + self.startup_timeout_seconds
last_stage = "process_spawned"
message: Any = None
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
self._terminate_worker_locked("startup_timeout", graceful=False)
raise FasterQwenWorkerError(
f"Faster-Qwen3-TTS worker startup exceeded {self.startup_timeout_seconds:.0f}s"
f"Faster-Qwen3-TTS worker startup exceeded {self.startup_timeout_seconds:.0f}s "
f"(last_stage={last_stage})"
)
if not parent_connection.poll(min(STARTUP_PROGRESS_POLL_SECONDS, remaining)):
if not process.is_alive():
exit_code = getattr(process, "exitcode", None)
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
self._terminate_worker_locked("startup_process_exited", graceful=False)
raise FasterQwenWorkerError(
f"Faster-Qwen3-TTS worker exited during startup "
f"(exit_code={exit_code}, last_stage={last_stage})"
)
continue
try:
message = parent_connection.recv()
except (EOFError, OSError) as exc:
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
self._terminate_worker_locked("startup_connection_closed", graceful=False)
raise FasterQwenWorkerError("Faster-Qwen3-TTS worker exited during startup") from exc
raise FasterQwenWorkerError(
f"Faster-Qwen3-TTS worker connection closed during startup "
f"(last_stage={last_stage})"
) from exc
if isinstance(message, dict) and message.get("type") == "progress":
last_stage = str(message.get("stage") or "unknown")
elapsed_ms = int((time.monotonic() - started) * 1000)
self._log(
"info",
"[FasterQwenTTS] worker 启动进度: %s, elapsed=%sms",
last_stage,
elapsed_ms,
)
continue
break
if not isinstance(message, dict) or message.get("type") != "ready":
if isinstance(message, dict):
error = str(message.get("error") or message.get("error_type") or "unknown startup error")
worker_traceback = str(message.get("traceback") or "").strip()
if worker_traceback:
self._log("error", "[FasterQwenTTS] worker 启动异常:\n%s", worker_traceback)
else:
error = "invalid startup response"
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
@@ -353,10 +535,13 @@ class FasterQwenWorkerClient:
self._next_start_after = 0.0
self._log(
"info",
"[FasterQwenTTS] worker 预热完成, pid=%s load=%sms warmup=%sms max_new_tokens=%s",
"[FasterQwenTTS] worker 预热完成, pid=%s load=%sms warmup=%sms "
"graph=%sms voice_clone=%sms max_new_tokens=%s",
self.worker_pid,
message.get("load_ms"),
message.get("warmup_ms"),
message.get("graph_warmup_ms"),
message.get("voice_clone_warmup_ms"),
message.get("max_new_tokens"),
)
self._log(
+397
View File
@@ -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
+5 -11
View File
@@ -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,7 +198,10 @@ 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))
# 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)
@@ -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
+142
View File
@@ -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)
+24
View File
@@ -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 (
+3
View File
@@ -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")
+58 -12
View File
@@ -2,7 +2,7 @@
"_说明": "BetterGI弹幕联动配置。改完保存即可,程序支持热说明请重启生效。",
"bilibili": {
"_说明": "room_id 可填短号。请在浏览器登录B站后,把 SESSDATA、bili_jct、buvid3 填入 cookie 字段(或分别填入 sessdata/bili_jct/buvid3),才能监听弹幕和回复。",
"room_id": 1871977513,
"room_id": 0,
"cookie": "",
"sessdata": "",
"bili_jct": "",
@@ -12,15 +12,20 @@
},
"bettergi": {
"_说明": "BetterGI.exe 的完整路径。work_dir 留空则自动取 exe 所在目录。",
"exe_path": "C:\\Program Files\\BetterGI\\BetterGI.exe",
"exe_path": "",
"work_dir": ""
},
"daily": {
"one_dragon_template": "默认配置",
"managed_one_dragon_name": "直播系统自动每日",
"ley_line_craft_resin_before": true,
"commission_use_current_party": true,
"current_party_read_timeout_sec": 45
},
"global": {
"_说明": "default_cooldown: 冷却秒数防刷屏。admin_uids: 管理员UID绕过冷却。log_level: DEBUG/INFO/WARNING。restart_mode: gentle温和(执行中跳过)/aggressive激进(先杀BetterGI再重启,立即响应,适合直播)。",
"default_cooldown": 30,
"admin_uids": [
1123997326
],
"admin_uids": [],
"log_level": "INFO",
"restart_mode": "aggressive",
"log_file": "danmu_bettergi.log"
@@ -44,7 +49,7 @@
"model_name_or_path": "vendor/tts-model",
"language": "Chinese",
"ref_audio": "data/ref_audio.wav",
"ref_text": "凯茨莱茵家族的迪奥娜小姐,货物我确实收下了,再次感谢您选择狛荷屋",
"ref_text": "",
"xvec_only": true,
"non_streaming_mode": true,
"chunk_size": 8,
@@ -52,14 +57,15 @@
"streaming": false,
"cpu_threads": 3,
"cpu_affinity_count": 6,
"process_priority": "below_normal"
"process_priority": "below_normal",
"startup_timeout_seconds": 900
}
},
"enable_danmu_reply": true,
"enable_system_danmu": true,
"enable_tts": true,
"enable_tts": false,
"danmu_interval_sec": 1,
"tts_provider": "faster-qwen3-tts",
"tts_provider": "none",
"tts_categories": {
"signin": false,
"queue": true,
@@ -92,7 +98,7 @@
}
},
"frontend": {
"background_image": "/uploads/background.jpg?t=1783666624",
"background_image": "",
"background_opacity": 0.65,
"background_blur": 0,
"background_fit": "cover",
@@ -167,10 +173,10 @@
"reboot_after_stop_delay_sec": 60,
"launch_bilibili_live_enabled": true,
"launch_bilibili_live_time": "08:50",
"bilibili_live_exe": "C:\\Program Files\\bililive\\livehime\\livehime.exe",
"bilibili_live_exe": "",
"launch_genshin_enabled": true,
"launch_genshin_time": "08:50",
"genshin_exe": "C:\\Program Files\\miHoYo Launcher\\games\\Genshin Impact Game\\YuanShen.exe",
"genshin_exe": "",
"bilibili_push_enabled": true,
"bilibili_push_time": "09:00",
"bilibili_push_window_keyword": "直播姬",
@@ -249,6 +255,46 @@
"active_operator"
]
},
"daily": {
"enabled": true,
"aliases": [
"自动每日",
"每日",
"日常",
"做每日",
"做日常"
],
"allowed_roles": [
"super_admin",
"active_operator"
]
},
"switch_party": {
"enabled": true,
"aliases": [
"切换队伍",
"更换队伍",
"换队伍",
"换队"
],
"allowed_roles": [
"super_admin",
"active_operator"
]
},
"edit_party": {
"enabled": true,
"aliases": [
"修改队员",
"更换队员",
"换队员",
"换角色"
],
"allowed_roles": [
"super_admin",
"active_operator"
]
},
"leave": {
"enabled": true,
"aliases": [
+7
View File
@@ -0,0 +1,7 @@
{
"_说明": "键为 BetterGI 正式秘境名,值为可维护的俗称列表;修改后无需重启。",
"铭记之谷": [
"风本",
"少女套"
]
}
+194
View File
@@ -0,0 +1,194 @@
# 自动每日与队伍管理
本功能要求 BetterGI `0.63.0` 或更高版本,系统会通过以下命令行入口启动托管的一条龙配置:
```text
BetterGI.exe startOneDragon <配置名称>
```
## 弹幕指令
```text
自动每日
每日
日常
做每日
做日常
自动每日 秘境 <秘境正式名称或俗称>
自动每日 地脉 <经验|摩拉> <国家>
自动每日 委托
切换队伍 <队伍名称>
更换队伍 <队伍名称>
换队伍 <队伍名称>
换队 <队伍名称>
修改队员 <队员1> <队员2> <队员3> <队员4>
更换队员 <队员1> <队员2> <队员3> <队员4>
换队员 <队员1> <队员2> <队员3> <队员4>
换角色 <队员1> <队员2> <队员3> <队员4>
```
示例:
```text
自动每日
每日
日常 秘境 风本
自动每日 秘境 风本
自动每日 秘境 少女套
自动每日 地脉 经验 蒙德
自动每日 地脉 摩拉 枫丹
自动每日 委托
切换队伍 永冻队
换队 永冻队
修改队员 神里绫华 申鹤 枫原万叶 珊瑚宫心海
换角色 神里绫华 申鹤 枫原万叶 珊瑚宫心海
修改队员 神里绫华、申鹤、枫原万叶、珊瑚宫心海
```
队员支持空格、英文或中文逗号、顿号、斜杠分隔。连续输入四个角色名时,只有能够唯一拆分为四名已知角色才会执行。角色必须存在于 BGI 的 `AutoSwitchRoles` 角色数据中,且四人不能重复。新版脚本同时支持 `combat_avatar.json` 中维护的角色别名。
执行类指令可以省略指令后的空格,例如 `每日秘境 风本`、`换队永冻队`、`换角色芙宁娜纳西妲钟离雷电将军`。系统按最长别名优先解析,因此“换队员”不会被误识别为“换队”。
## 每日流程
每日流程固定为:
```text
领取邮件 > 合成树脂 > 可选其他任务 > 领取尘歌壶奖励 > 领取每日奖励
```
- 直接发送 `自动每日` 时不插入其他任务。
- 秘境模式一直执行到树脂耗尽或 BGI 报错。
- 地脉模式默认在执行前合成树脂,然后一直执行到树脂耗尽或 BGI 报错。
- 委托模式执行配置组“每日委托”。
- 委托启用“使用当前队伍”时,正式任务启动前会先读取游戏当前队伍预设名,并写入 AutoCommissionNova 的战斗与元素采集队伍配置;该准备步骤不进入正式计费任务。
- 其他任务报错时,由 BetterGI 一条龙继续执行后面的尘歌壶和每日奖励任务。
- 一条龙完成后复用现有配置组的计费、三分钟保留队列和任务结算规则。
当 `daily.ley_line_craft_resin_before` 设为 `false` 时,只有地脉模式会跳过“合成树脂”;其他每日模式仍会执行该步骤。
## 项目配置
`config/config.json`:
```json
{
"daily": {
"one_dragon_template": "默认配置",
"managed_one_dragon_name": "直播系统自动每日",
"ley_line_craft_resin_before": true,
"commission_use_current_party": true,
"current_party_read_timeout_sec": 45
}
}
```
| 字段 | 说明 |
|------|------|
| `one_dragon_template` | BGI `User/OneDragon` 中作为来源的一条龙配置名,不含 `.json` |
| `managed_one_dragon_name` | 系统每次覆盖生成并通过命令行启动的配置名 |
| `ley_line_craft_resin_before` | 地脉模式执行前是否插入“合成树脂” |
| `commission_use_current_party` | 委托模式是否读取游戏当前队伍名并覆盖 AutoCommissionNova 的战斗与元素采集队伍 |
| `current_party_read_timeout_sec` | 读取当前队伍名称的超时时间,限制为 10 至 120 秒 |
托管配置每次执行都会从模板重新生成。模板中的合成地区、冒险家协会地区、战斗队伍、好感队和尘歌壶设置会保留,系统会强制把完成动作设为“无”,防止任务结束后退出游戏或关机。
秘境俗称维护在 `config/domain_aliases.json`:
```json
{
"铭记之谷": ["风本", "少女套"]
}
```
键必须是当前 BGI `AutoDomain/settings.json` 中存在的正式秘境名。值是俗称列表。同一俗称不能指向多个秘境;文件在每次指令执行时读取,修改后无需重启本项目。
## BetterGI 配置
### 1. 一条龙模板
在 BetterGI 中创建一条龙配置,名称与 `daily.one_dragon_template` 相同,默认是“默认配置”。在模板中配置:
- 合成浓缩树脂使用的地区。
- 领取每日奖励使用的冒险家协会地区。
- 自动秘境和自动地脉需要的战斗队伍、好感队及其他树脂选项。
- 尘歌壶进入方式和奖励领取设置。
模板的任务勾选和顺序不会直接采用,系统会按固定流程重新生成托管配置。
### 2. 自动秘境
- 安装并配置 BGI 的 `AutoDomain` 脚本。
- 在 BGI 自动战斗配置中选择可用战斗策略,并确保策略文件存在于 `User/AutoFight`。
- 系统会关闭一条龙周计划覆盖并写入解析后的正式秘境名。
- 系统会永久将 `User/config.json` 中 `autoDomainConfig.specifyResinUse` 修正为 `false`,以树脂耗尽为停止条件。
### 3. 自动地脉花
- 在 BGI 中配置自动地脉花使用的战斗策略和队伍。
- 系统会把地脉类型和国家写入星期一至星期日的全部字段。
- 系统会开启树脂耗尽模式,并关闭“运行次数取小值”。
- 系统会永久将 `autoLeyLineOutcropConfig.isGoToSynthesizer` 修正为 `false`。是否在地脉前合成树脂只由本项目的 `ley_line_craft_resin_before` 控制。
支持国家:蒙德、璃月、稻妻、须弥、枫丹、纳塔、挪德卡莱。
### 4. 每日委托配置组
在 BetterGI 的配置组页面创建“每日委托”,加入并启用实际完成每日委托所需的项目。系统不会自动创建此配置组,缺失或没有可执行项目时会拒绝启动自动每日,且不会停止当前任务。
使用 `AutoCommissionNova` 时,首次必须在 BetterGI 中手动运行并完成配置面板,至少保存用户配置和战斗策略。启用“使用当前队伍”后,战斗队伍与元素采集队伍都可以留空;未启用时,这两个队伍名称都必须保存。确认生成以下文件后,再关闭“启动时显示配置面板”:
```text
User/JsScript/AutoCommissionNova/Data/user-config.json
```
系统会在启动“自动每日 委托”前校验该文件。启用当前队伍后,项目会自动同步自维护脚本:
```text
integrations/LiveCurrentParty
```
系统自动生成并覆盖托管配置组“直播系统读取当前队伍”,通过请求 ID 对应的 `status.json` 获取游戏当前队伍预设名,然后只修改:
```text
AutoCommissionNova/Data/user-config.json
party.global.battleTeamName
party.global.elementTeamName
```
不会修改 `AutoCommissionNova` 的任何源码,因此第三方脚本更新不会覆盖本项目逻辑。两个字段使用同一次 OCR 得到的当前队伍预设名。
### 5. 切换队伍配置组
在 BetterGI 创建配置组“切换队伍”,加入并只启用一个文件夹名为 `AcceleratedEditionSwitchParty` 的 JavaScript 项目。系统会在执行前更新该项目的 `jsScriptSettingsObject.partyName`。
### 6. 修改队员配置组
在 BetterGI 创建配置组“修改队员”,加入并只启用一个文件夹名为 `AutoSwitchRoles` 的 JavaScript 项目。系统会按顺序读取:
```text
User/JsScript/AutoSwitchRoles/settings.json
User/JsScript/AutoSwitchRoles/combat_avatar.json
```
旧版脚本会从 `settings.json` 的下拉选项读取角色;`AutoSwitchRoles 6.7.0+` 的位置参数是文本框,系统会改为读取 `combat_avatar.json` 中的正式名称和别名。校验后更新配置组中的 `position1` 到 `position4`。
## 权限与计费
`daily`、`switch_party`、`edit_party` 默认权限与 `run` 相同:一级超管和已完成上号的二级队首。可以在后台“规则”页修改启停状态、别名和允许角色。
三个新功能都复用现有排队和计费规则。切换队伍与修改队员分别执行配置组“切换队伍”和“修改队员”,并按普通配置组的三分钟完成规则结算。
## 排障
- 提示找不到一条龙模板:检查 `User/OneDragon/<模板名>.json` 是否存在,且模板名与配置完全一致。
- 提示秘境未知:检查 BGI `AutoDomain/settings.json` 和 `config/domain_aliases.json` 的正式名称。
- 提示战斗策略不存在:在 BetterGI 中重新选择策略,并确认对应文件位于 `User/AutoFight`。
- 提示配置组缺少 JavaScript 项目:检查配置组名称、项目的 `folderName` 和启用状态。
- 提示 AutoCommissionNova 未完成首次配置:在 BGI 中打开配置面板并保存用户配置与战斗策略;关闭“使用当前队伍”时还需保存战斗队伍和元素采集队伍。
- 提示读取当前队伍失败:确认当前场景允许打开配队界面;若 OCR 返回多个候选,查看 BetterGI 日志中的“当前队伍名称候选”记录。
- 提示角色未知或有歧义:检查 `AutoSwitchRoles/settings.json` 或 `combat_avatar.json`,使用其中唯一对应的名称或别名,并用空格明确分隔四人。
- BGI 启动失败:确认版本为 `0.63.0+`、`bettergi.exe_path` 和 `bettergi.work_dir` 正确,并查看 BetterGI 日志。
- 一条龙执行中子任务报错:查看 BetterGI 日志确认后续“领取尘歌壶奖励”和“领取每日奖励”是否继续;本项目只在整条一条龙完成标记出现后结算。
- 自动秘境异常后扫码上号卡在“退出秘境”:确认项目维护的 `integrations/扫码上号` 已同步。脚本会 OCR 识别“退出秘境”并点击右侧确认,OCR 按钮定位失败时使用 1920×1080 坐标 `(1164, 757)` 兜底。
+138 -1
View File
@@ -15,6 +15,9 @@ const COMMAND_ROLE_DEFAULTS = {
confirm_yes: ["super_admin", "pending_operator"],
confirm_no: ["super_admin", "pending_operator"],
run: ["super_admin", "active_operator"],
daily: ["super_admin", "active_operator"],
switch_party: ["super_admin", "active_operator"],
edit_party: ["super_admin", "active_operator"],
leave: ["super_admin", "active_operator", "viewer"],
reset: ["super_admin"],
points: ROLE_OPTIONS.map(([id]) => id),
@@ -29,6 +32,9 @@ const CMD_LABELS = {
confirm_yes: "确认是",
confirm_no: "确认不是",
run: "执行配置组",
daily: "自动每日",
switch_party: "切换队伍",
edit_party: "修改队员",
leave: "退出",
reset: "重置",
points: "积分",
@@ -36,7 +42,7 @@ const CMD_LABELS = {
help: "帮助"
};
const CMD_HAS_ARG = { run: true };
const CMD_HAS_ARG = { run: true, daily: true, switch_party: true, edit_party: true };
const clone = (value) => JSON.parse(JSON.stringify(value ?? {}));
const splitList = (value) => String(value || "").replaceAll(",", ",").split(",").map((item) => item.trim()).filter(Boolean);
@@ -82,6 +88,12 @@ const ensureDraftShape = (draft) => {
draft.bilibili.cookie_auto_refresh_enabled ??= true;
draft.bilibili.cookie_check_interval_hours ??= 6;
draft.bettergi ||= {};
draft.daily ||= {};
draft.daily.one_dragon_template ||= "默认配置";
draft.daily.managed_one_dragon_name ||= "直播系统自动每日";
draft.daily.ley_line_craft_resin_before ??= true;
draft.daily.commission_use_current_party ??= true;
draft.daily.current_party_read_timeout_sec ??= 45;
draft.global ||= {};
draft.queue ||= {};
draft.frontend ||= {};
@@ -126,6 +138,31 @@ const ensureDraftShape = (draft) => {
draft.rules ||= [];
draft.commands ||= {};
draft.music_monitor.targets ||= [];
draft.llm_reply ||= {};
const llmDefaults = {
enabled: false,
provider: "openai",
base_url: "",
api_key: "",
model: "",
system_prompt: "你是B站直播间里的互动助手,直播间主播在玩原神。观众会发弹幕和你聊天,你用简短口语化的中文回复(不超过50字),语气轻松友好,可以玩梗,但不要讨论政治敏感内容,不要承诺任何游戏代练或交易。",
temperature: 0.8,
max_tokens: 128,
timeout_sec: 20,
retry_attempts: 2,
max_reply_chars: 60,
replies_per_minute: 6,
per_user_cooldown_sec: 30,
context_turns: 6,
min_text_len: 2,
blacklist_uids: [],
ignore_prefixes: ["//"],
reply_via_tts: true,
reply_via_danmu: true
};
for (const [key, value] of Object.entries(llmDefaults)) {
draft.llm_reply[key] ??= value;
}
draft.global.admin_uids ||= [];
draft.global.admin_uidsText = draft.global.admin_uids.join(",");
draft.music_monitor.targetsText = draft.music_monitor.targets.join(",");
@@ -166,6 +203,7 @@ createApp({
["redemption", "兑换码"],
["songs", "点歌"],
["media", "音乐/TTS"],
["llm", "AI回复"],
["system", "系统定时"],
["logs", "日志"],
["raw", "JSON"]
@@ -207,6 +245,8 @@ createApp({
giftTestNum: 1,
giftTestValue: 0,
giftTestResult: "",
llmTestText: "主播在玩什么游戏呀?",
llmTestResult: "",
lastLoadedAt: "",
songKeyword: "",
songResults: [],
@@ -276,6 +316,22 @@ createApp({
lanAdminUrls() {
return this.state.access_urls?.lan_admin || [];
},
llmIgnorePrefixesText: {
get() {
return (this.draft?.llm_reply?.ignore_prefixes || []).join(",");
},
set(value) {
this.draft.llm_reply.ignore_prefixes = String(value || "").split(",").map((s) => s.trim()).filter(Boolean);
}
},
llmBlacklistText: {
get() {
return (this.draft?.llm_reply?.blacklist_uids || []).join(",");
},
set(value) {
this.draft.llm_reply.blacklist_uids = String(value || "").split(",").map((s) => s.trim()).filter(Boolean).map(Number).filter(Number.isFinite);
}
},
songQueue() {
return this.music.song_requests?.queue || this.music.requests || [];
},
@@ -1013,6 +1069,41 @@ createApp({
this.giftTestResult = err.message || String(err);
}
},
async saveLlmReply() {
if (!this.draft?.llm_reply) return;
this.saving = true;
try {
await adminApi("/api/admin/action/save_llm_reply_config", {
method: "POST",
body: JSON.stringify(clone(this.draft.llm_reply))
});
this.notify("AI 回复配置已保存");
} catch (err) {
this.handleAuthLoss(err);
this.notify(err.message || String(err), "error");
} finally {
this.saving = false;
}
},
async testLlmReply() {
if (!this.draft?.llm_reply) return;
this.llmTestResult = "请求中...";
try {
// 先保存当前配置再测试,保证测的是面板上的最新值
await adminApi("/api/admin/action/save_llm_reply_config", {
method: "POST",
body: JSON.stringify(clone(this.draft.llm_reply))
});
const data = await adminApi("/api/admin/action/llm_reply_test", {
method: "POST",
body: JSON.stringify({ text: this.llmTestText, uname: "后台测试", broadcast: false })
});
this.llmTestResult = `${data.reply}(${data.provider}/${data.model},${data.duration_ms}ms)`;
} catch (err) {
this.handleAuthLoss(err);
this.llmTestResult = err.message || String(err);
}
},
async saveSystemSchedule() {
if (!this.draft?.system) return;
this.saving = true;
@@ -1261,6 +1352,11 @@ createApp({
<h2>BetterGI</h2>
<label>BetterGI.exe<input v-model="draft.bettergi.exe_path"></label>
<label>工作目录<input v-model="draft.bettergi.work_dir"></label>
<label>每日一条龙模板<input v-model="draft.daily.one_dragon_template"></label>
<label>托管一条龙名称<input v-model="draft.daily.managed_one_dragon_name"></label>
<label class="inline"><input v-model="draft.daily.ley_line_craft_resin_before" type="checkbox"> 地脉前合成树脂</label>
<label class="inline"><input v-model="draft.daily.commission_use_current_party" type="checkbox"> 委托战斗及采集均使用游戏当前队伍</label>
<label>读取当前队伍超时秒数<input v-model.number="draft.daily.current_party_read_timeout_sec" type="number" min="10" max="120"></label>
<label>超管 UID<input v-model="draft.global.admin_uidsText" @blur="draft.global.admin_uids = splitList(draft.global.admin_uidsText).map((item) => Number(item)).filter((item) => Number.isFinite(item))" placeholder="逗号分隔 UID"></label>
</div>
<div class="panel">
@@ -1648,6 +1744,47 @@ createApp({
</div>
</section>
<section v-if="activeTab==='llm' && draft" class="form-grid">
<div class="panel">
<h2>AI 弹幕回复</h2>
<label class="inline"><input v-model="draft.llm_reply.enabled" type="checkbox"> 启用 LLM 弹幕回复(触发指令的弹幕自动忽略,不会发给 AI)</label>
<label>协议适配
<select v-model="draft.llm_reply.provider">
<option value="openai">OpenAI 兼容(DeepSeek/通义/Kimi/GLM/硅基流动等)</option>
<option value="ollama">Ollama 原生</option>
<option value="anthropic">Anthropic(Claude)</option>
</select>
</label>
<label>接口地址 base_url<input v-model="draft.llm_reply.base_url" placeholder="如 https://api.deepseek.com/v1 或 http://localhost:11434"></label>
<label>API Key<input v-model="draft.llm_reply.api_key" type="password" placeholder="Ollama 本地可留空"></label>
<label>模型<input v-model="draft.llm_reply.model" placeholder="如 deepseek-chat / qwen3 / claude-sonnet-4-5"></label>
<label>人设(系统提示词)<textarea v-model="draft.llm_reply.system_prompt" rows="4"></textarea></label>
</div>
<div class="panel">
<h2>回复与限流</h2>
<label>回复最大字数<input v-model.number="draft.llm_reply.max_reply_chars" type="number" min="10" max="200"></label>
<label>每分钟最多回复条数<input v-model.number="draft.llm_reply.replies_per_minute" type="number" min="0" max="60"></label>
<label>同一观众冷却秒数(0=不限)<input v-model.number="draft.llm_reply.per_user_cooldown_sec" type="number" min="0" max="3600"></label>
<label>上下文轮数<input v-model.number="draft.llm_reply.context_turns" type="number" min="0" max="20"></label>
<label>最短触发字数<input v-model.number="draft.llm_reply.min_text_len" type="number" min="0" max="20"></label>
<label>请求超时秒数<input v-model.number="draft.llm_reply.timeout_sec" type="number" min="3" max="120"></label>
<label>温度<input v-model.number="draft.llm_reply.temperature" type="number" min="0" max="2" step="0.1"></label>
<label class="inline"><input v-model="draft.llm_reply.reply_via_tts" type="checkbox"> 语音播报 AI 回复</label>
<label class="inline"><input v-model="draft.llm_reply.reply_via_danmu" type="checkbox"> 弹幕发送 AI 回复</label>
<label>忽略前缀(逗号分隔,这些开头的弹幕不回复)<input v-model="llmIgnorePrefixesText" placeholder="//"></label>
<label>黑名单 UID(逗号分隔)<input v-model="llmBlacklistText" placeholder="123456,654321"></label>
</div>
<div class="panel">
<h2>测试</h2>
<label>模拟弹幕<input v-model="llmTestText" placeholder="输入一条观众弹幕"></label>
<div class="actions-row">
<button class="primary" :disabled="saving" @click="saveLlmReply">保存配置</button>
<button class="secondary" :disabled="!draft.llm_reply.enabled" @click="testLlmReply">发送测试弹幕并预览回复</button>
</div>
<div v-if="llmTestResult" class="llm-test-result">{{ llmTestResult }}</div>
</div>
</section>
<section v-if="activeTab==='system' && draft" class="form-grid">
<div class="panel">
<h2>直播时间</h2>
+154
View File
@@ -0,0 +1,154 @@
(async function () {
setGameMetrics(1920, 1080, 1);
const STATUS_FILE = "status.json";
const requestId = String(settings.requestId || "").trim();
let lastCandidates = [];
function writeStatus(state, data) {
const payload = Object.assign({
state: state,
request_id: requestId,
party_name: "",
candidates: lastCandidates,
message: "",
updated_at: new Date().toISOString()
}, data || {});
file.writeTextSync(STATUS_FILE, JSON.stringify(payload, null, 2));
}
function normalizeText(value) {
return String(value || "").replace(/\s+/g, "").trim();
}
function isPartyNameCandidate(text) {
if (!text || text.length > 20) {
return false;
}
const ignored = [
"队伍配置",
"快速编队",
"元素共鸣",
"调整队伍",
"当前队伍",
"部署",
"出战",
"详情"
];
if (ignored.some((label) => text === label || text.includes(label))) {
return false;
}
return !/^(esc|enter|space|l|f\d{1,2})$/i.test(text);
}
function isPartyPageOpen() {
const capture = captureGameRegion();
try {
const results = capture.findMulti(RecognitionObject.ocr(0, 0, 720, 180));
for (let index = 0; index < results.count; index++) {
const text = normalizeText(results[index].text);
if (text.includes("队伍配置")) {
return true;
}
}
return false;
} finally {
capture.dispose();
}
}
function recognizePartyNameCandidates() {
const capture = captureGameRegion();
try {
const results = capture.findMulti(RecognitionObject.ocr(0, 940, 520, 140));
const candidates = [];
for (let index = 0; index < results.count; index++) {
const result = results[index];
const text = normalizeText(result.text);
log.info(
"当前队伍名称候选位置:({x},{y},{w},{h}), 识别结果:{text}",
result.x,
result.y,
result.Width,
result.Height,
text
);
if (isPartyNameCandidate(text)) {
candidates.push({
text: text,
x: Number(result.x || 0),
y: Number(result.y || 0)
});
}
}
candidates.sort((left, right) => {
if (left.x !== right.x) {
return left.x - right.x;
}
return right.y - left.y;
});
return candidates.filter(
(candidate, index, values) =>
values.findIndex((value) => value.text === candidate.text) === index
);
} finally {
capture.dispose();
}
}
async function openPartyPageAndRead() {
for (let attempt = 0; attempt < 3; attempt++) {
keyPress("VK_L");
await sleep(2200);
for (let scan = 0; scan < 4; scan++) {
if (isPartyPageOpen()) {
const candidates = recognizePartyNameCandidates();
if (candidates.length > 0) {
return candidates;
}
}
await sleep(600);
}
keyPress("VK_ESCAPE");
await sleep(800);
await genshin.returnMainUi();
}
return [];
}
try {
if (!requestId) {
throw new Error("读取请求标识为空");
}
writeStatus("running");
await genshin.returnMainUi();
const candidates = await openPartyPageAndRead();
lastCandidates = candidates.map((candidate) => candidate.text);
if (candidates.length === 0) {
throw new Error("未识别到当前队伍名称,请确认游戏处于可打开配队界面的状态");
}
if (candidates.length > 1) {
throw new Error("识别到多个队伍名称候选:" + lastCandidates.join("、"));
}
const partyName = candidates[0].text;
await genshin.returnMainUi();
await sleep(500);
writeStatus("success", {
party_name: partyName,
candidates: lastCandidates
});
log.info("当前队伍名称读取成功: {name}", partyName);
} catch (error) {
try {
await genshin.returnMainUi();
} catch (_) {
}
const message = String(error && error.message ? error.message : error);
writeStatus("error", {
candidates: lastCandidates,
message: message
});
log.error("当前队伍名称读取失败: {message}", message);
}
})();
@@ -0,0 +1,17 @@
{
"manifest_version": 1,
"name": "直播系统读取当前队伍",
"version": "1.0.0",
"bgi_version": "0.63.0",
"description": "读取游戏当前队伍预设名称并通过状态文件返回给直播联动系统",
"authors": [
{
"name": "BGI直播项目"
}
],
"settings_ui": "settings.json",
"main": "main.js",
"saved_files": [
"status.json"
]
}
@@ -0,0 +1,8 @@
[
{
"name": "requestId",
"type": "input-text",
"label": "读取请求标识",
"default": ""
}
]
+77 -1
View File
@@ -1,6 +1,6 @@
(async function () {
// ========================================
// 扫码上号 v3
// 扫码上号 v3.1
// 流程: 状态判定 → (已登录则先退出) → A0检测tap → A1选号 → A2等登录+点中心 → A3完成
// 状态文件: status.txt (登录中 / 已登录)
// ========================================
@@ -20,6 +20,25 @@
// 脚本启动 → 写入"登录中"
writeStatus("登录中");
function settingNumber(name, fallback) {
const value = typeof settings !== "undefined" && settings ? Number(settings[name]) : NaN;
return Number.isFinite(value) ? value : fallback;
}
function settingBoolean(name, fallback) {
if (typeof settings === "undefined" || !settings || settings[name] === undefined) return fallback;
return settings[name] !== false && String(settings[name]).toLowerCase() !== "false";
}
const useExitDomainOcr = settingBoolean("useOcr", true);
let confirmExitX = settingNumber("confirmExitX", 1164);
let confirmExitY = settingNumber("confirmExitY", 757);
if (confirmExitX === 830 && confirmExitY === 600) {
confirmExitX = 1164;
confirmExitY = 757;
log.info("检测到旧版退出秘境坐标,已自动修正为 (1164, 757)");
}
// ---------- 加载图像资源 ----------
const tapMat = file.readImageMatSync("assets/tap.png");
const a0PhoneMat = file.readImageMatSync("assets/a0_phone.png");
@@ -115,6 +134,54 @@
return null;
}
function normalizeOcrText(text) {
return String(text || "").replace(/\s+/g, "");
}
function readOcrResults(x, y, w, h) {
const cap = captureGameRegion();
try {
const results = cap.findMulti(RecognitionObject.ocr(x, y, w, h));
return Array.from(results).map((result) => ({
text: normalizeOcrText(result.text),
x: Number(result.x || 0),
y: Number(result.y || 0),
w: Number(result.width || result.Width || 0),
h: Number(result.height || result.Height || 0)
}));
} catch (e) {
log.warn("退出秘境弹窗 OCR 失败: " + e);
return [];
} finally {
cap.dispose();
}
}
async function handleExitDomainDialog() {
const results = readOcrResults(480, 220, 960, 620);
const combinedText = results.map((result) => result.text).join("");
if (!combinedText.includes("退出秘境")) return false;
log.info("检测到退出秘境确认弹窗");
const confirmResult = results
.filter((result) => result.text.includes("确认") && result.x >= 900)
.sort((left, right) => right.x - left.x)[0];
if (useExitDomainOcr && confirmResult) {
const x = Math.round(confirmResult.x + confirmResult.w / 2);
const y = Math.round(confirmResult.y + confirmResult.h / 2);
log.info(`OCR 定位确认按钮,点击 (${x}, ${y})`);
click(x, y);
} else {
const reason = useExitDomainOcr ? "OCR 未定位到确认按钮" : "已关闭 OCR 按钮定位";
log.warn(`${reason},使用固定坐标 (${confirmExitX}, ${confirmExitY})`);
click(confirmExitX, confirmExitY);
}
log.info("已点击退出秘境确认,等待返回主界面...");
await sleep(6000);
return true;
}
// ========================================
// 前置处理:检测登录前的提示图标
// 检测到则点击 (1830, 985),1秒后点击 (1100, 675),再1秒后进入初始判定;
@@ -155,11 +222,20 @@
log.info("未同时检测到 tap 图标和手机图标 → 开始检测派蒙头像");
let paimonFound = findImageMatchRobust(paimonMat);
if (!paimonFound && await handleExitDomainDialog()) {
paimonFound = findImageMatchRobust(paimonMat);
if (paimonFound) log.info("退出秘境后已检测到派蒙头像");
}
for (let i = 0; !paimonFound && i < 8; i++) {
log.info(`第 ${i + 1}/8 次未检测到派蒙头像,按 ESC 后重试...`);
keyPress("Escape");
await sleep(1000);
if (await handleExitDomainDialog()) {
log.info("退出秘境弹窗已处理,重新检测主界面");
}
paimonFound = findImageMatchRobust(paimonMat);
if (paimonFound) log.info("已检测到派蒙头像,继续退出账号流程");
}
if (!paimonFound) {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 1,
"name": "扫码上号",
"version": "1.0",
"version": "1.1",
"bgi_version": "0.48.0",
"description": "直播用扫码上号脚本:判断在大世界则退出到开门页面→点击扫码登录→等待扫码→检测验证码",
"authors": [
+5 -5
View File
@@ -44,7 +44,7 @@
{
"name": "useOcr",
"type": "checkbox",
"label": "使用OCR识别文本(否则用固定坐标)",
"label": "退出秘境确认按钮使用 OCR 定位(关闭后使用固定坐标)",
"default": true
},
{
@@ -53,14 +53,14 @@
{
"name": "confirmExitX",
"type": "input-text",
"label": "确认退出按钮 X(固定坐标模式)",
"default": "830"
"label": "退出秘境确认按钮 X(1920×1080 固定坐标)",
"default": "1164"
},
{
"name": "confirmExitY",
"type": "input-text",
"label": "确认退出按钮 Y(固定坐标模式)",
"default": "600"
"label": "退出秘境确认按钮 Y(1920×1080 固定坐标)",
"default": "757"
},
{
"name": "qrLoginX",
+6 -1
View File
@@ -4,7 +4,12 @@ cd /d "%~dp0"
set PYTHON=%~dp0.venv-tts\Scripts\python.exe
if not exist "%PYTHON%" (
echo Python virtual environment not found: %PYTHON%
set PYTHON=%~dp0.venv\Scripts\python.exe
)
if not exist "%PYTHON%" (
echo Python virtual environment not found.
echo Checked .venv-tts and .venv under: %~dp0
echo Please run setup first or ask WorkBuddy to repair dependencies.
pause
exit /b 1
+31
View File
@@ -0,0 +1,31 @@
import datetime
import sys
import psutil
targets = []
for p in psutil.process_iter(["name", "cmdline"]):
try:
cmd = " ".join(p.info["cmdline"] or [])
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
if "main.py" in cmd and "--role" in cmd:
targets.append(p)
for p in targets:
try:
parent = p.parent()
print(
p.pid,
p.name(),
"started",
datetime.datetime.fromtimestamp(p.create_time()).strftime("%m-%d %H:%M:%S"),
"parent",
parent.pid if parent else None,
parent.name() if parent else "",
)
except psutil.NoSuchProcess:
print(p.pid, "exited")
if not targets:
print("NO_SERVICE_PROCESS")
+33
View File
@@ -0,0 +1,33 @@
import os
import sys
import time
import psutil
TARGET = "perf_sampler.py"
mode = sys.argv[1] if len(sys.argv) > 1 else "kill"
me = os.getpid()
matched = []
for p in psutil.process_iter(["name", "cmdline"]):
if p.pid == me:
continue
try:
parts = [x for x in (p.info["cmdline"] or [])]
# only match when the script is the program argument, not substring of -c code
if any(part.endswith(TARGET) for part in parts):
matched.append(p)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
if mode == "kill":
for p in matched:
try:
p.kill()
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
time.sleep(1)
print("killed:", [p.pid for p in matched])
else:
import datetime
for p in matched:
print(p.pid, p.name(), datetime.datetime.fromtimestamp(p.create_time()).strftime("%H:%M:%S"))
+5 -2
View File
@@ -32,7 +32,9 @@ function Invoke-Checked([string]$File, [string[]]$Arguments) {
function Get-ProjectPython {
$candidates = @(
$env:LIVE_STREAMING_PYTHON,
"E:\Programs\Anaconda3\envs\Live-streaming\python.exe",
(Join-Path $Root ".venv-tts\Scripts\python.exe"),
(Join-Path $Root ".venv\Scripts\python.exe"),
$(if ($env:CONDA_PREFIX) { Join-Path $env:CONDA_PREFIX "python.exe" }),
"python"
) | Where-Object { $_ }
foreach ($candidate in $candidates) {
@@ -45,7 +47,7 @@ function Get-ProjectPython {
} catch {
}
}
throw "No usable Python found. Install Python 3.10+, set LIVE_STREAMING_PYTHON, or use the Live-streaming conda env."
throw "No usable Python found. Create .venv, install Python 3.10+, or set LIVE_STREAMING_PYTHON."
}
$Python = Get-ProjectPython
@@ -197,6 +199,7 @@ New-Item -ItemType Directory -Force -Path (Join-Path $AppDist "logs") | Out-Null
Copy-Item -LiteralPath (Join-Path $Root "web") -Destination (Join-Path $AppDist "web") -Recurse -Force
Copy-Item -LiteralPath (Join-Path $Root "config") -Destination (Join-Path $AppDist "config") -Recurse -Force
Copy-Item -LiteralPath (Join-Path $Root "integrations") -Destination (Join-Path $AppDist "integrations") -Recurse -Force
Copy-Item -LiteralPath (Join-Path $Root "docs") -Destination (Join-Path $AppDist "docs") -Recurse -Force
Copy-Item -LiteralPath (Join-Path $Root "vendor") -Destination (Join-Path $AppDist "vendor") -Recurse -Force
Copy-Item -LiteralPath (Join-Path $Root "README.md") -Destination (Join-Path $AppDist "README.md") -Force
+94
View File
@@ -0,0 +1,94 @@
"""Update the Kirara sprite rectangles in web/index.html from transparent PNGs.
Usage: .venv/Scripts/python.exe scripts/build_kirara_sprite_catalog.py
"""
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
ASSETS = ROOT / "web" / "uploads"
PAGE = ROOT / "web" / "index.html"
START = " // Source rectangles come from the four generated transparent 2×2 walk sheets."
END = " let kiraraDirection = \"front\";"
def frame_boxes(path: Path) -> list[list[int]]:
image = Image.open(path).convert("RGBA")
width, height = image.size
if width < 100 or height < 100:
raise ValueError(f"Sprite is unexpectedly small: {path}")
boxes = []
for frame in range(4):
start = round(frame * width / 4)
stop = round((frame + 1) * width / 4)
alpha = image.crop((start, 0, stop, height)).getchannel("A")
content = alpha.point(lambda value: 255 if value >= 16 else 0).getbbox()
if content is None:
raise ValueError(f"Empty frame {frame}: {path}")
left = max(0, content[0] - 3)
top = max(0, content[1] - 3)
right = min(stop - start, content[2] + 3)
bottom = min(height, content[3] + 3)
boxes.append([start + left, top, right - left, bottom - top])
return boxes
names = [
*(f"kirara_walk_{direction}" for direction in ("front", "back", "left", "right")),
"kirara_eat_rice", "kirara_eat_dango", "kirara_bath_sponge", "kirara_bath_shower",
*(f"kirara_idle_{name}" for name in (
"delivery", "reading", "phone", "sleep", "stretch", "groom",
"butterfly", "tea", "yarn", "humming")),
"kirara_rest_breathe",
*(f"kirara_emotion_{name}_{variant}" for name in (
"plus3", "plus1", "minus1", "minus3") for variant in ("a", "b")),
]
lines = [
" // Crops follow each generated sprite's transparent silhouette.",
" const kiraraSpriteBoxes = {",
]
for name in names:
boxes = frame_boxes(ASSETS / f"{name}.png")
formatted = ", ".join(str(box).replace(" ", "") for box in boxes)
lines.append(f' "{name}": [{formatted}],')
lines += [
" };",
" const kiraraSpriteAsset = name => ({",
" src: `/uploads/${name}.png`, boxes: kiraraSpriteBoxes[name]",
" });",
" const kiraraWalkAssets = Object.fromEntries(",
" [\"front\", \"back\", \"left\", \"right\"].map(direction =>",
" [direction, kiraraSpriteAsset(`kirara_walk_${direction}`)])",
" );",
" const kiraraActionAssets = {",
" feed: [kiraraSpriteAsset(\"kirara_eat_rice\"), kiraraSpriteAsset(\"kirara_eat_dango\")],",
" bath: [kiraraSpriteAsset(\"kirara_bath_sponge\"), kiraraSpriteAsset(\"kirara_bath_shower\")]",
" };",
" const kiraraIdleAssets = Object.fromEntries(",
" [\"delivery\", \"reading\", \"phone\", \"sleep\", \"stretch\",",
" \"groom\", \"butterfly\", \"tea\", \"yarn\", \"humming\"].map(type =>",
" [type, kiraraSpriteAsset(`kirara_idle_${type}`)])",
" );",
" const kiraraRestAsset = kiraraSpriteAsset(\"kirara_rest_breathe\");",
" const kiraraEmotionAssets = {",
]
for delta, word in ((3, "plus3"), (1, "plus1"), (-1, "minus1"), (-3, "minus3")):
lines.append(
f' "{delta}": [kiraraSpriteAsset("kirara_emotion_{word}_a"), '
f'kiraraSpriteAsset("kirara_emotion_{word}_b")],'
)
lines += [" };", ""]
text = PAGE.read_text(encoding="utf-8")
start = text.find(START)
if start < 0:
start = text.find(" // Crops follow each generated sprite's transparent silhouette.")
end = text.find(END, start)
if start < 0 or end < 0:
raise ValueError("Kirara asset block was not found")
PAGE.write_text(text[:start] + "\n".join(lines) + text[end:], encoding="utf-8")
print(f"Updated {len(names)} Kirara sprite sheets in {PAGE}")
+132
View File
@@ -0,0 +1,132 @@
"""Lightweight perf sampler: GPU (nvidia-smi) + system/per-process CPU every 30s.
Writes CSV rows to logs/perf_samples.csv. Designed to run all day in background
so TTS synthesis duration (logs/tts_memory.log) can be correlated against
GPU temperature/clocks and CPU pressure afterwards.
"""
from __future__ import annotations
import csv
import shutil
import subprocess
import time
from datetime import datetime
from pathlib import Path
import psutil
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "logs" / "perf_samples.csv"
INTERVAL_SEC = 30
NVIDIA_SMI = shutil.which("nvidia-smi") or r"C:\Windows\System32\nvidia-smi.exe"
# process name substrings (lowercase) to track individually
TRACKED = {
"livehime": "livehime.exe",
"yuanshen": "yuanshen.exe",
"bettergi": "bettergi.exe",
"mpv": "mpv.exe",
"audiodg": "audiodg.exe",
"python": None, # aggregated below (main + TTS worker)
}
FIELDNAMES = [
"ts",
"gpu_temp_c", "gpu_clock_mhz", "gpu_mem_clock_mhz", "gpu_util_pct",
"gpu_power_w", "gpu_throttle",
"cpu_total_pct",
"cpu_livehime", "cpu_yuanshen", "cpu_bettergi", "cpu_mpv", "cpu_audiodg",
"cpu_python_total", "python_proc_count",
]
def query_gpu() -> dict:
keys = (
"temperature.gpu", "clocks.current.graphics", "clocks.current.memory",
"utilization.gpu", "power.draw", "clocks_event_reasons.active",
)
try:
out = subprocess.check_output(
[NVIDIA_SMI, f"--query-gpu={','.join(keys)}", "--format=csv,noheader,nounits"],
text=True, timeout=5,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
).strip()
temp, clk, memclk, util, power, throttle = [x.strip() for x in out.split(",")]
return {
"gpu_temp_c": temp,
"gpu_clock_mhz": clk,
"gpu_mem_clock_mhz": memclk,
"gpu_util_pct": util,
"gpu_power_w": power,
"gpu_throttle": throttle,
}
except Exception:
return {k: "" for k in FIELDNAMES if k.startswith("gpu_")}
def sample_cpu(procs: dict[str, list[psutil.Process]]) -> dict:
row = {"cpu_total_pct": psutil.cpu_percent(interval=None)}
py_total = 0.0
py_count = 0
for name, plist in procs.items():
total = 0.0
alive = []
for p in plist:
try:
total += p.cpu_percent(interval=None)
alive.append(p)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
procs[name] = alive
if name == "python":
py_total, py_count = total, len(alive)
else:
row[f"cpu_{name}"] = round(total, 1)
row["cpu_python_total"] = round(py_total, 1)
row["python_proc_count"] = py_count
return row
def find_tracked() -> dict[str, list[psutil.Process]]:
found: dict[str, list[psutil.Process]] = {k: [] for k in TRACKED}
for p in psutil.process_iter(["name"]):
try:
pname = (p.info["name"] or "").lower()
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
for key in TRACKED:
if key in pname:
found[key].append(p)
return found
def main() -> None:
OUT.parent.mkdir(parents=True, exist_ok=True)
new_file = not OUT.exists()
procs = find_tracked()
# prime cpu_percent baselines
psutil.cpu_percent(interval=None)
for plist in procs.values():
for p in plist:
try:
p.cpu_percent(interval=None)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
with OUT.open("a", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDNAMES)
if new_file:
writer.writeheader()
while True:
time.sleep(INTERVAL_SEC)
if time.monotonic() % 300 < INTERVAL_SEC: # re-discover processes ~every 5 min
procs = find_tracked()
row = {"ts": datetime.now().isoformat(timespec="seconds")}
row.update(query_gpu())
row.update(sample_cpu(procs))
writer.writerow(row)
f.flush()
if __name__ == "__main__":
main()
+102
View File
@@ -0,0 +1,102 @@
import json
import tempfile
import unittest
from pathlib import Path
from app.bettergi_current_party import (
CURRENT_PARTY_SCRIPT_NAME,
CurrentPartyReadError,
clear_current_party_status,
prepare_current_party_read,
read_current_party_status,
)
class BetterGICurrentPartyTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name)
self.group_dir = self.root / "User" / "ScriptGroup"
self._write_json(
self.group_dir / "切换队伍.json",
{
"index": 2,
"name": "切换队伍",
"config": {"marker": "preserved"},
"projects": [{"name": "旧项目"}],
},
)
self._write_json(
self.group_dir / "其他.json",
{"index": 5, "name": "其他", "config": {}, "projects": []},
)
def tearDown(self):
self.temp_dir.cleanup()
@staticmethod
def _write_json(path: Path, data):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def test_prepare_reader_group_clones_config_and_uses_request_id(self):
prepared = prepare_current_party_read(self.root, "request-123")
update = prepared.updates[0]
self.assertEqual(update.data["index"], 6)
self.assertEqual(update.data["config"]["marker"], "preserved")
self.assertEqual(update.data["name"], "直播系统读取当前队伍")
project = update.data["projects"][0]
self.assertEqual(project["folderName"], CURRENT_PARTY_SCRIPT_NAME)
self.assertEqual(project["jsScriptSettingsObject"]["requestId"], "request-123")
self.assertEqual(
prepared.status_path,
self.root / "User" / "JsScript" / CURRENT_PARTY_SCRIPT_NAME / "status.json",
)
def test_status_reader_ignores_stale_and_running_results(self):
prepared = prepare_current_party_read(self.root, "request-123")
self._write_json(
prepared.status_path,
{"state": "success", "request_id": "old", "party_name": "旧队伍"},
)
self.assertIsNone(read_current_party_status(prepared.status_path, "request-123"))
self._write_json(
prepared.status_path,
{"state": "running", "request_id": "request-123"},
)
self.assertIsNone(read_current_party_status(prepared.status_path, "request-123"))
self._write_json(
prepared.status_path,
{
"state": "success",
"request_id": "request-123",
"party_name": "好感队",
"candidates": ["好感队"],
},
)
result = read_current_party_status(prepared.status_path, "request-123")
self.assertEqual(result.party_name, "好感队")
self.assertEqual(result.candidates, ("好感队",))
def test_status_reader_reports_script_error_and_can_be_cleared(self):
prepared = prepare_current_party_read(self.root, "request-123")
self._write_json(
prepared.status_path,
{
"state": "error",
"request_id": "request-123",
"message": "识别到多个候选",
},
)
with self.assertRaisesRegex(CurrentPartyReadError, "多个候选"):
read_current_party_status(prepared.status_path, "request-123")
clear_current_party_status(prepared.status_path)
self.assertFalse(prepared.status_path.exists())
if __name__ == "__main__":
unittest.main()
+503
View File
@@ -0,0 +1,503 @@
import json
import tempfile
import unittest
from pathlib import Path
from app.bettergi_daily import (
DAILY_MODE_COMMISSION,
DAILY_MODE_DOMAIN,
DAILY_MODE_LEY_LINE,
DAILY_MODE_NONE,
DailyAutomationError,
DomainAliasResolver,
apply_json_updates,
parse_daily_request,
prepare_commission_current_party_update,
prepare_daily_run,
prepare_edit_party_update,
prepare_switch_party_update,
resolve_party_members,
)
class BetterGIDailyTestCase(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name)
self.alias_path = self.root / "domain_aliases.json"
self._write_json(
self.alias_path,
{
"_说明": "test",
"铭记之谷": ["风本", "少女套"],
},
)
self._write_json(
self.root / "User" / "OneDragon" / "默认配置.json",
{
"Name": "默认配置",
"TaskEnabledList": {
"mail-template-id": True,
"serenitea-template-id": True,
"craft-template-id": True,
"domain-template-id": False,
"ley-line-template-id": False,
"daily-reward-template-id": True,
},
"TaskOrder": [
"mail-template-id",
"serenitea-template-id",
"craft-template-id",
"domain-template-id",
"ley-line-template-id",
"daily-reward-template-id",
],
"TaskDefinitions": {
"mail-template-id": "领取邮件",
"serenitea-template-id": "领取尘歌壶奖励",
"craft-template-id": "合成树脂",
"domain-template-id": "自动秘境",
"ley-line-template-id": "自动地脉花",
"daily-reward-template-id": "领取每日奖励",
},
"CraftingBenchCountry": "枫丹",
"AdventurersGuildCountry": "璃月",
"PartyName": "战斗队",
"DailyRewardPartyName": "好感队",
"SecretTreasureObjects": ["须臾树脂"],
"WeeklyDomainEnabled": True,
"DomainName": "旧秘境",
"CompletionAction": "关机",
},
)
self._write_json(
self.root / "User" / "config.json",
{
"marker": "preserved",
"autoFightConfig": {"strategyName": "策略A"},
"autoDomainConfig": {
"specifyResinUse": True,
"other": 1,
},
"autoLeyLineOutcropConfig": {
"isGoToSynthesizer": True,
"team": "战斗队",
"friendshipTeam": "",
"fightConfig": {"strategyName": "策略A"},
},
},
)
strategy = self.root / "User" / "AutoFight" / "策略A.txt"
strategy.parent.mkdir(parents=True, exist_ok=True)
strategy.write_text("战斗策略", encoding="utf-8")
self._write_json(
self.root / "User" / "JsScript" / "AutoDomain" / "settings.json",
[
{
"name": "domainName",
"type": "select",
"options": ["铭记之谷", "仲夏庭园"],
}
],
)
self._write_json(
self.root / "User" / "ScriptGroup" / "每日委托.json",
{"name": "每日委托", "projects": [{"name": "委托", "status": "Enabled"}]},
)
self._write_json(
self.root / "User" / "ScriptGroup" / "切换队伍.json",
{
"name": "切换队伍",
"projects": [
{
"folderName": "AcceleratedEditionSwitchParty",
"status": "Enabled",
"jsScriptSettingsObject": {"partyName": "旧队伍", "debug": True},
}
],
},
)
self._write_json(
self.root / "User" / "ScriptGroup" / "修改队员.json",
{
"name": "修改队员",
"projects": [
{
"folderName": "AutoSwitchRoles",
"status": "Enabled",
"jsScriptSettingsObject": {"position1": ""},
}
],
},
)
options = [
"水-单手剑-芙宁娜",
"冰-长枪-爱可菲",
"草-法器-纳西妲",
"雷-长枪-雷电将军",
"岩-长枪-钟离",
]
self._write_json(
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "settings.json",
[
{"name": f"position{index}", "type": "select", "options": ["", *options]}
for index in range(1, 5)
],
)
def tearDown(self):
self.temp_dir.cleanup()
@staticmethod
def _write_json(path: Path, data):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def _prepare(
self,
argument: str,
*,
craft_before: bool = True,
commission_use_current_party: bool = False,
):
return prepare_daily_run(
self.root,
self.alias_path,
argument,
template_name="默认配置",
managed_name="直播系统自动每日",
ley_line_craft_resin_before=craft_before,
commission_use_current_party=commission_use_current_party,
)
def test_parse_daily_modes(self):
self.assertEqual(parse_daily_request("").mode, DAILY_MODE_NONE)
domain = parse_daily_request("秘境 风本")
self.assertEqual((domain.mode, domain.domain_name), (DAILY_MODE_DOMAIN, "风本"))
ley_line = parse_daily_request("地脉 经验 蒙德")
self.assertEqual(
(ley_line.mode, ley_line.ley_line_type, ley_line.ley_line_country),
(DAILY_MODE_LEY_LINE, "启示之花", "蒙德"),
)
self.assertEqual(parse_daily_request("委托").mode, DAILY_MODE_COMMISSION)
def test_parse_rejects_missing_or_unknown_arguments(self):
with self.assertRaisesRegex(DailyAutomationError, "需要指定秘境"):
parse_daily_request("秘境")
with self.assertRaisesRegex(DailyAutomationError, "地脉模式格式"):
parse_daily_request("地脉 经验")
with self.assertRaisesRegex(DailyAutomationError, "不支持的地脉国家"):
parse_daily_request("地脉 摩拉 天空岛")
with self.assertRaisesRegex(DailyAutomationError, "仅支持"):
parse_daily_request("探索")
def test_domain_alias_resolves_canonical_and_common_names(self):
resolver = DomainAliasResolver(self.alias_path, self.root)
self.assertEqual(resolver.resolve("风本"), "铭记之谷")
self.assertEqual(resolver.resolve("少女套"), "铭记之谷")
self.assertEqual(resolver.resolve("仲夏庭园"), "仲夏庭园")
def test_domain_alias_collision_is_rejected(self):
self._write_json(
self.alias_path,
{
"铭记之谷": ["风本"],
"仲夏庭园": ["风 本"],
},
)
with self.assertRaisesRegex(DailyAutomationError, "同时指向"):
DomainAliasResolver(self.alias_path, self.root).resolve("风本")
def test_domain_run_generates_exact_flow_and_corrects_bgi_config(self):
prepared = self._prepare("秘境 风本")
self.assertEqual(prepared.request.domain_name, "铭记之谷")
self.assertEqual(prepared.task_name, "自动每日(秘境:铭记之谷)")
updates = {update.path.name: update for update in prepared.updates}
corrected = updates["config.json"].data
self.assertFalse(corrected["autoDomainConfig"]["specifyResinUse"])
self.assertEqual(corrected["autoDomainConfig"]["other"], 1)
self.assertEqual(corrected["marker"], "preserved")
managed = updates["直播系统自动每日.json"].data
names = [managed["TaskDefinitions"][task_id] for task_id in managed["TaskOrder"]]
self.assertEqual(
names,
["领取邮件", "合成树脂", "自动秘境", "领取尘歌壶奖励", "领取每日奖励"],
)
self.assertEqual(
managed["TaskOrder"],
[
"mail-template-id",
"craft-template-id",
"domain-template-id",
"serenitea-template-id",
"daily-reward-template-id",
],
)
self.assertFalse(managed["WeeklyDomainEnabled"])
self.assertEqual(managed["DomainName"], "铭记之谷")
self.assertEqual(managed["CompletionAction"], "无")
self.assertEqual(managed["PartyName"], "战斗队")
def test_no_mode_skips_only_the_other_task(self):
prepared = self._prepare("")
managed = prepared.updates[-1].data
names = [managed["TaskDefinitions"][task_id] for task_id in managed["TaskOrder"]]
self.assertEqual(
names,
["领取邮件", "合成树脂", "领取尘歌壶奖励", "领取每日奖励"],
)
def test_ley_line_run_sets_all_days_and_respects_craft_toggle(self):
prepared = self._prepare("地脉 摩拉 枫丹", craft_before=False)
updates = {update.path.name: update for update in prepared.updates}
corrected = updates["config.json"].data
self.assertFalse(corrected["autoLeyLineOutcropConfig"]["isGoToSynthesizer"])
managed = updates["直播系统自动每日.json"].data
names = [managed["TaskDefinitions"][task_id] for task_id in managed["TaskOrder"]]
self.assertEqual(
names,
["领取邮件", "自动地脉花", "领取尘歌壶奖励", "领取每日奖励"],
)
self.assertTrue(managed["LeyLineResinExhaustionMode"])
self.assertFalse(managed["LeyLineOpenModeCountMin"])
for day in ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"):
self.assertTrue(managed[f"LeyLineRun{day}"])
self.assertEqual(managed[f"LeyLine{day}Type"], "藏金之花")
self.assertEqual(managed[f"LeyLine{day}Country"], "枫丹")
def test_commission_is_added_as_a_configuration_group_task(self):
prepared = self._prepare("委托")
managed = prepared.updates[-1].data
names = [managed["TaskDefinitions"][task_id] for task_id in managed["TaskOrder"]]
self.assertEqual(
names,
["领取邮件", "合成树脂", "每日委托", "领取尘歌壶奖励", "领取每日奖励"],
)
commission_id = next(
task_id
for task_id, name in managed["TaskDefinitions"].items()
if name == "每日委托"
)
self.assertNotIn(
commission_id,
{
"mail-template-id",
"craft-template-id",
"serenitea-template-id",
"daily-reward-template-id",
},
)
def test_commission_requires_nonempty_group(self):
(self.root / "User" / "ScriptGroup" / "每日委托.json").unlink()
with self.assertRaisesRegex(DailyAutomationError, "每日委托配置组"):
self._prepare("委托")
def test_auto_commission_nova_requires_complete_first_run_config(self):
self._write_json(
self.root / "User" / "ScriptGroup" / "每日委托.json",
{
"name": "每日委托",
"projects": [
{
"folderName": "AutoCommissionNova",
"status": "Enabled",
}
],
},
)
with self.assertRaisesRegex(DailyAutomationError, "缺少 Data/user-config.json"):
self._prepare("委托")
user_config_path = (
self.root
/ "User"
/ "JsScript"
/ "AutoCommissionNova"
/ "Data"
/ "user-config.json"
)
self._write_json(
user_config_path,
{
"party": {
"global": {
"battleTeamName": "战斗队",
"elementTeamName": "",
"battleStrategy": "策略A",
}
}
},
)
with self.assertRaisesRegex(DailyAutomationError, "元素采集队伍"):
self._prepare("委托")
self._write_json(
user_config_path,
{
"party": {
"global": {
"battleTeamName": "战斗队",
"elementTeamName": "采集队",
"battleStrategy": "策略A",
}
}
},
)
self.assertEqual(self._prepare("委托").request.mode, DAILY_MODE_COMMISSION)
def test_auto_commission_nova_can_inject_current_party_for_battle_and_collection(self):
self._write_json(
self.root / "User" / "ScriptGroup" / "每日委托.json",
{
"name": "每日委托",
"projects": [
{
"folderName": "AutoCommissionNova",
"status": "Enabled",
}
],
},
)
user_config_path = (
self.root
/ "User"
/ "JsScript"
/ "AutoCommissionNova"
/ "Data"
/ "user-config.json"
)
self._write_json(
user_config_path,
{
"marker": "preserved",
"party": {
"global": {
"battleTeamName": "",
"elementTeamName": "",
"battleStrategy": "策略A",
}
},
},
)
prepared = self._prepare("委托", commission_use_current_party=True)
self.assertTrue(prepared.requires_current_party)
update = prepare_commission_current_party_update(self.root, "当前队伍")
self.assertEqual(
update.data["party"]["global"]["battleTeamName"],
"当前队伍",
)
self.assertEqual(
update.data["party"]["global"]["elementTeamName"],
"当前队伍",
)
self.assertEqual(update.data["marker"], "preserved")
def test_switch_party_updates_only_target_setting(self):
update = prepare_switch_party_update(self.root, "深渊队")
project = update.data["projects"][0]
self.assertEqual(project["jsScriptSettingsObject"]["partyName"], "深渊队")
self.assertTrue(project["jsScriptSettingsObject"]["debug"])
def test_edit_party_accepts_separators_and_contiguous_names(self):
separated, display = resolve_party_members(
self.root,
"芙宁娜 爱可菲 纳西妲 雷电将军",
)
self.assertEqual(display, ["芙宁娜", "爱可菲", "纳西妲", "雷电将军"])
contiguous, contiguous_display = resolve_party_members(
self.root,
"芙宁娜爱可菲纳西妲雷电将军",
)
self.assertEqual(contiguous, separated)
self.assertEqual(contiguous_display, display)
update, names = prepare_edit_party_update(
self.root,
"芙宁娜、爱可菲、纳西妲、雷电将军",
)
settings = update.data["projects"][0]["jsScriptSettingsObject"]
self.assertEqual(names, ("芙宁娜", "爱可菲", "纳西妲", "雷电将军"))
self.assertEqual(settings["position4"], "雷-长枪-雷电将军")
def test_edit_party_supports_auto_switch_roles_67_character_data(self):
self._write_json(
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "settings.json",
[
{"name": f"position{index}", "type": "input-text", "default": ""}
for index in range(1, 5)
],
)
self._write_json(
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "combat_avatar.json",
[
{"name": "神里绫华", "alias": ["绫华"]},
{"name": "申鹤", "alias": []},
{"name": "枫原万叶", "alias": ["万叶"]},
{"name": "珊瑚宫心海", "alias": ["心海"]},
],
)
update, names = prepare_edit_party_update(
self.root,
"绫华 申鹤 万叶 心海",
)
settings = update.data["projects"][0]["jsScriptSettingsObject"]
self.assertEqual(names, ("神里绫华", "申鹤", "枫原万叶", "珊瑚宫心海"))
self.assertEqual(
[settings[f"position{index}"] for index in range(1, 5)],
["神里绫华", "申鹤", "枫原万叶", "珊瑚宫心海"],
)
resolved, display = resolve_party_members(
self.root,
"神里绫华申鹤枫原万叶珊瑚宫心海",
)
self.assertEqual(resolved, list(display))
def test_edit_party_rejects_ambiguous_combat_avatar_alias(self):
self._write_json(
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "settings.json",
[
{"name": f"position{index}", "type": "input-text", "default": ""}
for index in range(1, 5)
],
)
self._write_json(
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "combat_avatar.json",
[
{"name": "角色甲", "alias": ["同名"]},
{"name": "角色乙", "alias": ["同名"]},
{"name": "角色丙", "alias": []},
{"name": "角色丁", "alias": []},
{"name": "角色戊", "alias": []},
],
)
with self.assertRaisesRegex(DailyAutomationError, "未知或有歧义"):
resolve_party_members(self.root, "同名 角色丙 角色丁 角色戊")
def test_edit_party_requires_four_distinct_known_members(self):
with self.assertRaisesRegex(DailyAutomationError, "必须是4人"):
resolve_party_members(self.root, "芙宁娜 爱可菲 纳西妲")
with self.assertRaisesRegex(DailyAutomationError, "不能重复"):
resolve_party_members(self.root, "芙宁娜 芙宁娜 纳西妲 雷电将军")
with self.assertRaisesRegex(DailyAutomationError, "未知"):
resolve_party_members(self.root, "芙宁娜 爱可菲 纳西妲 不存在")
def test_apply_json_updates_writes_managed_files(self):
prepared = self._prepare("秘境 风本")
apply_json_updates(prepared.updates)
bgi_config = json.loads((self.root / "User" / "config.json").read_text(encoding="utf-8"))
managed = json.loads(
(self.root / "User" / "OneDragon" / "直播系统自动每日.json").read_text(encoding="utf-8")
)
self.assertFalse(bgi_config["autoDomainConfig"]["specifyResinUse"])
self.assertEqual(managed["DomainName"], "铭记之谷")
if __name__ == "__main__":
unittest.main()
+86 -1
View File
@@ -1,8 +1,10 @@
import asyncio
import logging
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
from app.danmu_queue import BgiLogMonitor, WebServer
@@ -69,5 +71,88 @@ class BgiLogDisplayFormatTests(unittest.TestCase):
)
class BgiLogCompletionTests(unittest.IsolatedAsyncioTestCase):
async def test_configuration_group_marker_still_finishes_current_group(self):
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-group-log"))
callback = AsyncMock()
monitor.set_finish_callback(callback)
with patch.object(monitor, "_get_current_log_size", return_value=0):
monitor.set_current_group("薄荷", "group-run")
monitor._process_line('配置组 "薄荷" 执行结束')
await asyncio.sleep(0)
callback.assert_awaited_once_with("薄荷", "group-run")
self.assertIsNone(monitor._current_group)
async def test_one_dragon_marker_finishes_managed_daily(self):
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-one-dragon-log"))
callback = AsyncMock()
monitor.set_finish_callback(callback)
with patch.object(monitor, "_get_current_log_size", return_value=0):
monitor.set_current_one_dragon("自动每日(委托)", "daily-run")
monitor._process_line("一条龙和配置组任务结束")
await asyncio.sleep(0)
callback.assert_awaited_once_with("自动每日(委托)", "daily-run")
self.assertIsNone(monitor._current_group)
async def test_child_group_completion_does_not_finish_one_dragon_early(self):
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-child-group-log"))
callback = AsyncMock()
monitor.set_finish_callback(callback)
with patch.object(monitor, "_get_current_log_size", return_value=0):
monitor.set_current_one_dragon("自动每日", "daily-run")
monitor._process_line('配置组 "每日委托" 执行结束')
await asyncio.sleep(0)
callback.assert_not_awaited()
self.assertEqual(monitor._current_group, "自动每日")
class BgiLogInterruptTests(unittest.IsolatedAsyncioTestCase):
async def test_task_interrupt_marker_triggers_interrupt_callback(self):
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-interrupt-log"))
finish = AsyncMock()
interrupt = AsyncMock()
monitor.set_finish_callback(finish)
monitor.set_interrupt_callback(interrupt)
with patch.object(monitor, "_get_current_log_size", return_value=0):
monitor.set_current_group("霜仙花", "group-run")
# BetterGI 被外部取消时只写“任务中断”,不写“配置组 X 执行结束”。
monitor._process_line('任务中断:"取消自动任务"')
await asyncio.sleep(0)
interrupt.assert_awaited_once_with("霜仙花", "group-run")
finish.assert_not_awaited()
self.assertIsNone(monitor._current_group)
async def test_cancel_auto_task_marker_triggers_interrupt_callback(self):
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-cancel-log"))
interrupt = AsyncMock()
monitor.set_interrupt_callback(interrupt)
with patch.object(monitor, "_get_current_log_size", return_value=0):
monitor.set_current_group("霜仙花", "group-run")
monitor._process_line("取消自动任务")
await asyncio.sleep(0)
interrupt.assert_awaited_once_with("霜仙花", "group-run")
self.assertIsNone(monitor._current_group)
async def test_interrupt_does_not_fire_when_no_current_group(self):
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-no-group-log"))
interrupt = AsyncMock()
monitor.set_interrupt_callback(interrupt)
monitor._process_line('任务中断:"取消自动任务"')
await asyncio.sleep(0)
interrupt.assert_not_awaited()
if __name__ == "__main__":
unittest.main()
+439
View File
@@ -0,0 +1,439 @@
import asyncio
import json
import logging
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, call, patch
from app.bettergi_current_party import (
CurrentPartyReadError,
CurrentPartyReadResult,
PreparedCurrentPartyRead,
)
from app.bettergi_daily import (
DailyAutomationError,
DailyRequest,
JsonUpdate,
PreparedDailyRun,
)
from app.danmu_queue import (
COMMAND_ALLOWED_ROLE_DEFAULTS,
BetterGIRunner,
CommandHandler,
Config,
build_public_command_hints,
)
class DailyCommandIntegrationTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name)
self.config = SimpleNamespace(
data={
"commands": {
"run": {"enabled": True, "aliases": ["执行", "跑"]},
"daily": {
"enabled": True,
"aliases": ["自动每日", "每日", "日常", "做每日", "做日常"],
},
"switch_party": {
"enabled": True,
"aliases": ["切换队伍", "更换队伍", "换队伍", "换队"],
},
"edit_party": {
"enabled": True,
"aliases": ["修改队员", "更换队员", "换队员", "换角色"],
},
}
},
admin_uids=set(),
bettergi_work_dir=str(self.root),
daily_cfg={
"one_dragon_template": "默认配置",
"managed_one_dragon_name": "直播系统自动每日",
"ley_line_craft_resin_before": True,
"commission_use_current_party": True,
"current_party_read_timeout_sec": 45,
},
broadcast_cfg={"tts_categories": {"execution": True}},
music_monitor_cfg={"request_player": {}},
)
self.queue_mgr = MagicMock()
self.queue_mgr.state = {
"current_admin_uid": 123,
"login_status": "logged_in",
"current_group": None,
"default_running": False,
}
self.queue_mgr.is_admin.return_value = True
self.queue_mgr.start_group.return_value = {
"success": True,
"msg": "ok",
"run_id": "daily-run",
}
self.runner = MagicMock()
self.runner.kill_bgi = AsyncMock()
self.runner.start_groups = AsyncMock(return_value=True)
self.runner.start_one_dragon = AsyncMock(return_value=True)
self.runner.sync_js_script.return_value = True
self.log_monitor = MagicMock()
self.broadcaster = SimpleNamespace(broadcast=AsyncMock())
self.handler = CommandHandler(
self.config,
MagicMock(),
self.queue_mgr,
self.runner,
self.log_monitor,
MagicMock(),
logging.getLogger("test-daily-command"),
broadcaster=self.broadcaster,
)
def tearDown(self):
self.temp_dir.cleanup()
def _prepared_daily(self, *, requires_current_party: bool = False) -> PreparedDailyRun:
return PreparedDailyRun(
request=DailyRequest(mode="commission" if requires_current_party else "none"),
config_name="直播系统自动每日",
updates=(
JsonUpdate(
self.root / "User" / "OneDragon" / "直播系统自动每日.json",
{"Name": "直播系统自动每日"},
"test",
),
),
requires_current_party=requires_current_party,
)
async def test_daily_replaces_old_task_writes_config_and_starts_one_dragon(self):
self.queue_mgr.state["current_group"] = "旧任务"
prepared = self._prepared_daily()
with (
patch("app.danmu_queue.prepare_daily_run", return_value=prepared) as prepare,
patch("app.danmu_queue.apply_json_updates") as apply_updates,
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="daily-run")),
):
await self.handler._cmd_daily(123, "测试用户", "")
prepare.assert_called_once()
self.queue_mgr.interrupt_group.assert_called_once_with(
"daily_replaced",
status="cancelled",
)
self.runner.kill_bgi.assert_awaited_once_with(reason="自动每日配置更新")
apply_updates.assert_called_once_with(prepared.updates)
self.queue_mgr.start_group.assert_called_once_with(
123,
"自动每日",
run_id="daily-run",
)
self.log_monitor.set_current_one_dragon.assert_called_once_with(
"自动每日",
"daily-run",
)
self.runner.start_one_dragon.assert_awaited_once_with("直播系统自动每日")
async def test_daily_validation_failure_does_not_stop_current_task(self):
self.queue_mgr.state["current_group"] = "旧任务"
with patch(
"app.danmu_queue.prepare_daily_run",
side_effect=DailyAutomationError("未找到一条龙模板"),
):
await self.handler._cmd_daily(123, "测试用户", "秘境 风本")
self.queue_mgr.interrupt_group.assert_not_called()
self.runner.kill_bgi.assert_not_awaited()
self.runner.start_one_dragon.assert_not_awaited()
async def test_daily_commission_reads_current_party_before_starting(self):
prepared = self._prepared_daily(requires_current_party=True)
reader_update = JsonUpdate(
self.root / "User" / "ScriptGroup" / "直播系统读取当前队伍.json",
{"projects": []},
"reader",
)
reader = PreparedCurrentPartyRead(
request_id="daily-run",
group_name="直播系统读取当前队伍",
status_path=self.root / "status.json",
updates=(reader_update,),
)
party_update = JsonUpdate(
self.root / "User" / "JsScript" / "AutoCommissionNova" / "Data" / "user-config.json",
{
"party": {
"global": {
"battleTeamName": "好感队",
"elementTeamName": "好感队",
}
}
},
"party",
)
capture = AsyncMock(return_value="好感队")
with (
patch("app.danmu_queue.prepare_daily_run", return_value=prepared) as prepare,
patch("app.danmu_queue.prepare_current_party_read", return_value=reader),
patch(
"app.danmu_queue.prepare_commission_current_party_update",
return_value=party_update,
) as prepare_party,
patch("app.danmu_queue.apply_json_updates") as apply_updates,
patch.object(self.handler, "_capture_current_party_name", capture),
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="daily-run")),
):
await self.handler._cmd_daily(123, "测试用户", "委托")
self.assertTrue(prepare.call_args.kwargs["commission_use_current_party"])
capture.assert_awaited_once_with(reader, 45)
prepare_party.assert_called_once_with(str(self.root), "好感队")
apply_updates.assert_called_once_with((party_update, *prepared.updates))
self.runner.start_one_dragon.assert_awaited_once_with("直播系统自动每日")
async def test_daily_commission_reader_failure_does_not_start_billed_task(self):
prepared = self._prepared_daily(requires_current_party=True)
reader = PreparedCurrentPartyRead(
request_id="daily-run",
group_name="直播系统读取当前队伍",
status_path=self.root / "status.json",
updates=(),
)
with (
patch("app.danmu_queue.prepare_daily_run", return_value=prepared),
patch("app.danmu_queue.prepare_current_party_read", return_value=reader),
patch.object(
self.handler,
"_capture_current_party_name",
new=AsyncMock(side_effect=CurrentPartyReadError("未识别到队伍名称")),
),
patch("app.danmu_queue.apply_json_updates") as apply_updates,
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="daily-run")),
):
await self.handler._cmd_daily(123, "测试用户", "委托")
apply_updates.assert_not_called()
self.queue_mgr.start_group.assert_not_called()
self.runner.start_one_dragon.assert_not_awaited()
async def test_current_party_capture_syncs_group_and_waits_for_matching_status(self):
reader_update = JsonUpdate(
self.root / "User" / "ScriptGroup" / "直播系统读取当前队伍.json",
{"projects": []},
"reader",
)
reader = PreparedCurrentPartyRead(
request_id="request-123",
group_name="直播系统读取当前队伍",
status_path=self.root / "status.json",
updates=(reader_update,),
)
with (
patch("app.danmu_queue.apply_json_updates") as apply_updates,
patch(
"app.danmu_queue.read_current_party_status",
return_value=CurrentPartyReadResult("好感队", ("好感队",)),
),
patch("app.danmu_queue.asyncio.sleep", new=AsyncMock()),
):
party_name = await self.handler._capture_current_party_name(reader, 45)
self.assertEqual(party_name, "好感队")
self.runner.sync_js_script.assert_called_once_with("LiveCurrentParty")
apply_updates.assert_called_once_with(reader.updates)
self.runner.start_groups.assert_awaited_once_with(["直播系统读取当前队伍"])
async def test_daily_requires_confirmed_login_before_preparing_files(self):
self.queue_mgr.state["login_status"] = "confirming"
with patch("app.danmu_queue.prepare_daily_run") as prepare:
await self.handler._cmd_daily(123, "测试用户", "")
prepare.assert_not_called()
self.runner.kill_bgi.assert_not_awaited()
async def test_daily_start_failure_clears_monitor_and_enters_reset_flow(self):
prepared = self._prepared_daily()
self.runner.start_one_dragon.return_value = False
reset = AsyncMock()
self.handler.system = SimpleNamespace(_reset_wait_and_retry_login=reset)
with (
patch("app.danmu_queue.prepare_daily_run", return_value=prepared),
patch("app.danmu_queue.apply_json_updates"),
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="daily-run")),
):
await self.handler._cmd_daily(123, "测试用户", "")
self.log_monitor.set_current_one_dragon.assert_has_calls(
[call("自动每日", "daily-run"), call(None)]
)
reset.assert_awaited_once_with(
123,
"测试用户",
"自动每日启动失败,正在关闭原神并启动扫码上号",
)
async def test_switch_party_updates_group_then_reuses_group_execution(self):
update = JsonUpdate(
self.root / "User" / "ScriptGroup" / "切换队伍.json",
{"projects": []},
"test",
)
with (
patch("app.danmu_queue.prepare_switch_party_update", return_value=update),
patch("app.danmu_queue.apply_json_updates") as apply_updates,
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="party-run")),
patch.object(
self.handler,
"_resolve_group_name",
return_value={
"success": True,
"name": "切换队伍",
"matched": False,
"suggestions": [],
},
),
patch.object(self.handler, "_group_uses_nahida_collect", return_value=False),
):
await self.handler._cmd_switch_party(123, "测试用户", "永冻队")
apply_updates.assert_called_once_with((update,))
self.queue_mgr.start_group.assert_called_once_with(
123,
"切换队伍",
run_id="party-run",
)
self.runner.start_groups.assert_awaited_once_with(["切换队伍"])
def test_execution_command_aliases_support_no_space_arguments(self):
self.assertEqual(self.handler._split_command_text("执行薄荷"), ("执行", "薄荷"))
self.assertEqual(
self.handler._split_command_text("自动每日秘境 风本"),
("自动每日", "秘境 风本"),
)
self.assertEqual(
self.handler._split_command_text("更换队伍永冻队"),
("更换队伍", "永冻队"),
)
self.assertEqual(
self.handler._split_command_text("修改队员芙宁娜纳西妲钟离雷电将军"),
("修改队员", "芙宁娜纳西妲钟离雷电将军"),
)
self.assertEqual(
self.handler._split_command_text("日常秘境 风本"),
("日常", "秘境 风本"),
)
self.assertEqual(
self.handler._split_command_text("换队永冻队"),
("换队", "永冻队"),
)
self.assertEqual(
self.handler._split_command_text("换角色芙宁娜纳西妲钟离雷电将军"),
("换角色", "芙宁娜纳西妲钟离雷电将军"),
)
def test_longest_new_alias_wins_for_contiguous_arguments(self):
self.assertEqual(
self.handler._split_command_text("换队员芙宁娜纳西妲钟离雷电将军"),
("换队员", "芙宁娜纳西妲钟离雷电将军"),
)
async def test_confirmed_login_announces_daily_and_party_usage(self):
self.queue_mgr.state["login_status"] = "confirming"
await self.handler._cmd_confirm_login(123, "测试用户", "是")
self.assertEqual(self.broadcaster.broadcast.await_count, 2)
first, second = self.broadcaster.broadcast.await_args_list
self.assertIn("发送“每日”完成基础每日", first.args[0])
self.assertTrue(first.kwargs["tts"])
self.assertIn("每日 秘境 风本", second.args[0])
self.assertIn("换队 永冻队", second.args[0])
self.assertIn("换角色 四名角色", second.args[0])
self.assertIn("执行 组名", second.args[0])
self.assertFalse(second.kwargs["tts"])
self.assertTrue(second.kwargs["danmu"])
def test_public_command_hints_are_sanitized_and_use_shortest_alias(self):
config_data = {
"commands": self.config.data["commands"],
"bilibili": {"cookie": "secret"},
}
hints = build_public_command_hints(config_data)
self.assertEqual(set(hints), {"daily", "switch_party", "edit_party"})
self.assertEqual(hints["daily"]["preferred_alias"], "每日")
self.assertEqual(hints["switch_party"]["preferred_alias"], "换队")
self.assertEqual(hints["edit_party"]["preferred_alias"], "换角色")
self.assertNotIn("bilibili", hints)
def test_new_command_alias_defaults_are_complete(self):
config_path = self.root / "config.json"
config_path.write_text(json.dumps({}), encoding="utf-8")
config = Config(str(config_path))
self.assertEqual(
config.data["commands"]["daily"]["aliases"],
["自动每日", "每日", "日常", "做每日", "做日常"],
)
self.assertEqual(
config.data["commands"]["switch_party"]["aliases"],
["切换队伍", "更换队伍", "换队伍", "换队"],
)
self.assertEqual(
config.data["commands"]["edit_party"]["aliases"],
["修改队员", "更换队员", "换队员", "换角色"],
)
def test_disabled_command_hint_has_no_public_alias(self):
config_data = {
"commands": {
"daily": {"enabled": False, "aliases": ["每日"]},
}
}
hints = build_public_command_hints(config_data)
self.assertFalse(hints["daily"]["enabled"])
self.assertEqual(hints["daily"]["aliases"], [])
self.assertEqual(hints["daily"]["preferred_alias"], "")
def test_new_command_permissions_match_run(self):
for key in ("daily", "switch_party", "edit_party"):
self.assertEqual(
COMMAND_ALLOWED_ROLE_DEFAULTS[key],
COMMAND_ALLOWED_ROLE_DEFAULTS["run"],
)
class BetterGIRunnerOneDragonTests(unittest.IsolatedAsyncioTestCase):
async def test_start_one_dragon_uses_supported_cli_shape(self):
runner = BetterGIRunner(
r"C:\BetterGI\BetterGI.exe",
r"C:\BetterGI",
logging.getLogger("test-one-dragon-runner"),
)
create_process = AsyncMock(return_value=SimpleNamespace())
with (
patch("app.danmu_queue.asyncio.create_subprocess_exec", create_process),
patch("app.danmu_queue.asyncio.sleep", new=AsyncMock()),
):
result = await runner.start_one_dragon("直播系统自动每日")
self.assertTrue(result)
create_process.assert_awaited_once_with(
r"C:\BetterGI\BetterGI.exe",
"startOneDragon",
"直播系统自动每日",
cwd=r"C:\BetterGI",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
if __name__ == "__main__":
unittest.main()
+139 -1
View File
@@ -1,7 +1,8 @@
import asyncio
import logging
import os
import unittest
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
from app.faster_qwen_worker import (
DEFAULT_CPU_AFFINITY_COUNT,
@@ -9,6 +10,7 @@ from app.faster_qwen_worker import (
MAX_NEW_TOKENS,
FasterQwenWorkerClient,
FasterQwenWorkerTimeout,
faster_qwen_worker_main,
_generation_kwargs,
_configure_worker_environment,
)
@@ -91,6 +93,14 @@ def _ready(pid):
}
def _progress(stage):
return {
"type": "progress",
"stage": stage,
"pid": 4000,
}
def _probe_worker_main(connection, _settings):
connection.send({
"type": "ready",
@@ -153,6 +163,8 @@ class FasterQwenWorkerTests(unittest.TestCase):
def test_client_returns_worker_audio(self):
connection = _FakeConnection([
_progress("model_loading"),
_progress("warmup_started"),
_ready(4100),
{
"type": "result",
@@ -181,6 +193,81 @@ class FasterQwenWorkerTests(unittest.TestCase):
self.assertEqual(len(context.processes), 1)
client.close()
def test_client_default_startup_timeout_allows_slow_model_load(self):
client = FasterQwenWorkerClient({})
self.assertEqual(client.startup_timeout_seconds, 900.0)
client.close()
def test_worker_applies_process_limits_after_model_warmup(self):
order = []
class FakeModel:
def warmup(self):
order.append("warmup")
class WorkerConnection:
def __init__(self):
self.sent = []
self.closed = False
def send(self, message):
self.sent.append(message)
def recv(self):
return {"command": "stop"}
def close(self):
self.closed = True
connection = WorkerConnection()
with (
patch("app.faster_qwen_worker._configure_worker_environment", return_value=3),
patch(
"app.faster_qwen_worker._load_runtime",
side_effect=lambda _settings, progress=None: (
progress("from_pretrained_started") if progress else None,
order.append("load"),
{"model": FakeModel()},
)[-1],
),
patch(
"app.faster_qwen_worker._apply_worker_process_limits",
side_effect=lambda _settings: order.append("limits") or {
"cpu_affinity_count": 6,
"process_priority": "below_normal",
},
),
patch(
"app.faster_qwen_worker._synthesize_wav",
side_effect=lambda _runtime, _text: order.append("voice_clone") or b"wav",
),
):
faster_qwen_worker_main(connection, {})
self.assertEqual(order, ["load", "warmup", "voice_clone", "limits"])
progress_stages = [
item.get("stage")
for item in connection.sent
if item.get("type") == "progress"
]
self.assertEqual(
progress_stages,
[
"process_started",
"environment_ready",
"model_loading",
"from_pretrained_started",
"model_loaded",
"warmup_started",
"graph_warmup_complete",
"voice_clone_warmup_started",
"voice_clone_warmup_complete",
"warmup_complete",
],
)
self.assertEqual(connection.sent[-1]["type"], "ready")
def test_timeout_terminates_worker_and_starts_a_prewarmed_replacement(self):
first_connection = _FakeConnection([_ready(4200)])
replacement_connection = _FakeConnection([_ready(4300)])
@@ -252,5 +339,56 @@ class FasterQwenWorkerTests(unittest.TestCase):
client.close()
class DegradeRebuildTests(unittest.IsolatedAsyncioTestCase):
"""合成耗时超阈值 → 后台自动重建 worker 的退化自愈逻辑。"""
def _make_engine(self):
from app.danmu_queue import FasterQwen3TTSEngine
engine = FasterQwen3TTSEngine.__new__(FasterQwen3TTSEngine)
engine.logger = logging.getLogger("test-degrade-rebuild")
engine._degrade_rebuild_threshold_ms = 25000
engine._degrade_rebuild_cooldown_sec = 1800
engine._last_degrade_rebuild_at = 0.0
engine._degrade_rebuild_task = None
engine.restart_worker = AsyncMock(return_value=True)
return engine
async def test_slow_synthesis_triggers_rebuild(self):
engine = self._make_engine()
engine._maybe_schedule_degrade_rebuild(40000)
await asyncio.sleep(0)
engine.restart_worker.assert_awaited_once_with(reason="合成退化自愈")
async def test_fast_synthesis_does_not_rebuild(self):
engine = self._make_engine()
engine._maybe_schedule_degrade_rebuild(8000)
await asyncio.sleep(0)
engine.restart_worker.assert_not_awaited()
async def test_cooldown_prevents_repeat_rebuild(self):
engine = self._make_engine()
engine._maybe_schedule_degrade_rebuild(40000)
await asyncio.sleep(0)
engine.restart_worker.reset_mock()
# 冷却期内的再次超阈值不重复重建
engine._maybe_schedule_degrade_rebuild(50000)
await asyncio.sleep(0)
engine.restart_worker.assert_not_awaited()
async def test_inflight_task_not_duplicated(self):
engine = self._make_engine()
async def slow_rebuild(reason=""):
await asyncio.sleep(0.05)
engine.restart_worker = slow_rebuild
engine._maybe_schedule_degrade_rebuild(40000)
first_task = engine._degrade_rebuild_task
engine._maybe_schedule_degrade_rebuild(40000)
self.assertIs(engine._degrade_rebuild_task, first_task)
await first_task
if __name__ == "__main__":
unittest.main()
+184
View File
@@ -0,0 +1,184 @@
"""LLM 弹幕回复单元测试:协议适配、过滤、限流、上下文、重试。"""
import asyncio
import unittest
from unittest.mock import AsyncMock, patch
from app.llm_client import (
LLMError,
LLMReplyEngine,
OpenAICompatProvider,
OllamaNativeProvider,
AnthropicProvider,
create_provider,
)
def _make_engine(**overrides) -> LLMReplyEngine:
cfg = {
"enabled": True,
"provider": "openai",
"base_url": "https://api.example.com/v1",
"api_key": "sk-test",
"model": "test-model",
"system_prompt": "你是直播间助手",
"max_reply_chars": 60,
"replies_per_minute": 6,
"per_user_cooldown_sec": 30,
"context_turns": 4,
"min_text_len": 2,
"blacklist_uids": [666],
"ignore_prefixes": ["//"],
}
cfg.update(overrides)
return LLMReplyEngine(cfg)
class _FakeProvider:
"""可注入的假 provider:返回固定回复,可记录调用。"""
name = "fake"
model = "fake-model"
def __init__(self, reply: str = "你好呀~", delay: float = 0.0, fail_times: int = 0):
self.reply = reply
self.delay = delay
self.fail_times = fail_times
self.calls: list[list[dict]] = []
async def chat(self, messages, *, temperature=0.8, max_tokens=256):
self.calls.append(messages)
if self.delay:
await asyncio.sleep(self.delay)
if self.fail_times > 0:
self.fail_times -= 1
raise LLMError("模拟失败")
return self.reply
def _install(engine: LLMReplyEngine, provider: _FakeProvider):
engine._provider = provider
engine._cfg_fingerprint = engine._provider_fingerprint()
return provider
class TestProviders(unittest.TestCase):
def test_create_openai(self):
p = create_provider({"provider": "openai", "base_url": "https://x/v1",
"api_key": "k", "model": "m"})
self.assertIsInstance(p, OpenAICompatProvider)
self.assertEqual(p.base_url, "https://x/v1")
def test_create_ollama(self):
p = create_provider({"provider": "ollama", "base_url": "http://localhost:11434",
"model": "qwen3"})
self.assertIsInstance(p, OllamaNativeProvider)
def test_create_anthropic(self):
p = create_provider({"provider": "anthropic", "base_url": "https://x",
"api_key": "k", "model": "claude"})
self.assertIsInstance(p, AnthropicProvider)
def test_unknown_provider(self):
with self.assertRaises(LLMError):
create_provider({"provider": "nope", "base_url": "https://x"})
def test_missing_base_url(self):
with self.assertRaises(LLMError):
create_provider({"provider": "openai"})
class TestFiltering(unittest.IsolatedAsyncioTestCase):
async def test_disabled_engine(self):
engine = _make_engine(enabled=False)
self.assertIsNone(await engine.generate_reply("你好", 1, "路人"))
async def test_too_short(self):
engine = _make_engine()
self.assertIsNone(engine.should_reply("哈", 1, "路人"))
async def test_ignore_prefix(self):
engine = _make_engine()
self.assertIsNone(engine.should_reply("//悄悄话", 1, "路人"))
async def test_blacklist(self):
engine = _make_engine()
self.assertIsNone(engine.should_reply("你好呀", 666, "黑名单"))
async def test_command_like_text_passes_through(self):
"""指令拦截在 CommandHandler 层完成;引擎本身不拦截普通文本。"""
engine = _make_engine()
self.assertIsNotNone(engine.should_reply("上号", 1, "路人"))
async def test_per_user_cooldown(self):
engine = _make_engine()
p = _install(engine, _FakeProvider())
first = await engine.generate_reply("在吗", 1, "小明")
second = await engine.generate_reply("还在吗", 1, "小明")
self.assertTrue(first)
self.assertIsNone(second) # 冷却期内被过滤
self.assertEqual(len(p.calls), 1)
async def test_zero_cooldown_disabled(self):
"""per_user_cooldown_sec=0 表示禁用冷却,不应被吞成默认 30。"""
engine = _make_engine(per_user_cooldown_sec=0)
_install(engine, _FakeProvider())
self.assertTrue(await engine.generate_reply("第一条", 1, "甲"))
self.assertTrue(await engine.generate_reply("第二条", 1, "甲"))
async def test_global_rate_limit(self):
engine = _make_engine(replies_per_minute=1, per_user_cooldown_sec=0)
_install(engine, _FakeProvider())
self.assertTrue(await engine.generate_reply("第一条", 1, "甲"))
self.assertIsNone(engine.should_reply("第二条", 2, "乙"))
async def test_busy_skip(self):
"""并发保护:在途请求未完成时新请求直接放弃。"""
engine = _make_engine(per_user_cooldown_sec=0)
_install(engine, _FakeProvider(delay=0.2))
t1 = asyncio.create_task(engine.generate_reply("第一条", 1, "甲"))
await asyncio.sleep(0.05)
t2 = asyncio.create_task(engine.generate_reply("第二条", 2, "乙"))
self.assertTrue(await t1)
self.assertIsNone(await t2)
async def test_retry_then_success(self):
engine = _make_engine(retry_attempts=2, per_user_cooldown_sec=0)
_install(engine, _FakeProvider(fail_times=1))
with patch("app.llm_client.asyncio.sleep", new=AsyncMock()):
reply = await engine.generate_reply("在吗", 1, "小明")
self.assertEqual(reply, "你好呀~")
async def test_sanitize_truncates_and_strips_markdown(self):
engine = _make_engine(max_reply_chars=10)
# 去掉 ** 后共 14 字,截断到 10 字
self.assertEqual(engine._sanitize("**很长**的一段话超过十个字了哦朋友"),
"很长的一段话超过十个…")
class TestContext(unittest.IsolatedAsyncioTestCase):
async def test_context_included_and_capped(self):
engine = _make_engine(context_turns=2, per_user_cooldown_sec=0)
p = _install(engine, _FakeProvider())
for i in range(5):
await engine.generate_reply(f"消息{i}", 1, "小明")
# 最后一次调用:system(1) + 最多 2*2 条上下文 + 当前 1 条
msgs = p.calls[-1]
user_msgs = [m for m in msgs if m["role"] == "user"]
self.assertLessEqual(len(msgs), 1 + 4 + 1)
self.assertEqual(user_msgs[-1]["content"], "小明: 消息4")
async def test_system_prompt_present(self):
engine = _make_engine()
p = _install(engine, _FakeProvider())
await engine.generate_reply("在吗", 1, "小明")
first = p.calls[0][0]
self.assertEqual(first["role"], "system")
# 人设在前,情绪标签指令在后(桌宠联动需要)
self.assertIn("你是直播间助手", first["content"])
self.assertIn("情绪标签", first["content"])
self.assertIn("[开心]", first["content"])
if __name__ == "__main__":
unittest.main()
+33
View File
@@ -0,0 +1,33 @@
import json
import unittest
from pathlib import Path
class LoginScriptDomainExitTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.script_dir = Path(__file__).resolve().parents[1] / "integrations" / "扫码上号"
cls.source = (cls.script_dir / "main.js").read_text(encoding="utf-8")
cls.settings = json.loads((cls.script_dir / "settings.json").read_text(encoding="utf-8"))
cls.manifest = json.loads((cls.script_dir / "manifest.json").read_text(encoding="utf-8"))
def test_domain_exit_requires_specific_ocr_text_before_click(self):
self.assertIn('combinedText.includes("退出秘境")', self.source)
self.assertIn('result.text.includes("确认") && result.x >= 900', self.source)
def test_legacy_and_default_confirm_coordinates_are_corrected(self):
defaults = {
item.get("name"): item.get("default")
for item in self.settings
if isinstance(item, dict) and item.get("name")
}
self.assertEqual(defaults["confirmExitX"], "1164")
self.assertEqual(defaults["confirmExitY"], "757")
self.assertIn("confirmExitX === 830 && confirmExitY === 600", self.source)
def test_private_login_script_version_is_updated(self):
self.assertEqual(self.manifest["version"], "1.1")
if __name__ == "__main__":
unittest.main()
+112
View File
@@ -0,0 +1,112 @@
import asyncio
import json
import sys
import tempfile
import unittest
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "app"))
from pet_state import PetState, gift_recovery, split_emotion
from app.danmu_queue import get_room_live_status, monitor_pet_live_status
def timestamp(iso: str) -> float:
return datetime.fromisoformat(iso).timestamp()
class PetStateTests(unittest.TestCase):
def test_only_active_sessions_count_and_partial_hours_carry_over(self):
with tempfile.TemporaryDirectory() as directory:
pet = PetState(Path(directory) / "pet.json")
start = timestamp("2026-09-25T22:00:00")
pet.updated_at = start
self.assertEqual(pet.sync(start + 3600)["stamina"], 100)
pet.set_live_active(True, start + 3600)
pet.set_live_active(False, start + 5400)
self.assertEqual(pet.sync(start + 9000)["stamina"], 100)
pet.set_live_active(True, start + 9000)
self.assertEqual(pet.sync(start + 10800)["stamina"], 90)
pet.set_live_active(False, start + 10800)
self.assertEqual(pet.sync(start + 86400)["stamina"], 90)
def test_hourly_decay_mood_and_persistence(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "pet.json"
pet = PetState(path)
pet.updated_at = timestamp("2026-09-25T09:00:00")
pet.set_live_active(True, timestamp("2026-09-25T09:00:00"))
state = pet.sync(timestamp("2026-09-25T16:00:00"))
self.assertEqual(state, {"stamina": 30, "cleanliness": 30, "mood": 95})
self.assertEqual(json.loads(path.read_text(encoding="utf-8"))["stamina"], 30)
loaded = PetState(path)
self.assertEqual(loaded.values, state)
self.assertFalse(loaded.live_active)
self.assertEqual(loaded.sync(timestamp("2026-09-25T17:00:00")), state)
def test_emotion_and_gift_clamp(self):
with tempfile.TemporaryDirectory() as directory:
pet = PetState(Path(directory) / "pet.json")
pet.values = {"stamina": 95, "cleanliness": 99, "mood": 1}
self.assertEqual(pet.apply_emotion("很生气")["mood"], 0)
self.assertEqual(pet.restore_gift(20, 20),
{"stamina": 100, "cleanliness": 100, "mood": 0})
self.assertEqual(split_emotion("[很开心] 今天真好"), ("今天真好", "很开心"))
self.assertEqual(split_emotion("[未知] 今天真好"), ("[未知] 今天真好", None))
def test_mood_reaction_events_cover_four_deltas_and_hourly_gain(self):
with tempfile.TemporaryDirectory() as directory:
pet = PetState(Path(directory) / "pet.json")
pet.values["mood"] = 50
for emotion in ("很开心", "开心", "生气", "很生气", "平静"):
pet.apply_emotion(emotion)
self.assertEqual([event["delta"] for event in pet.recent_emotions],
[3, 1, -1, -3])
self.assertEqual(len({event["id"] for event in pet.recent_emotions}), 4)
start = timestamp("2026-09-25T09:00:00")
pet.values.update(stamina=100, cleanliness=100)
pet.updated_at = start
pet.live_active = True
pet.sync(start + 3600)
self.assertEqual(pet.recent_emotions[-1]["delta"], 3)
def test_named_gifts_and_full_stat_keep_nominal_animation_points(self):
self.assertEqual(gift_recovery("饭团"), (5, 0))
self.assertEqual(gift_recovery("薯条", 2), (10, 0))
self.assertEqual(gift_recovery("人气票"), (0, 5))
self.assertEqual(gift_recovery("粉丝团灯牌", 2), (0, 10))
self.assertEqual(gift_recovery("灯牌"), (0, 5))
self.assertEqual(gift_recovery("其他礼物"), (0, 0))
with tempfile.TemporaryDirectory() as directory:
pet = PetState(Path(directory) / "pet.json")
nominal_stamina, nominal_cleanliness = gift_recovery("饭团")
self.assertEqual((nominal_stamina, nominal_cleanliness), (5, 0))
self.assertEqual(pet.restore_gift(nominal_stamina, nominal_cleanliness)["stamina"], 100)
def test_room_state_only_counts_confirmed_live(self):
with patch("app.danmu_queue._request_bilibili_json") as request:
for status, expected in ((0, False), (1, True), (2, False)):
request.return_value = {"code": 0, "data": {"live_status": status}}
self.assertIs(get_room_live_status(76), expected)
request.return_value = {"code": -1, "data": {"live_status": 1}}
with self.assertRaises(RuntimeError):
get_room_live_status(76)
def test_room_monitor_switches_pet_session_on_confirmed_state(self):
pet = Mock()
system = SimpleNamespace(pet_state=pet)
config = SimpleNamespace(room_id=76, bilibili_cookie="")
with patch("app.danmu_queue.asyncio.to_thread", new=AsyncMock(side_effect=[True, False])), \
patch("app.danmu_queue.asyncio.sleep", new=AsyncMock(
side_effect=[None, asyncio.CancelledError()])):
with self.assertRaises(asyncio.CancelledError):
asyncio.run(monitor_pet_live_status(system, config, Mock()))
self.assertEqual([call.args[0] for call in pet.set_live_active.call_args_list],
[True, False, False])
if __name__ == "__main__":
unittest.main()
+267 -1
View File
@@ -1,10 +1,14 @@
import asyncio
import ctypes
import logging
import tempfile
import unittest
from datetime import datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from app.danmu_queue import SystemScheduler
from app.danmu_queue import CommandHandler, QueueSystem, SystemScheduler
class _FakeConfig:
@@ -155,5 +159,267 @@ class StartupLiveCompensationTests(unittest.IsolatedAsyncioTestCase):
)
class LivehimePushVerificationTests(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.logger = logging.getLogger("test-livehime-push-verification")
self.scheduler = SystemScheduler(
_FakeConfig({
"bilibili_push_window_keyword": "直播姬",
"bilibili_push_click_x_ratio": 0.787,
"bilibili_push_click_y_ratio": 0.927,
"bilibili_stop_push_click_x_ratio": 0.787,
"bilibili_stop_push_click_y_ratio": 0.927,
}),
self.logger,
)
async def test_push_skips_click_when_bilibili_is_already_live(self):
self.scheduler._get_bilibili_live_status = AsyncMock(return_value=1)
self.scheduler._click_bilibili_live = AsyncMock()
self.assertTrue(await self.scheduler.push_bilibili_live())
self.scheduler._click_bilibili_live.assert_not_awaited()
async def test_push_clicks_then_waits_for_confirmed_live_status(self):
self.scheduler._get_bilibili_live_status = AsyncMock(side_effect=[0, 0, 1])
self.scheduler._click_bilibili_live = AsyncMock(return_value=True)
with patch("app.danmu_queue.asyncio.sleep", new_callable=AsyncMock):
result = await self.scheduler.push_bilibili_live()
self.assertTrue(result)
self.scheduler._click_bilibili_live.assert_awaited_once()
async def test_stop_skips_click_when_bilibili_is_already_offline(self):
self.scheduler._get_bilibili_live_status = AsyncMock(return_value=0)
self.scheduler._click_bilibili_live = AsyncMock()
self.assertTrue(await self.scheduler.stop_bilibili_live())
self.scheduler._click_bilibili_live.assert_not_awaited()
async def test_live_status_timeout_returns_specific_error(self):
self.scheduler._get_bilibili_live_status = AsyncMock(return_value=0)
with patch("app.danmu_queue.asyncio.sleep", new_callable=AsyncMock):
result = await self.scheduler._wait_for_bilibili_live_status(
1,
timeout_seconds=0.01,
)
self.assertFalse(result)
self.assertIn("未确认开播", self.scheduler.last_live_action_error)
@patch("app.danmu_queue.os.name", "nt")
async def test_click_restores_and_focuses_before_reading_physical_window_rect(self):
hwnd = 12345
user32 = MagicMock()
call_order = []
user32.IsIconic.return_value = True
user32.GetForegroundWindow.return_value = hwnd
user32.GetSystemMetrics.side_effect = lambda index: {
76: 0,
77: 0,
78: 2560,
79: 1440,
}[index]
user32.SetCursorPos.return_value = True
user32.GetCursorPos.return_value = False
user32.SetThreadDpiAwarenessContext.return_value = None
def show_window(_hwnd, _command):
call_order.append("restore")
return True
def get_window_rect(_hwnd, pointer):
call_order.append("rect")
rect = ctypes.cast(pointer, ctypes.POINTER(ctypes.wintypes.RECT)).contents
rect.left = -11
rect.top = -11
rect.right = 2571
rect.bottom = 1379
return True
user32.ShowWindow.side_effect = show_window
user32.GetWindowRect.side_effect = get_window_rect
self.scheduler._find_window_by_keyword = MagicMock(return_value=hwnd)
self.scheduler._force_foreground_window = MagicMock(return_value=True)
with (
patch("app.danmu_queue.ctypes.windll.user32", user32),
patch("app.danmu_queue.asyncio.sleep", new_callable=AsyncMock),
):
result = await self.scheduler._click_bilibili_live(
x_ratio_key="bilibili_push_click_x_ratio",
y_ratio_key="bilibili_push_click_y_ratio",
action_name="开启推流按钮",
)
self.assertTrue(result)
self.assertLess(call_order.index("restore"), call_order.index("rect"))
user32.SetCursorPos.assert_called_once_with(2021, 1277)
self.assertEqual(user32.mouse_event.call_count, 2)
class ManualLiveOverrideTests(unittest.TestCase):
def setUp(self):
self.system = QueueSystem.__new__(QueueSystem)
self.system.logger = logging.getLogger("test-manual-live-override")
self.system._manual_live_override_until_monotonic = 0.0
self.system._manual_live_override_until_wall = 0.0
self.system._bilibili_live_status = None
self.system._bilibili_live_status_updated_monotonic = 0.0
self.system._bilibili_live_status_updated_wall = 0.0
def test_manual_live_override_allows_commands_outside_schedule(self):
handler = CommandHandler.__new__(CommandHandler)
handler.system = self.system
handler.config = SimpleNamespace(
system_cfg={"live_start_time": "08:30", "live_end_time": "23:30"}
)
with patch("app.danmu_queue.time.monotonic", return_value=100.0):
self.system.enable_manual_live_override(300)
self.assertTrue(handler._is_live_time())
snapshot = self.system.manual_live_override_snapshot()
self.assertTrue(snapshot["active"])
self.assertGreater(snapshot["until"], 0)
def test_clear_manual_live_override_restores_schedule_gate(self):
handler = CommandHandler.__new__(CommandHandler)
handler.system = self.system
handler.config = SimpleNamespace(
system_cfg={"live_start_time": "08:30", "live_end_time": "23:30"}
)
with (
patch("app.danmu_queue.time.monotonic", return_value=100.0),
patch("app.danmu_queue.datetime") as mocked_datetime,
):
mocked_datetime.now.return_value = datetime(2026, 8, 16, 3, 30)
mocked_datetime.strptime = datetime.strptime
self.system.enable_manual_live_override(300)
self.system.clear_manual_live_override()
self.assertFalse(handler._is_live_time())
def test_confirmed_bilibili_live_status_allows_commands_outside_schedule(self):
handler = CommandHandler.__new__(CommandHandler)
handler.system = self.system
handler.config = SimpleNamespace(
system_cfg={"live_start_time": "08:30", "live_end_time": "23:30"}
)
with (
patch("app.danmu_queue.time.monotonic", return_value=100.0),
patch("app.danmu_queue.datetime") as mocked_datetime,
):
mocked_datetime.now.return_value = datetime(2026, 8, 16, 3, 30)
mocked_datetime.strptime = datetime.strptime
self.system.set_bilibili_live_status(1)
self.assertTrue(handler._is_live_time())
class StartupTaskSelfHealTests(unittest.TestCase):
def setUp(self):
self.logger = logging.getLogger("test-startup-self-heal")
self.scheduler = SystemScheduler(
_FakeConfig({
"enable_startup_shortcut": True,
"startup_bat": "run.bat",
}),
self.logger,
)
@patch("app.danmu_queue.os.name", "nt")
@patch("app.danmu_queue.project_path")
def test_rebuilds_when_task_missing(self, project_path_mock):
with tempfile.TemporaryDirectory() as root:
root_path = Path(root)
(root_path / "run.bat").touch()
(root_path / ".venv-tts" / "Scripts").mkdir(parents=True)
(root_path / ".venv-tts" / "Scripts" / "pythonw.exe").touch()
(root_path / "app").mkdir()
(root_path / "app" / "main.py").touch()
project_path_mock.side_effect = lambda relative: root_path / relative
self.scheduler._startup_task_exists = MagicMock(return_value=False)
self.scheduler._create_startup_task = MagicMock(return_value=True)
self.scheduler.ensure_startup_task_self_heal()
self.scheduler._create_startup_task.assert_called_once()
args = self.scheduler._create_startup_task.call_args.args
self.assertEqual(args[0], "BetterGI弹幕排队系统")
# 无窗口模式:pythonw 直接运行 main.py,不再弹 cmd 窗口
self.assertIn(str(root_path / "app" / "main.py"), args[1])
self.assertIn("--role all", args[1])
self.assertIn(f"--port {self.scheduler.port}", args[1])
@patch("app.danmu_queue.os.name", "nt")
@patch("app.danmu_queue.project_path")
def test_skips_when_task_present(self, project_path_mock):
project_path_mock.return_value = MagicMock(
exists=MagicMock(return_value=True),
__str__=lambda _s: r"C:\proj\run.bat",
)
self.scheduler._startup_task_exists = MagicMock(return_value=True)
self.scheduler._create_startup_task = MagicMock()
self.scheduler.ensure_startup_task_self_heal()
self.scheduler._create_startup_task.assert_not_called()
@patch("app.danmu_queue.os.name", "nt")
def test_skips_when_disabled(self):
self.scheduler.config.system_cfg["enable_startup_shortcut"] = False
self.scheduler._startup_task_exists = MagicMock()
self.scheduler._create_startup_task = MagicMock()
self.scheduler.ensure_startup_task_self_heal()
self.scheduler._startup_task_exists.assert_not_called()
self.scheduler._create_startup_task.assert_not_called()
class ScriptGroupSyncTests(unittest.TestCase):
def setUp(self):
self.logger = logging.getLogger("test-scriptgroup-sync")
self.scheduler = SystemScheduler(
_FakeConfig({}),
self.logger,
)
def test_copies_and_overwrites_files(self):
with tempfile.TemporaryDirectory() as src_dir, tempfile.TemporaryDirectory() as bgi_dir:
src = Path(src_dir)
(src / "薄荷.json").write_text('{"v": 2}', encoding="utf-8")
(src / "晶蝶.json").write_text("{}", encoding="utf-8")
dst = Path(bgi_dir) / "User" / "ScriptGroup"
dst.mkdir(parents=True)
(dst / "薄荷.json").write_text('{"v": 1, "corrupt": true}', encoding="utf-8")
(dst / "本地多余.json").write_text("{}", encoding="utf-8")
self.scheduler.config.bettergi_work_dir = bgi_dir
self.scheduler.config.system_cfg["scriptgroup_backup_dir"] = src_dir
self.scheduler._sync_script_groups()
self.assertEqual(
(dst / "薄荷.json").read_text(encoding="utf-8"), '{"v": 2}'
)
self.assertTrue((dst / "晶蝶.json").exists())
# 目标目录里多出的文件不删除
self.assertTrue((dst / "本地多余.json").exists())
def test_missing_source_dir_is_skipped(self):
with tempfile.TemporaryDirectory() as bgi_dir:
self.scheduler.config.bettergi_work_dir = bgi_dir
self.scheduler.config.system_cfg["scriptgroup_backup_dir"] = (
str(Path(bgi_dir) / "不存在")
)
# 不抛异常即通过
self.scheduler._sync_script_groups()
if __name__ == "__main__":
unittest.main()
+44
View File
@@ -2,6 +2,7 @@ import asyncio
import logging
import time
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
@@ -144,6 +145,49 @@ class PipelineOverlapTests(unittest.IsolatedAsyncioTestCase):
await asyncio.gather(synth_worker, play_worker, return_exceptions=True)
class BackgroundWarmupTests(unittest.IsolatedAsyncioTestCase):
async def test_start_returns_before_tts_warmup_finishes(self):
warmup_started = asyncio.Event()
release_warmup = asyncio.Event()
class FakeTTS:
enabled = True
async def warmup(self):
warmup_started.set()
await release_warmup.wait()
async def idle_worker():
await asyncio.Event().wait()
broadcaster = Broadcaster.__new__(Broadcaster)
broadcaster._tts_started = False
broadcaster._tts_warmup_done = asyncio.Event()
broadcaster._tts_warmup_task = None
broadcaster._tts_worker_tasks = set()
broadcaster._tts_queue_cfg = {"warmup_on_start": True}
broadcaster._tts_pending = SimpleNamespace(maxsize=8)
broadcaster._tts_playback_queue = SimpleNamespace(maxsize=2)
broadcaster._tts_synthesis_loop = idle_worker
broadcaster._tts_playback_loop = idle_worker
broadcaster.tts = FakeTTS()
broadcaster.logger = logging.getLogger("tts-background-warmup-test")
await asyncio.wait_for(broadcaster.start(), timeout=0.2)
await asyncio.wait_for(warmup_started.wait(), timeout=0.2)
self.assertFalse(broadcaster._tts_warmup_done.is_set())
self.assertIsNotNone(broadcaster._tts_warmup_task)
release_warmup.set()
await asyncio.wait_for(broadcaster._tts_warmup_done.wait(), timeout=0.2)
tasks = list(broadcaster._tts_worker_tasks)
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
class TTSExpiryPolicyTests(unittest.TestCase):
def test_default_expiry_windows_match_configured_policy(self):
broadcaster = Broadcaster.__new__(Broadcaster)
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -4,10 +4,11 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>直播联动后台</title>
<script type="module" crossorigin src="/admin/assets/index-CIcFYlv1.js"></script>
<script type="module" crossorigin src="/admin/assets/index-DzEqvM4m.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BB8VhdaG.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+838 -28
View File
@@ -46,10 +46,11 @@
#viewport {
width: 100vw;
height: 100vh;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
align-items: flex-start;
justify-content: flex-start; background: transparent;
overflow: hidden;
}
body.preview #viewport {
@@ -61,9 +62,13 @@
#canvas {
width: 1920px;
height: 1080px;
position: relative;
position: absolute;
left: 50%;
top: 50%;
margin-left: -960px;
margin-top: -540px;
background: transparent;
transform-origin: top left;
transform-origin: center center;
}
#frame {
@@ -1315,6 +1320,34 @@
border: 1px solid rgba(255, 242, 201, 0.13);
}
.command-quick-reference {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px 12px;
margin: 0 12px 2px;
padding: 3px 8px;
border-top: 1px solid rgba(255, 242, 201, 0.15);
border-bottom: 1px solid rgba(255, 242, 201, 0.15);
color: rgba(244, 255, 247, 0.86);
font-size: 12px;
font-weight: 720;
line-height: 1.25;
}
.command-quick-reference span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-quick-reference strong {
color: #fff0b5;
font-weight: 900;
}
.tutorial-stage {
position: relative;
z-index: 1;
@@ -1322,19 +1355,20 @@
display: flex;
align-items: center;
justify-content: center;
padding: 0 14px 3px;
min-height: 32px;
padding: 0 10px 1px;
overflow: hidden;
}
.tutorial-text {
width: 100%;
color: var(--text);
font-size: 26px;
font-size: 20px;
font-weight: 900;
line-height: 1.35;
line-height: 1.25;
text-align: center;
white-space: nowrap;
letter-spacing: 0.7px;
letter-spacing: 0;
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.48), 0 0 14px rgba(255, 240, 181, 0.18);
opacity: 1;
transform: translateY(0);
@@ -1342,12 +1376,12 @@
}
.tutorial-text.long {
font-size: 23px;
letter-spacing: 0.35px;
font-size: 18px;
letter-spacing: 0;
}
.tutorial-text.extra-long {
font-size: 21px;
font-size: 16px;
letter-spacing: 0;
}
@@ -1698,6 +1732,85 @@
body.theme-aurora .tutorial-text {
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.5), 0 0 16px rgba(150, 180, 255, 0.22);
}
/* 桌宠与状态环作为一个整体,只在游戏镂空区外游走。 */
.kirara-wanderer {
position: absolute;
left: 0;
top: 0;
z-index: 12;
width: 172px;
height: 140px;
display: flex;
align-items: center;
gap: 6px;
pointer-events: none;
will-change: transform;
}
.kirara-sprite {
width: 126px;
height: 140px;
flex: none;
object-fit: contain;
image-rendering: pixelated;
filter: drop-shadow(0 5px 1px rgba(5, 22, 18, .8));
}
.kirara-status {
width: 40px;
height: 40px;
flex: none;
overflow: visible;
filter: drop-shadow(0 2px 1px rgba(7, 22, 21, .8));
}
.kirara-ring-track, .kirara-ring-progress { shape-rendering: crispEdges; }
.kirara-ring-track.stamina { fill: #65562e; }
.kirara-ring-track.cleanliness { fill: #28566a; }
.kirara-ring-track.mood { fill: #70413d; }
.kirara-ring-progress.stamina { fill: #f8d76c; }
.kirara-ring-progress.cleanliness { fill: #7ccdf5; }
.kirara-ring-progress.mood { fill: #f58382; }
.kirara-need-bubble {
position: absolute;
z-index: 2;
box-sizing: border-box;
padding: 8px 11px;
border: 3px solid #3d3034;
background: #fff0c9;
color: #49372f;
box-shadow: 4px 4px 0 rgba(18, 33, 29, .65), inset 0 0 0 2px #fff9e5;
font-size: 16px;
font-weight: 800;
line-height: 22px;
letter-spacing: .2px;
white-space: nowrap;
text-shadow: none;
}
.kirara-need-bubble[hidden] { display: none; }
.kirara-need-bubble::after {
content: "";
position: absolute;
width: 9px;
height: 9px;
background: #fff0c9;
border: 3px solid #3d3034;
}
.kirara-need-bubble[data-place="side"]::after {
left: -12px;
top: 17px;
border-right: 0;
border-top: 0;
}
.kirara-need-bubble[data-place="above"]::after {
left: 39px;
bottom: -12px;
border-left: 0;
border-top: 0;
}
.kirara-need-bubble[data-place="below"]::after {
left: 39px;
top: -12px;
border-right: 0;
border-bottom: 0;
}
</style>
</head>
<body>
@@ -1719,6 +1832,20 @@
<!-- game-cutout 放在 #frame 外面, 不受 clip-path 裁剪, 保证镂空区域完全透明(OBS 穿透) -->
<div id="game-cutout"></div>
<!-- 镂空区外游走的桌宠;三层状态环跟随角色,不显示文字。 -->
<div id="kirara-wanderer" class="kirara-wanderer" aria-label="绮良良桌宠状态">
<canvas id="kirara-sprite" class="kirara-sprite" width="126" height="140" role="img" aria-label="像素绮良良桌宠"></canvas>
<svg class="kirara-status" viewBox="0 0 80 80" role="img" aria-label="外圈黄色体力、中圈蓝色清洁、内圈红色心情">
<path id="kirara-stamina-track" class="kirara-ring-track stamina"/>
<path id="kirara-stamina-ring" class="kirara-ring-progress stamina"/>
<path id="kirara-cleanliness-track" class="kirara-ring-track cleanliness"/>
<path id="kirara-cleanliness-ring" class="kirara-ring-progress cleanliness"/>
<path id="kirara-mood-track" class="kirara-ring-track mood"/>
<path id="kirara-mood-ring" class="kirara-ring-progress mood"/>
</svg>
<div id="kirara-need-bubble" class="kirara-need-bubble" role="status" aria-live="polite" hidden></div>
</div>
<!-- 游戏画面左侧:可用配置组 -->
<section class="panel available-groups-panel">
<div class="panel-title">可用配置组</div>
@@ -1852,6 +1979,7 @@
<!-- 右侧中部:教学轮播 -->
<section class="panel tutorial-panel">
<div class="panel-title tutorial-title">直播间使用说明</div>
<div id="command-quick-reference" class="command-quick-reference"></div>
<div class="tutorial-stage">
<div id="tutorial-text" class="tutorial-text">发送“签到”领取积分,发送“排队”加入队伍</div>
</div>
@@ -1898,7 +2026,7 @@
return m + ":" + String(sec % 60).padStart(2, "0");
}
const tutorialSlides = [
const baseTutorialSlides = [
"发送“签到”领取积分,发送“排队”加入队伍",
"成为队首后,请在90秒内发送“上号”",
"发送“上号”后,请在4分钟内完成扫码登录",
@@ -1907,8 +2035,85 @@
"发送“积分”查询积分,发送“队列”查看排队情况",
"发送“退出”离开队伍,发送“点歌 歌名”点歌"
];
const fallbackCommandHints = {
daily: { enabled: true, aliases: ["自动每日", "每日", "日常"], preferred_alias: "每日" },
switch_party: { enabled: true, aliases: ["切换队伍", "换队"], preferred_alias: "换队" },
edit_party: { enabled: true, aliases: ["修改队员", "换角色"], preferred_alias: "换角色" }
};
let commandHints = fallbackCommandHints;
let tutorialSlides = baseTutorialSlides.slice();
let tutorialIndex = 0;
function normalizeCommandHint(value, fallback) {
const source = value && typeof value === "object" ? value : fallback;
const aliases = Array.isArray(source.aliases)
? source.aliases.map((item) => String(item || "").trim()).filter(Boolean)
: fallback.aliases.slice();
const preferred = String(source.preferred_alias || "").trim()
|| aliases.reduce((best, item) => !best || item.length < best.length ? item : best, "")
|| fallback.preferred_alias;
return {
enabled: source.enabled !== false,
aliases,
preferred_alias: preferred
};
}
function renderCommandQuickReference() {
const el = document.getElementById("command-quick-reference");
if (!el) return;
const daily = commandHints.daily;
if (!daily || !daily.enabled) {
el.hidden = true;
el.innerHTML = "";
return;
}
const cmd = esc(daily.preferred_alias || "每日");
el.hidden = false;
el.innerHTML = [
`<span><strong>${cmd}</strong>:基础流程</span>`,
`<span><strong>${cmd} 秘境</strong> &lt;名称&gt;</span>`,
`<span><strong>${cmd} 地脉</strong> &lt;类型&gt; &lt;国家&gt;</span>`,
`<span><strong>${cmd} 委托</strong>:每日委托</span>`
].join("");
}
function rebuildTutorialSlides() {
const slides = baseTutorialSlides.slice();
const daily = commandHints.daily;
const switchParty = commandHints.switch_party;
const editParty = commandHints.edit_party;
if (daily && daily.enabled) {
const cmd = daily.preferred_alias || "每日";
slides.push(`发送“${cmd}”完成邮件、树脂、尘歌壶和每日奖励`);
slides.push(`发送“${cmd} 秘境 风本”刷到树脂耗尽`);
slides.push(`发送“${cmd} 地脉 经验 蒙德”刷经验地脉`);
slides.push(`发送“${cmd} 委托”执行每日委托配置`);
}
const partyParts = [];
if (switchParty && switchParty.enabled) {
partyParts.push(`“${switchParty.preferred_alias || "换队"} 永冻队”切换队伍`);
}
if (editParty && editParty.enabled) {
partyParts.push(`“${editParty.preferred_alias || "换角色"} 四名角色”修改队员`);
}
if (partyParts.length) slides.push(`发送${partyParts.join(",发送")}`);
tutorialSlides = slides;
tutorialIndex %= tutorialSlides.length;
renderCommandQuickReference();
showTutorialSlide(tutorialIndex, false);
}
function applyCommandHints(value) {
const source = value && typeof value === "object" ? value : {};
commandHints = {
daily: normalizeCommandHint(source.daily, fallbackCommandHints.daily),
switch_party: normalizeCommandHint(source.switch_party, fallbackCommandHints.switch_party),
edit_party: normalizeCommandHint(source.edit_party, fallbackCommandHints.edit_party)
};
rebuildTutorialSlides();
}
function renderTutorialDots() {
const dots = document.getElementById("tutorial-dots");
if (!dots) return;
@@ -2012,14 +2217,603 @@
let performanceState = {cpu: null, memory: null, gpu: null, gpu_available: false};
let lastCoverKey = "";
// 在 2px 网格上逐格绘制,轮廓保持像素阶梯,进度仍可随数值变化。
const kiraraPixelRings = Object.fromEntries(
[["stamina", 32], ["cleanliness", 23], ["mood", 14]].map(([key, radius]) => {
const tiles = [];
for (let y = 4; y < 76; y += 2) {
for (let x = 4; x < 76; x += 2) {
const dx = x + 1 - 40;
const dy = y + 1 - 40;
if (Math.abs(Math.hypot(dx, dy) - radius) > 3.5) continue;
const angle = (Math.atan2(dy, dx) + Math.PI / 2 + Math.PI * 2) % (Math.PI * 2);
tiles.push({angle, path: `M${x} ${y}h2v2h-2z`});
}
}
document.getElementById("kirara-" + key + "-track")
.setAttribute("d", tiles.map(tile => tile.path).join(""));
return [key, tiles];
})
);
let kiraraSleeping = false;
let kiraraSleepStartedAt = 0;
const kiraraNeedMessages = {
stamina: ["饿饿,投喂一个饭团叭~", "饿饿,投喂一个薯条叭~"],
cleanliness: ["脏脏,投喂一个人气票叭~", "脏脏,投喂一个粉丝团灯牌叭~"]
};
const kiraraNeedValues = {stamina: 100, cleanliness: 100};
let kiraraNeedNextAt = null;
let kiraraNeedCurrent = null;
let kiraraNeedLastType = null;
function kiraraNeedDuration(value) {
if (value > 60) return 0;
if (value <= 0) return Infinity;
if (value >= 40) return (5 + (60 - value) / 4) * 1000;
if (value >= 20) return (10 + (40 - value)) * 1000;
return (30 + (20 - value) * 1.5) * 1000;
}
function updateKiraraNeedBubble(now) {
const bubble = document.getElementById("kirara-need-bubble");
const eligible = ["stamina", "cleanliness"]
.filter(type => kiraraNeedDuration(kiraraNeedValues[type]) > 0);
if (!eligible.length) {
kiraraNeedCurrent = null;
kiraraNeedNextAt = null;
bubble.hidden = true;
return;
}
if (kiraraNeedCurrent && !eligible.includes(kiraraNeedCurrent.type)) {
kiraraNeedCurrent = null;
kiraraNeedNextAt = now;
}
if (kiraraNeedNextAt === null) kiraraNeedNextAt = now;
if (now >= kiraraNeedNextAt) {
const empty = eligible.filter(type => kiraraNeedValues[type] === 0);
const choices = empty.length ? empty : eligible;
const type = choices.length === 1 ? choices[0]
: (kiraraNeedLastType && choices.includes(kiraraNeedLastType)
? choices.find(candidate => candidate !== kiraraNeedLastType)
: choices.reduce((a, b) => kiraraNeedValues[a] <= kiraraNeedValues[b] ? a : b));
const messages = kiraraNeedMessages[type];
kiraraNeedCurrent = {type, startedAt: now};
kiraraNeedLastType = type;
kiraraNeedNextAt = now + 60000;
bubble.textContent = messages[Math.floor(Math.random() * messages.length)];
}
const active = kiraraNeedCurrent &&
now - kiraraNeedCurrent.startedAt < kiraraNeedDuration(
kiraraNeedValues[kiraraNeedCurrent.type]);
bubble.hidden = !active;
if (!active) return;
if (kiraraPosition.x < 1306) {
bubble.dataset.place = "side";
bubble.style.left = "178px";
bubble.style.top = "19px";
} else {
const below = kiraraPosition.y < 85;
bubble.dataset.place = below ? "below" : "above";
bubble.style.left = `${Math.round(Math.min(kiraraPosition.x + 20, 1630) - kiraraPosition.x)}px`;
bubble.style.top = below ? "144px" : "-61px";
}
}
function renderKirara(pet) {
if (!pet) return;
for (const [key, tiles] of Object.entries(kiraraPixelRings)) {
const value = Math.max(0, Math.min(100, Number(pet[key]) || 0));
if (key in kiraraNeedValues) kiraraNeedValues[key] = value;
const ring = document.getElementById("kirara-" + key + "-ring");
ring.setAttribute("d", tiles
.filter(tile => tile.angle < value / 100 * Math.PI * 2)
.map(tile => tile.path).join(""));
}
const mood = Number(pet.mood);
if (!Number.isFinite(mood) || (mood < 50) === kiraraSleeping) return;
kiraraSleeping = mood < 50;
if (kiraraSleeping) {
kiraraSleepStartedAt = performance.now();
kiraraWalkEndsAt = 0;
kiraraWaypoints.length = 0;
kiraraIdle = null;
kiraraDirection = "front";
kiraraDrawnFrameKey = ""; // 强制立即重绘第一帧睡觉姿势
} else {
kiraraNextWalkAt = null;
kiraraNextIdleAt = null;
}
}
// Crops follow each generated sprite's transparent silhouette.
const kiraraSpriteBoxes = {
"kirara_walk_front": [[133,15,407,670], [617,15,392,665], [1126,15,503,672], [1629,15,388,664]],
"kirara_walk_back": [[68,19,420,691], [563,27,416,685], [1064,19,416,692], [1561,19,420,692]],
"kirara_walk_left": [[136,28,407,679], [543,28,537,679], [1151,28,446,679], [1673,28,448,679]],
"kirara_walk_right": [[33,136,397,691], [456,133,399,694], [894,137,419,690], [1330,137,408,690]],
"kirara_eat_rice": [[14,42,425,681], [510,42,424,681], [1005,42,425,681], [1502,42,424,681]],
"kirara_eat_dango": [[49,13,443,690], [592,13,443,690], [1135,13,443,690], [1677,13,444,690]],
"kirara_bath_sponge": [[62,21,437,677], [592,21,429,677], [1143,21,450,677], [1686,21,432,677]],
"kirara_bath_shower": [[62,24,405,676], [618,24,389,676], [1133,24,437,676], [1639,102,492,599]],
"kirara_idle_delivery": [[92,23,436,676], [580,21,460,678], [1118,23,444,676], [1647,23,433,676]],
"kirara_idle_reading": [[59,14,425,692], [601,14,428,692], [1144,44,427,662], [1687,13,424,693]],
"kirara_idle_phone": [[138,19,405,682], [543,19,484,681], [1118,20,511,682], [1629,18,405,683]],
"kirara_idle_sleep": [[72,81,471,595], [605,128,467,548], [1122,99,469,577], [1647,69,465,607]],
"kirara_idle_stretch": [[65,133,365,587], [527,133,430,587], [1022,154,450,566], [1553,134,365,586]],
"kirara_idle_groom": [[84,38,413,650], [612,38,409,650], [1153,38,421,650], [1696,38,416,650]],
"kirara_idle_butterfly": [[46,138,418,592], [537,138,430,592], [1061,123,381,607], [1515,111,424,619]],
"kirara_idle_tea": [[66,13,438,684], [609,13,437,684], [1153,13,436,684], [1695,13,436,684]],
"kirara_idle_yarn": [[108,26,419,663], [606,26,418,663], [1149,26,418,663], [1646,26,419,663]],
"kirara_idle_humming": [[76,71,420,671], [561,72,417,670], [1005,72,416,670], [1487,71,420,671]],
"kirara_rest_breathe": [[23,16,426,679], [571,15,420,680], [1104,20,433,675], [1664,16,424,679]],
"kirara_emotion_plus3_a": [[69,59,429,627], [622,33,411,594], [1139,10,426,583], [1676,71,428,615]],
"kirara_emotion_plus3_b": [[56,35,459,654], [573,35,504,654], [1135,32,458,657], [1653,35,466,654]],
"kirara_emotion_plus1_a": [[65,14,415,678], [611,14,412,678], [1133,14,431,678], [1695,14,413,678]],
"kirara_emotion_plus1_b": [[63,16,435,683], [608,37,447,664], [1136,28,430,673], [1679,16,432,683]],
"kirara_emotion_minus1_a": [[73,30,440,662], [606,30,433,662], [1153,30,415,662], [1664,30,438,662]],
"kirara_emotion_minus1_b": [[146,49,397,638], [543,47,501,640], [1151,44,408,643], [1637,49,418,638]],
"kirara_emotion_minus3_a": [[52,61,427,644], [534,71,495,634], [1081,61,474,644], [1555,67,476,638]],
"kirara_emotion_minus3_b": [[52,27,450,665], [598,23,466,669], [1139,15,472,679], [1676,29,444,663]],
};
const kiraraSpriteAsset = name => ({
src: `/uploads/${name}.png`, boxes: kiraraSpriteBoxes[name]
});
const kiraraWalkAssets = Object.fromEntries(
["front", "back", "left", "right"].map(direction =>
[direction, kiraraSpriteAsset(`kirara_walk_${direction}`)])
);
const kiraraActionAssets = {
feed: [kiraraSpriteAsset("kirara_eat_rice"), kiraraSpriteAsset("kirara_eat_dango")],
bath: [kiraraSpriteAsset("kirara_bath_sponge"), kiraraSpriteAsset("kirara_bath_shower")]
};
const kiraraIdleAssets = Object.fromEntries(
["delivery", "reading", "phone", "sleep", "stretch",
"groom", "butterfly", "tea", "yarn", "humming"].map(type =>
[type, kiraraSpriteAsset(`kirara_idle_${type}`)])
);
const kiraraRestAsset = kiraraSpriteAsset("kirara_rest_breathe");
const kiraraEmotionAssets = {
"3": [kiraraSpriteAsset("kirara_emotion_plus3_a"), kiraraSpriteAsset("kirara_emotion_plus3_b")],
"1": [kiraraSpriteAsset("kirara_emotion_plus1_a"), kiraraSpriteAsset("kirara_emotion_plus1_b")],
"-1": [kiraraSpriteAsset("kirara_emotion_minus1_a"), kiraraSpriteAsset("kirara_emotion_minus1_b")],
"-3": [kiraraSpriteAsset("kirara_emotion_minus3_a"), kiraraSpriteAsset("kirara_emotion_minus3_b")],
};
let kiraraDirection = "front";
let kiraraDrawnFrameKey = "";
const kiraraActionQueue = [];
const kiraraActionVariant = {feed: 0, bath: 0};
let kiraraAction = null;
const kiraraIdleTypes = ["delivery", "reading", "phone", "stretch", "groom",
"butterfly", "tea", "yarn", "humming"];
let kiraraIdle = null;
let kiraraNextIdleAt = null;
let kiraraRestActive = false;
let kiraraRestStartedAt = 0;
const kiraraEmotionQueue = [];
const kiraraEmotionVariant = {"3": 0, "1": 0, "-1": 0, "-3": 0};
const kiraraEmotionSeen = new Set();
const kiraraEmotionSeenOrder = [];
const kiraraEmotionOpenedAtClientTs = Date.now() / 1000;
let kiraraEmotionBaseline = null;
let kiraraEmotionAction = null;
function drawKiraraSpriteFrame(asset, frame, key, action) {
if (!asset.image?.complete || !asset.image.naturalWidth || key === kiraraDrawnFrameKey) return;
const box = asset.boxes[frame];
if (!box) return;
// 源矩形越界夹紧:部分浏览器对越界 drawImage 会整帧不绘制,
// 配合 clearRect 会表现为"桌宠消失"。
let [sx, sy, sw, sh] = box;
const maxX = asset.image.naturalWidth, maxY = asset.image.naturalHeight;
if (sx < 0) { sw += sx; sx = 0; }
if (sy < 0) { sh += sy; sy = 0; }
if (sx + sw > maxX) sw = maxX - sx;
if (sy + sh > maxY) sh = maxY - sy;
if (sw <= 0 || sh <= 0) return;
try {
const canvas = document.getElementById("kirara-sprite");
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, 126, 140);
ctx.imageSmoothingEnabled = false;
const scale = Math.min(
130 / Math.max(...asset.boxes.map(box => box[3])),
120 / Math.max(...asset.boxes.map(box => box[2]))
);
const width = sw * scale;
const height = sh * scale;
ctx.drawImage(asset.image, sx, sy, sw, sh,
(126 - width) / 2, 136 - height, width, height);
canvas.dataset.action = action;
canvas.dataset.direction = action ? "front" : kiraraDirection;
canvas.dataset.frame = String(frame);
kiraraDrawnFrameKey = key;
} catch (e) {
console.error("kirara draw", e);
window.__kiraraError = String(e && e.stack || e);
}
}
function drawKiraraWalkFrame(direction, frame) {
drawKiraraSpriteFrame(kiraraWalkAssets[direction], frame,
"walk:" + direction + ":" + frame, "");
}
function drawKiraraActionFrame(action, frame) {
drawKiraraSpriteFrame(kiraraActionAssets[action.type][action.variant], frame,
"action:" + action.type + ":" + action.variant + ":" + frame, action.type);
}
function drawKiraraIdleFrame(idle, phase) {
drawKiraraSpriteFrame(kiraraIdleAssets[idle.type], phase,
"idle:" + idle.type + ":" + phase, idle.type);
}
function drawKiraraSleepFrame(phase) {
drawKiraraSpriteFrame(kiraraIdleAssets.sleep, phase,
"sleep:" + phase, "sleep");
}
function drawKiraraRestFrame(phase) {
drawKiraraSpriteFrame(kiraraRestAsset, phase,
"rest:" + phase, "rest");
}
function drawKiraraEmotionFrame(action, frame) {
drawKiraraSpriteFrame(kiraraEmotionAssets[String(action.delta)][action.variant], frame,
"emotion:" + action.delta + ":" + action.variant + ":" + frame,
"emotion");
}
for (const [direction, asset] of Object.entries(kiraraWalkAssets)) {
asset.image = new Image();
asset.image.onload = () => {
if (!kiraraSleeping && !kiraraIdle && !kiraraAction && !kiraraEmotionAction && kiraraDirection === direction) {
kiraraDrawnFrameKey = "";
drawKiraraWalkFrame(direction, 1);
}
};
asset.image.src = asset.src;
}
for (const [type, variants] of Object.entries(kiraraActionAssets)) {
for (const [variant, asset] of variants.entries()) {
asset.image = new Image();
asset.image.onload = () => {
if (!kiraraSleeping && kiraraAction?.type === type && kiraraAction.variant === variant) {
kiraraDrawnFrameKey = "";
drawKiraraActionFrame(kiraraAction, 0);
}
};
asset.image.src = asset.src;
}
}
for (const [type, asset] of Object.entries(kiraraIdleAssets)) {
asset.image = new Image();
asset.image.onload = () => {
if (!kiraraAction && !kiraraEmotionAction && ((type === "sleep" && kiraraSleeping) ||
(!kiraraSleeping && kiraraIdle?.type === type))) {
kiraraDrawnFrameKey = "";
if (type === "sleep") drawKiraraSleepFrame(0);
else drawKiraraIdleFrame(kiraraIdle, 0);
}
};
asset.image.src = asset.src;
}
kiraraRestAsset.image = new Image();
kiraraRestAsset.image.onload = () => {
if (kiraraRestActive && !kiraraSleeping && !kiraraIdle &&
!kiraraAction && !kiraraEmotionAction) {
kiraraDrawnFrameKey = "";
drawKiraraRestFrame(Math.floor((performance.now() - kiraraRestStartedAt) / 900) % 4);
}
};
kiraraRestAsset.image.src = kiraraRestAsset.src;
for (const [delta, variants] of Object.entries(kiraraEmotionAssets)) {
for (const [variant, asset] of variants.entries()) {
asset.image = new Image();
asset.image.onload = () => {
if (!kiraraAction && kiraraEmotionAction?.delta === Number(delta) &&
kiraraEmotionAction.variant === variant) {
kiraraDrawnFrameKey = "";
drawKiraraEmotionFrame(kiraraEmotionAction, 0);
}
};
asset.image.src = asset.src;
}
}
// 以完整桌宠(含状态环)的外接矩形规划路径。上、右、下三条通道
// 均与游戏镂空矩形保持至少 16px 间距,转弯经过通道交叠处。
const kiraraPosition = {x: 1450, y: 590};
const kiraraWaypoints = [];
let kiraraSpeed = 25;
let kiraraNextWalkAt = null;
let kiraraWalkStartedAt = 0;
let kiraraWalkEndsAt = 0;
let kiraraLastFrame = 0;
let kiraraStepDistance = 0;
function kiraraRandom(min, max) {
return min + Math.random() * (max - min);
}
function kiraraPoint(zone) {
if (zone === "top") return {x: kiraraRandom(16, 1682), y: kiraraRandom(16, 54)};
if (zone === "bottom") return {x: kiraraRandom(16, 1682), y: kiraraRandom(866, 924)};
return {x: kiraraRandom(1306, 1682), y: kiraraRandom(16, 924)};
}
function kiraraJunction(edge) {
return {x: kiraraRandom(1306, 1682),
y: edge === "top" ? kiraraRandom(16, 54) : kiraraRandom(866, 924)};
}
function appendKiraraOrthogonalPoint(point) {
const from = kiraraWaypoints[kiraraWaypoints.length - 1] || kiraraPosition;
if (Math.abs(point.x - from.x) > 0.01 && Math.abs(point.y - from.y) > 0.01) {
kiraraWaypoints.push(Math.random() < 0.5
? {x: point.x, y: from.y}
: {x: from.x, y: point.y});
}
kiraraWaypoints.push(point);
}
function chooseKiraraRoute() {
const kiraraZone = kiraraPosition.x >= 1306 ? "right"
: kiraraPosition.y <= 54 ? "top" : "bottom";
const zones = ["top", "right", "bottom"];
const destination = zones[Math.floor(Math.random() * zones.length)];
if (destination !== kiraraZone) {
if (kiraraZone !== "right" && destination !== "right") {
appendKiraraOrthogonalPoint(kiraraJunction(kiraraZone));
appendKiraraOrthogonalPoint(kiraraJunction(destination));
} else {
appendKiraraOrthogonalPoint(kiraraJunction(
kiraraZone === "right" ? destination : kiraraZone));
}
}
appendKiraraOrthogonalPoint(kiraraPoint(destination));
}
function advanceKirara(distanceBudget) {
// 一帧只走一条水平或垂直路段,转角处不出现斜向位移。
for (let i = 0; distanceBudget > 0.01 && i < 64; i++) {
if (kiraraWaypoints.length === 0) chooseKiraraRoute();
const next = kiraraWaypoints[0];
const dx = next.x - kiraraPosition.x;
const dy = next.y - kiraraPosition.y;
const distance = Math.abs(dx) + Math.abs(dy);
if (distance <= 0.01) {
kiraraWaypoints.shift();
continue;
}
kiraraDirection = Math.abs(dx) > 0.01
? (dx > 0 ? "right" : "left")
: (dy > 0 ? "front" : "back");
if (distance <= distanceBudget) {
kiraraPosition.x = next.x;
kiraraPosition.y = next.y;
kiraraStepDistance += distance;
kiraraWaypoints.shift();
} else {
if (Math.abs(dx) > 0.01) kiraraPosition.x += Math.sign(dx) * distanceBudget;
else kiraraPosition.y += Math.sign(dy) * distanceBudget;
kiraraStepDistance += distanceBudget;
}
break;
}
}
function queueKiraraGiftAnimations(gift) {
// 动画按礼物名义恢复量计算;数值满 100 时照样播放。
for (const [type, field] of [["feed", "pet_animation_stamina"], ["bath", "pet_animation_cleanliness"]]) {
const raw = Number(gift[field]);
const points = Number.isFinite(raw) ? Math.max(0, Math.floor(raw)) : 0;
if (points > 0) kiraraActionQueue.push({type, remaining: points});
}
}
function syncKiraraEmotionEvents(events, serverTs) {
if (!Array.isArray(events)) return;
if (kiraraEmotionBaseline === null) {
const serverNow = Number(serverTs);
const clientNow = Date.now() / 1000;
kiraraEmotionBaseline = Number.isFinite(serverNow) && serverNow > 0
? serverNow - (clientNow - kiraraEmotionOpenedAtClientTs)
: kiraraEmotionOpenedAtClientTs;
}
for (const event of events) {
const id = String(event.id || "");
const delta = Number(event.delta);
if (!id || !kiraraEmotionAssets[String(delta)] ||
Number(event.ts) <= kiraraEmotionBaseline || kiraraEmotionSeen.has(id)) continue;
kiraraEmotionSeen.add(id);
kiraraEmotionSeenOrder.push(id);
if (kiraraEmotionSeenOrder.length > 128) {
kiraraEmotionSeen.delete(kiraraEmotionSeenOrder.shift());
}
kiraraEmotionQueue.push({delta});
}
}
function updateKiraraEmotionAction(now) {
if (!kiraraEmotionAction && kiraraEmotionQueue.length) {
const job = kiraraEmotionQueue.shift();
kiraraWalkEndsAt = 0;
kiraraWaypoints.length = 0;
kiraraIdle = null;
kiraraDirection = "front";
kiraraEmotionAction = {
delta: job.delta,
startedAt: now,
endsAt: now + 3000
};
const key = String(kiraraEmotionAction.delta);
kiraraEmotionAction.variant = job.variant ?? kiraraEmotionVariant[key]++ % 2;
}
if (kiraraEmotionAction && now >= kiraraEmotionAction.endsAt) {
kiraraEmotionAction = null;
kiraraNextWalkAt = now + kiraraRandom(60000, 90000);
kiraraNextIdleAt = now + kiraraRandom(120000, 150000);
return updateKiraraEmotionAction(now);
}
return kiraraEmotionAction;
}
function nextKiraraAction(startedAt) {
const job = kiraraActionQueue[0];
if (!job) return null;
job.remaining--;
if (job.remaining === 0) kiraraActionQueue.shift();
const variant = kiraraActionVariant[job.type]++ % 2;
return {type: job.type, variant, startedAt, endsAt: startedAt + 5000};
}
function updateKiraraAction(now) {
if (!kiraraAction && kiraraActionQueue.length) {
if (kiraraEmotionAction) {
kiraraEmotionQueue.unshift({
delta: kiraraEmotionAction.delta,
variant: kiraraEmotionAction.variant
});
kiraraEmotionAction = null;
}
kiraraWalkEndsAt = 0;
kiraraWaypoints.length = 0;
kiraraIdle = null;
kiraraNextIdleAt = now + kiraraRandom(120000, 150000);
kiraraDirection = "front";
kiraraAction = nextKiraraAction(now);
}
while (kiraraAction && now >= kiraraAction.endsAt) {
if (kiraraActionQueue.length) {
kiraraAction = nextKiraraAction(kiraraAction.endsAt);
} else {
kiraraAction = null;
kiraraNextWalkAt = now + kiraraRandom(60000, 90000);
}
}
return kiraraAction;
}
function animateKirara(now) {
// 任何一帧异常都不允许杀死渲染循环(否则桌宠永久消失)。
try {
updateKiraraNeedBubble(now);
const action = updateKiraraAction(now);
if (action) {
kiraraRestActive = false;
kiraraLastFrame = now;
document.getElementById("kirara-wanderer").style.transform =
`translate3d(${Math.round(kiraraPosition.x)}px, ${Math.round(kiraraPosition.y)}px, 0)`;
const bob = -Math.abs(Math.sin((now - action.startedAt) / 500 * Math.PI)) * 2;
document.getElementById("kirara-sprite").style.transform =
`translateY(${bob.toFixed(1)}px)`;
drawKiraraActionFrame(action, Math.floor((now - action.startedAt) / 260) % 4);
requestAnimationFrame(animateKirara);
return;
}
const emotionAction = updateKiraraEmotionAction(now);
if (emotionAction) {
kiraraRestActive = false;
kiraraLastFrame = now;
document.getElementById("kirara-wanderer").style.transform =
`translate3d(${Math.round(kiraraPosition.x)}px, ${Math.round(kiraraPosition.y)}px, 0)`;
document.getElementById("kirara-sprite").style.transform = "none";
drawKiraraEmotionFrame(emotionAction,
Math.min(3, Math.floor((now - emotionAction.startedAt) / 650)));
requestAnimationFrame(animateKirara);
return;
}
if (kiraraSleeping) {
kiraraRestActive = false;
kiraraLastFrame = now;
document.getElementById("kirara-wanderer").style.transform =
`translate3d(${Math.round(kiraraPosition.x)}px, ${Math.round(kiraraPosition.y)}px, 0)`;
document.getElementById("kirara-sprite").style.transform = "none";
drawKiraraSleepFrame(Math.floor((now - kiraraSleepStartedAt) / 650) % 4);
requestAnimationFrame(animateKirara);
return;
}
if (kiraraNextIdleAt === null) kiraraNextIdleAt = now + kiraraRandom(120000, 150000);
if (kiraraIdle && now >= kiraraIdle.endsAt) {
kiraraIdle = null;
kiraraDirection = "front";
}
if (!kiraraIdle && now >= kiraraNextIdleAt) {
kiraraIdle = {
type: kiraraIdleTypes[Math.floor(Math.random() * kiraraIdleTypes.length)],
startedAt: now,
endsAt: now + kiraraRandom(15000, 20000)
};
kiraraNextIdleAt = now + kiraraRandom(120000, 150000);
kiraraWalkEndsAt = 0;
kiraraWaypoints.length = 0;
kiraraDirection = "front";
}
if (kiraraIdle) {
kiraraRestActive = false;
kiraraLastFrame = now;
document.getElementById("kirara-wanderer").style.transform =
`translate3d(${Math.round(kiraraPosition.x)}px, ${Math.round(kiraraPosition.y)}px, 0)`;
document.getElementById("kirara-sprite").style.transform = "none";
drawKiraraIdleFrame(kiraraIdle,
Math.floor((now - kiraraIdle.startedAt) / 360) % 4);
requestAnimationFrame(animateKirara);
return;
}
if (kiraraNextWalkAt === null) kiraraNextWalkAt = now + kiraraRandom(60000, 90000);
if (now >= kiraraNextWalkAt) {
kiraraWalkStartedAt = now;
kiraraWalkEndsAt = now + kiraraRandom(10000, 15000);
kiraraNextWalkAt = now + kiraraRandom(60000, 90000);
kiraraSpeed = kiraraRandom(21, 35); // 原先 42–70 px/s 的一半
}
if (kiraraWalkEndsAt) {
const activeUntil = Math.min(now, kiraraWalkEndsAt);
const elapsed = Math.max(0, activeUntil - Math.max(kiraraLastFrame, kiraraWalkStartedAt)) / 1000;
advanceKirara(kiraraSpeed * elapsed);
}
const moving = now < kiraraWalkEndsAt;
if (!moving && kiraraWalkEndsAt) {
kiraraWalkEndsAt = 0;
kiraraWaypoints.length = 0;
}
if (!moving) kiraraDirection = "front";
if (moving) kiraraRestActive = false;
else if (!kiraraRestActive) {
kiraraRestActive = true;
kiraraRestStartedAt = now;
}
kiraraLastFrame = now;
document.getElementById("kirara-wanderer").style.transform =
`translate3d(${Math.round(kiraraPosition.x)}px, ${Math.round(kiraraPosition.y)}px, 0)`;
const bob = moving ? -Math.abs(Math.sin(kiraraStepDistance / 10 * Math.PI)) * 3 : 0;
document.getElementById("kirara-sprite").style.transform =
`translateY(${bob.toFixed(1)}px)`;
if (moving) {
drawKiraraWalkFrame(kiraraDirection, Math.floor(kiraraStepDistance / 5) % 4);
} else if (kiraraRestAsset.image.complete && kiraraRestAsset.image.naturalWidth) {
drawKiraraRestFrame(Math.floor((now - kiraraRestStartedAt) / 900) % 4);
} else {
drawKiraraWalkFrame("front", 1);
}
} catch (e) {
console.error("kirara anim", e);
window.__kiraraError = String(e && e.stack || e);
}
requestAnimationFrame(animateKirara);
}
async function fetchState() {
try {
const r = await fetch("/api/state");
const d = await r.json();
renderTask(d.current_group, d.group_start_time, d.current_admin, d.users, d.default_running, d.bgi_running, d.current_group_uses_nahida_collect);
renderQueue(d.queue, d.users, d.current_admin);
initGiftFxBaseline(d.recent_gifts);
initGiftFxBaseline(d.server_ts);
giftFx.sync(d.recent_gifts);
renderKirara(d.pet);
syncKiraraEmotionEvents(d.pet_emotion_events, d.server_ts);
} catch (e) {
console.error("state", e);
}
@@ -2344,8 +3138,10 @@
themeClasses.forEach((c) => document.body.classList.remove(c));
const theme = cfg.theme || "classic";
if (theme && theme !== "classic") document.body.classList.add("theme-" + theme);
applyCommandHints(cfg.command_hints);
} catch (e) {
console.error("frontend-config", e);
applyCommandHints(fallbackCommandHints);
}
}
@@ -2406,17 +3202,16 @@
const noteEl = document.getElementById("music-note");
const listEl = document.getElementById("playlist");
const monitor = musicState.monitor || {};
const updatedAtMs = Number(monitor.updated_at || 0) * 1000;
const monitorStale = !updatedAtMs || Date.now() - updatedAtMs > 10000;
const monitorOffline = monitor.online === false || monitorStale;
// 以 mpv 播放器真实状态为准(已移除 SMTC 系统媒体监听):
// idle 或没有曲目时为“未播放”,不再显示“音乐源离线 / SMTC”。
const isIdle = !!musicState.idle || !cur.title || cur.title === "暂无歌曲";
if (monitorOffline) {
titleEl.textContent = "音乐源离线";
artistEl.textContent = "等待网易云 SMTC 状态";
if (isIdle) {
titleEl.textContent = "未播放";
artistEl.textContent = "等待点歌或背景歌单";
coverEl.innerHTML = "♪";
lastCoverKey = "";
noteEl.textContent = "监听未运行或状态已过期";
noteEl.textContent = "点歌或开启背景歌单后将自动播放";
} else if (!cur.title && !cur.artist) {
titleEl.textContent = "未配置";
artistEl.textContent = "等待数据源";
@@ -2468,6 +3263,8 @@
particles: [],
rafId: null,
lastTs: 0,
baselineInitialized: false,
openedAtClientTs: Date.now() / 1000,
bannerTimer: null,
queue: [],
playing: false,
@@ -2633,24 +3430,37 @@
const fresh = gifts.filter(g => Number(g.ts || 0) > this.lastTs);
if (fresh.length === 0) return;
this.lastTs = Math.max(...gifts.map(g => Number(g.ts || 0)));
for (const g of fresh) this.enqueue(g);
for (const g of fresh) {
this.enqueue(g);
queueKiraraGiftAnimations(g);
}
},
};
// 首次加载时把已有礼物的最大 ts 设为基线,避免刷新页面重放旧礼物
function initGiftFxBaseline(gifts) {
if (giftFx.lastTs > 0 || !Array.isArray(gifts) || gifts.length === 0) return;
giftFx.lastTs = Math.max(...gifts.map(g => Number(g.ts || 0)));
// 用页面打开时刻作为一次性基线:跳过刷新前的礼物,同时保留打开后的首件礼物。
function initGiftFxBaseline(serverTs) {
if (giftFx.baselineInitialized) return;
const serverNow = Number(serverTs);
const clientNow = Date.now() / 1000;
giftFx.lastTs = Number.isFinite(serverNow) && serverNow > 0
? serverNow - (clientNow - giftFx.openedAtClientTs)
: giftFx.openedAtClientTs;
giftFx.baselineInitialized = true;
}
function scaleCanvas() {
const canvas = document.getElementById("canvas");
const scale = Math.min(window.innerWidth / 1920, window.innerHeight / 1080);
canvas.style.transform = "scale(" + scale + ")";
const offsetX = Math.max(0, (window.innerWidth - 1920 * scale) / 2);
const offsetY = Math.max(0, (window.innerHeight - 1080 * scale) / 2);
canvas.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
}
applyCommandHints(fallbackCommandHints);
startTutorialCarousel();
renderKirara({stamina: 100, cleanliness: 100, mood: 100});
requestAnimationFrame(animateKirara);
fetchFrontendConfig();
fetchState();
fetchLog();
+12
View File
@@ -386,5 +386,17 @@
{
"name": "霜仙花",
"image": "/uploads/group_霜仙花.webp"
},
{
"name": "植绒草",
"image": "/uploads/group_植绒草.webp"
},
{
"name": "金蕨",
"image": "/uploads/group_金蕨.webp"
},
{
"name": "冰醋栗",
"image": "/uploads/group_冰醋栗.webp"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.