合并外部维护版本并同步自动每日功能
This commit is contained in:
@@ -26,6 +26,9 @@ data/admin_audit.log
|
|||||||
data/admin_auth.json
|
data/admin_auth.json
|
||||||
data/statistics.sqlite3*
|
data/statistics.sqlite3*
|
||||||
data/users.json
|
data/users.json
|
||||||
|
data/kirara_pet.json
|
||||||
|
data/startup_tasks_state.json
|
||||||
|
data/ref_audio.wav
|
||||||
web/music_cover.jpg
|
web/music_cover.jpg
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
|
|||||||
@@ -4,17 +4,16 @@
|
|||||||
|
|
||||||
## 环境
|
## 环境
|
||||||
|
|
||||||
- **Python**: 项目虚拟环境 `.venv`(Python 3.11)
|
- **Python**: 项目虚拟环境 `.venv` 或 `.venv-tts`(Python 3.11+)
|
||||||
- **Node.js**: 构建前端用
|
- **Node.js**: 构建前端用
|
||||||
- **BetterGI**: `0.63.0+`,路径在 `config/config.json` 中配置
|
- **BetterGI**: 自动化脚本引擎,路径在 `config/config.json` 中配置
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 安装 Python 依赖
|
# 安装 Python 依赖
|
||||||
py -3.11 -m venv .venv
|
|
||||||
.venv\Scripts\python.exe -m pip install -r requirements.txt
|
.venv\Scripts\python.exe -m pip install -r requirements.txt
|
||||||
|
|
||||||
# 下载 TTS 模型(约 1.2GB,仅需一次)
|
# 下载 TTS 模型(约 1.2GB,仅需一次)
|
||||||
.venv\Scripts\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
|
cd frontend\admin
|
||||||
@@ -30,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` | 绑定地址 |
|
| `--host` | `0.0.0.0` | 绑定地址 |
|
||||||
| `--port` | `8086` | 端口 |
|
| `--port` | `8086` | 端口 |
|
||||||
|
|
||||||
@@ -133,10 +132,34 @@ npm run build
|
|||||||
|
|
||||||
### music_monitor — 音乐监听与点歌
|
### music_monitor — 音乐监听与点歌
|
||||||
|
|
||||||
监听 Windows 系统媒体会话(SMTC),观众可通过弹幕点歌。
|
观众可通过弹幕点歌,内置 mpv 播放器负责播放、续播和状态维护;旧版 `music_monitor.py` 仍保留用于兼容历史配置,但不再作为独立进程启动。
|
||||||
|
|
||||||
`request_player.commands`:点歌触发词,默认 `["点歌", "dg"]`。
|
`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 — 系统定时任务
|
### system — 系统定时任务
|
||||||
|
|
||||||
| 字段 | 说明 |
|
| 字段 | 说明 |
|
||||||
@@ -180,7 +203,7 @@ npm run build # 构建到 web/admin/
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 编译检查
|
# 编译检查
|
||||||
.venv\Scripts\python.exe -m py_compile app/danmu_queue.py
|
.venv-tts\Scripts\python.exe -m py_compile app/danmu_queue.py
|
||||||
```
|
```
|
||||||
|
|
||||||
核心文件:
|
核心文件:
|
||||||
@@ -190,7 +213,7 @@ npm run build # 构建到 web/admin/
|
|||||||
| `app/danmu_queue.py` | 弹幕监听、指令处理、队列管理、BGI 控制、TTS、Web API |
|
| `app/danmu_queue.py` | 弹幕监听、指令处理、队列管理、BGI 控制、TTS、Web API |
|
||||||
| `app/bettergi_daily.py` | 自动每日配置生成、秘境别名和队伍参数更新 |
|
| `app/bettergi_daily.py` | 自动每日配置生成、秘境别名和队伍参数更新 |
|
||||||
| `app/bettergi_current_party.py` | 当前队伍读取托管配置组、状态协议和防串读校验 |
|
| `app/bettergi_current_party.py` | 当前队伍读取托管配置组、状态协议和防串读校验 |
|
||||||
| `app/music_monitor.py` | SMTC 音乐监听与点歌调度 |
|
| `app/music_monitor.py` | 旧版 SMTC 音乐监听兼容模块;当前点歌由 `app/mpv_player.py` 和排队调度负责 |
|
||||||
| `app/core/runtime_paths.py` | 运行时路径解析 |
|
| `app/core/runtime_paths.py` | 运行时路径解析 |
|
||||||
|
|
||||||
## 打包
|
## 打包
|
||||||
|
|||||||
+792
-126
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gc
|
||||||
import io
|
import io
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import os
|
import os
|
||||||
@@ -18,6 +19,8 @@ DEFAULT_STARTUP_TIMEOUT_SECONDS = 900.0
|
|||||||
# 启动失败后再次拉起 worker 的最小间隔:避免"启动超时→立即重启→再超时"的死循环
|
# 启动失败后再次拉起 worker 的最小间隔:避免"启动超时→立即重启→再超时"的死循环
|
||||||
# 在系统高负载时持续加载 torch/CUDA,进一步加剧卡顿。
|
# 在系统高负载时持续加载 torch/CUDA,进一步加剧卡顿。
|
||||||
STARTUP_FAILURE_BACKOFF_SECONDS = 60.0
|
STARTUP_FAILURE_BACKOFF_SECONDS = 60.0
|
||||||
|
# 每 N 次合成才做一次显存回收(empty_cache 会 stall 共用 GPU 的游戏/编码)
|
||||||
|
FREE_GPU_EVERY_N_SYNTH = 50
|
||||||
DEFAULT_CPU_THREADS = 4
|
DEFAULT_CPU_THREADS = 4
|
||||||
DEFAULT_CPU_AFFINITY_COUNT = 8
|
DEFAULT_CPU_AFFINITY_COUNT = 8
|
||||||
DEFAULT_PROCESS_PRIORITY = "below_normal"
|
DEFAULT_PROCESS_PRIORITY = "below_normal"
|
||||||
@@ -207,6 +210,68 @@ def _synthesize_wav(runtime: dict[str, Any], text: str) -> bytes:
|
|||||||
return output.getvalue()
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_memory_stats(runtime: dict[str, Any]) -> dict[str, float]:
|
||||||
|
"""采集 worker 进程的内存/显存占用,用于定位长跑性能退化(泄漏 vs 碎片化)。"""
|
||||||
|
stats: dict[str, float] = {}
|
||||||
|
torch = runtime.get("torch")
|
||||||
|
try:
|
||||||
|
if torch is not None and torch.cuda.is_available():
|
||||||
|
stats["vram_alloc_mb"] = round(torch.cuda.memory_allocated() / 1048576.0, 1)
|
||||||
|
stats["vram_reserved_mb"] = round(torch.cuda.memory_reserved() / 1048576.0, 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
stats["rss_mb"] = round(psutil.Process().memory_info().rss / 1048576.0, 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def _free_gpu_memory(runtime: dict[str, Any]) -> None:
|
||||||
|
"""回收碎片化显存与 Python 内存。
|
||||||
|
|
||||||
|
注意:empty_cache 会把缓存显存还给驱动,下次合成需重新申请,
|
||||||
|
在原神/直播姬共用 GPU 时会引发驱动级页表重整,导致 GPU 瞬时停摆
|
||||||
|
(占用掉到 0、直播画面卡顿)。因此不能在每次合成后调用,
|
||||||
|
只能在低频维护窗口(每 FREE_GPU_EVERY_N_SYNTH 次)执行。
|
||||||
|
"""
|
||||||
|
gc.collect()
|
||||||
|
torch = runtime.get("torch")
|
||||||
|
try:
|
||||||
|
if torch is not None and torch.cuda.is_available():
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _append_memory_sample(
|
||||||
|
log_path: str,
|
||||||
|
duration_ms: int,
|
||||||
|
mem: dict[str, float],
|
||||||
|
status: str,
|
||||||
|
) -> None:
|
||||||
|
"""把每次合成的耗时与内存/显存写入独立日志文件,用于定位长跑退化根因。"""
|
||||||
|
if not log_path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
line = (
|
||||||
|
f"{datetime.now().strftime('%Y-%m-%dT%H:%M:%S')} "
|
||||||
|
f"duration_ms={int(duration_ms)} "
|
||||||
|
f"vram_alloc_mb={mem.get('vram_alloc_mb', '')} "
|
||||||
|
f"vram_reserved_mb={mem.get('vram_reserved_mb', '')} "
|
||||||
|
f"rss_mb={mem.get('rss_mb', '')} "
|
||||||
|
f"status={status}"
|
||||||
|
)
|
||||||
|
with open(log_path, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(line + "\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) -> None:
|
def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) -> None:
|
||||||
def report(stage: str, **details: Any) -> None:
|
def report(stage: str, **details: Any) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -219,6 +284,12 @@ def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) ->
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
try:
|
||||||
report("process_started", executable=sys.executable, prefix=sys.prefix)
|
report("process_started", executable=sys.executable, prefix=sys.prefix)
|
||||||
cpu_threads = _configure_worker_environment(settings)
|
cpu_threads = _configure_worker_environment(settings)
|
||||||
@@ -282,6 +353,7 @@ def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) ->
|
|||||||
connection.close()
|
connection.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
synth_count = 0
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
message = connection.recv()
|
message = connection.recv()
|
||||||
@@ -297,17 +369,36 @@ def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) ->
|
|||||||
|
|
||||||
request_id = str(message.get("request_id") or "")
|
request_id = str(message.get("request_id") or "")
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
|
memory_log_path = str(settings.get("memory_log_path") or "")
|
||||||
try:
|
try:
|
||||||
audio = _synthesize_wav(runtime, str(message.get("text") or ""))
|
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({
|
connection.send({
|
||||||
"type": "result",
|
"type": "result",
|
||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
"audio": audio,
|
"audio": audio,
|
||||||
"duration_ms": int((time.monotonic() - started) * 1000),
|
"duration_ms": duration_ms,
|
||||||
"bytes": len(audio),
|
"bytes": len(audio),
|
||||||
"max_new_tokens": MAX_NEW_TOKENS,
|
"max_new_tokens": MAX_NEW_TOKENS,
|
||||||
|
"mem": mem,
|
||||||
})
|
})
|
||||||
except BaseException as exc:
|
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:
|
try:
|
||||||
connection.send({
|
connection.send({
|
||||||
"type": "error",
|
"type": "error",
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"""LLM 客户端抽象层。
|
||||||
|
|
||||||
|
目标:为直播间弹幕回复提供统一的对话接口,屏蔽底层各家协议差异。
|
||||||
|
|
||||||
|
结构:
|
||||||
|
- BaseLLMProvider 抽象基类,定义 async chat(messages) -> str
|
||||||
|
- OpenAICompatProvider OpenAI /chat/completions 兼容协议
|
||||||
|
(DeepSeek / 通义千问DashScope兼容模式 / Kimi / 硅基流动 /
|
||||||
|
Ollama / vLLM / LM Studio 等几乎所有主流服务)
|
||||||
|
- OllamaNativeProvider Ollama 原生 /api/chat 协议
|
||||||
|
- AnthropicProvider Anthropic /v1/messages 协议(Claude 系列)
|
||||||
|
- create_provider() 按 config["llm_reply"]["provider"] 工厂构造
|
||||||
|
- LLMReplyEngine 弹幕回复业务引擎:过滤、限流、上下文、重试
|
||||||
|
|
||||||
|
扩展新协议只需:继承 BaseLLMProvider 实现 chat(),并在 PROVIDERS 注册。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
try:
|
||||||
|
import aiohttp
|
||||||
|
except ImportError: # pragma: no cover - aiohttp 为运行时必需依赖
|
||||||
|
aiohttp = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provider 抽象与实现
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class LLMError(Exception):
|
||||||
|
"""LLM 调用失败(网络/协议/内容拒绝等)。"""
|
||||||
|
|
||||||
|
|
||||||
|
class BaseLLMProvider(ABC):
|
||||||
|
"""所有 LLM 协议适配器的公共基类。"""
|
||||||
|
|
||||||
|
name = "base"
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, api_key: str = "", model: str = "",
|
||||||
|
timeout_sec: float = 20.0, logger: logging.Logger | None = None):
|
||||||
|
self.base_url = (base_url or "").rstrip("/")
|
||||||
|
self.api_key = api_key or ""
|
||||||
|
self.model = model or ""
|
||||||
|
self.timeout_sec = max(3.0, float(timeout_sec or 20.0))
|
||||||
|
self.logger = logger or logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def chat(self, messages: list[dict], *, temperature: float = 0.8,
|
||||||
|
max_tokens: int = 256) -> str:
|
||||||
|
"""发送一轮对话,返回助手回复文本;失败抛 LLMError。"""
|
||||||
|
|
||||||
|
async def _post_json(self, url: str, payload: dict,
|
||||||
|
headers: dict | None = None) -> dict:
|
||||||
|
if aiohttp is None:
|
||||||
|
raise LLMError("缺少 aiohttp 依赖")
|
||||||
|
timeout = aiohttp.ClientTimeout(total=self.timeout_sec)
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.post(url, json=payload,
|
||||||
|
headers=headers or {}) as resp:
|
||||||
|
body = await resp.text()
|
||||||
|
if resp.status != 200:
|
||||||
|
raise LLMError(
|
||||||
|
f"HTTP {resp.status}: {body[:200]}")
|
||||||
|
import json as _json
|
||||||
|
return _json.loads(body)
|
||||||
|
except asyncio.TimeoutError as e:
|
||||||
|
raise LLMError(f"请求超时({self.timeout_sec}s)") from e
|
||||||
|
except LLMError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise LLMError(f"请求失败: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAICompatProvider(BaseLLMProvider):
|
||||||
|
"""OpenAI /v1/chat/completions 兼容协议。
|
||||||
|
|
||||||
|
覆盖:OpenAI、DeepSeek、Kimi(Moonshot)、通义千问(DashScope兼容模式)、
|
||||||
|
智谱GLM、硅基流动、OpenRouter、Ollama/VoidAI/vLLM/LM Studio 本地端点等。
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "openai"
|
||||||
|
|
||||||
|
DEFAULT_PATH = "/chat/completions"
|
||||||
|
|
||||||
|
async def chat(self, messages: list[dict], *, temperature: float = 0.8,
|
||||||
|
max_tokens: int = 256) -> str:
|
||||||
|
url = self.base_url + self.DEFAULT_PATH
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if self.api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": max(0.0, min(2.0, temperature)),
|
||||||
|
"max_tokens": max(16, int(max_tokens)),
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
data = await self._post_json(url, payload, headers)
|
||||||
|
try:
|
||||||
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
except (KeyError, IndexError, TypeError) as e:
|
||||||
|
raise LLMError(f"响应格式异常: {str(data)[:200]}") from e
|
||||||
|
if not isinstance(content, str):
|
||||||
|
raise LLMError("响应 content 非文本")
|
||||||
|
return content.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaNativeProvider(BaseLLMProvider):
|
||||||
|
"""Ollama 原生 /api/chat 协议(无需鉴权)。"""
|
||||||
|
|
||||||
|
name = "ollama"
|
||||||
|
|
||||||
|
async def chat(self, messages: list[dict], *, temperature: float = 0.8,
|
||||||
|
max_tokens: int = 256) -> str:
|
||||||
|
url = self.base_url + "/api/chat"
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": False,
|
||||||
|
"options": {
|
||||||
|
"temperature": max(0.0, min(2.0, temperature)),
|
||||||
|
"num_predict": max(16, int(max_tokens)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data = await self._post_json(url, payload)
|
||||||
|
content = data.get("message", {}).get("content", "")
|
||||||
|
if not isinstance(content, str):
|
||||||
|
raise LLMError("响应 message.content 非文本")
|
||||||
|
return content.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicProvider(BaseLLMProvider):
|
||||||
|
"""Anthropic /v1/messages 协议(Claude 系列)。"""
|
||||||
|
|
||||||
|
name = "anthropic"
|
||||||
|
|
||||||
|
ANTHROPIC_VERSION = "2023-06-01"
|
||||||
|
|
||||||
|
async def chat(self, messages: list[dict], *, temperature: float = 0.8,
|
||||||
|
max_tokens: int = 256) -> str:
|
||||||
|
url = self.base_url + "/v1/messages"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": self.api_key,
|
||||||
|
"anthropic-version": self.ANTHROPIC_VERSION,
|
||||||
|
}
|
||||||
|
# Anthropic 协议中 system 是顶层字段,不放在 messages 里。
|
||||||
|
system_text = "\n".join(
|
||||||
|
m["content"] for m in messages if m.get("role") == "system")
|
||||||
|
chat_msgs = [m for m in messages if m.get("role") != "system"]
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"system": system_text or None,
|
||||||
|
"messages": [
|
||||||
|
{"role": m["role"], "content": m["content"]}
|
||||||
|
for m in chat_msgs
|
||||||
|
],
|
||||||
|
"temperature": max(0.0, min(1.0, temperature)),
|
||||||
|
"max_tokens": max(16, int(max_tokens)),
|
||||||
|
}
|
||||||
|
data = await self._post_json(url, payload, headers)
|
||||||
|
try:
|
||||||
|
parts = data["content"]
|
||||||
|
text = "".join(p.get("text", "") for p in parts if isinstance(p, dict))
|
||||||
|
except (KeyError, TypeError) as e:
|
||||||
|
raise LLMError(f"响应格式异常: {str(data)[:200]}") from e
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
# 协议注册表:新增协议在此注册即可。
|
||||||
|
PROVIDERS: dict[str, type[BaseLLMProvider]] = {
|
||||||
|
OpenAICompatProvider.name: OpenAICompatProvider,
|
||||||
|
OllamaNativeProvider.name: OllamaNativeProvider,
|
||||||
|
AnthropicProvider.name: AnthropicProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_provider(cfg: dict, logger: logging.Logger | None = None) -> BaseLLMProvider:
|
||||||
|
"""根据 llm_reply 配置节构造 provider。"""
|
||||||
|
cfg = cfg or {}
|
||||||
|
name = str(cfg.get("provider", "openai") or "openai").strip().lower()
|
||||||
|
cls = PROVIDERS.get(name)
|
||||||
|
if cls is None:
|
||||||
|
raise LLMError(
|
||||||
|
f"未知 LLM 协议: {name}(可选: {', '.join(sorted(PROVIDERS))})")
|
||||||
|
base_url = str(cfg.get("base_url", "") or "").strip()
|
||||||
|
if not base_url:
|
||||||
|
raise LLMError("未配置 base_url")
|
||||||
|
return cls(
|
||||||
|
base_url=base_url,
|
||||||
|
api_key=str(cfg.get("api_key", "") or ""),
|
||||||
|
model=str(cfg.get("model", "") or "").strip(),
|
||||||
|
timeout_sec=float(cfg.get("timeout_sec", 20) or 20),
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 弹幕回复业务引擎
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class LLMReplyEngine:
|
||||||
|
"""把观众弹幕交给 LLM 生成回复。
|
||||||
|
|
||||||
|
职责边界:本类只负责"生成回复文本",不负责发送——
|
||||||
|
由调用方(CommandHandler)决定走弹幕/TTS 通道。
|
||||||
|
内置:
|
||||||
|
- 开关与触发过滤(长度、忽略前缀、黑名单)
|
||||||
|
- 频率限制(全局每分钟上限 + 单人冷却)
|
||||||
|
- 短期上下文(最近 N 条观众发言与机器人回复)
|
||||||
|
- 失败重试(指数退避,最多 retry_attempts 次)
|
||||||
|
- 并发保护(同一时刻只允许一个在途请求,忙时直接跳过该条)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cfg: dict | None = None, logger: logging.Logger | None = None,
|
||||||
|
config_getter=None):
|
||||||
|
"""config_getter: 可选的可调用对象,返回最新 llm_reply 配置 dict,
|
||||||
|
用于跟随 Config 热更新(reload 后 data 节点可能是新对象)。"""
|
||||||
|
self.logger = logger or logging.getLogger(__name__)
|
||||||
|
self._cfg = cfg or {}
|
||||||
|
self._config_getter = config_getter
|
||||||
|
self._provider: BaseLLMProvider | None = None
|
||||||
|
self._provider_built_at = 0.0
|
||||||
|
self._context: deque[tuple[str, str]] = deque() # (speaker, text)
|
||||||
|
self._reply_times: deque[float] = deque() # 全局滑动窗口
|
||||||
|
self._user_last_reply: dict[int, float] = {}
|
||||||
|
self._inflight = asyncio.Lock()
|
||||||
|
# 配置指纹:热更新后自动重建 provider。
|
||||||
|
self._cfg_fingerprint = ""
|
||||||
|
|
||||||
|
# -- 配置 ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def _config(self) -> dict:
|
||||||
|
if self._config_getter is not None:
|
||||||
|
try:
|
||||||
|
cfg = self._config_getter()
|
||||||
|
if cfg:
|
||||||
|
self._cfg = cfg
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return self._cfg or {}
|
||||||
|
|
||||||
|
def update_config(self, cfg: dict):
|
||||||
|
"""配置热更新入口。"""
|
||||||
|
self._cfg = cfg or {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return bool(self._config().get("enabled", False))
|
||||||
|
|
||||||
|
def _provider_fingerprint(self) -> str:
|
||||||
|
c = self._config()
|
||||||
|
return "|".join(str(c.get(k, "")) for k in
|
||||||
|
("provider", "base_url", "api_key", "model", "timeout_sec"))
|
||||||
|
|
||||||
|
def _get_provider(self) -> BaseLLMProvider:
|
||||||
|
fp = self._provider_fingerprint()
|
||||||
|
if self._provider is None or fp != self._cfg_fingerprint:
|
||||||
|
self._provider = create_provider(self._config(), self.logger)
|
||||||
|
self._cfg_fingerprint = fp
|
||||||
|
self.logger.info(f"[LLM] 使用协议 {self._provider.name} "
|
||||||
|
f"模型 {self._provider.model or '(默认)'}")
|
||||||
|
return self._provider
|
||||||
|
|
||||||
|
# -- 过滤 ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def should_reply(self, text: str, uid: int, uname: str) -> str | None:
|
||||||
|
"""返回需要回复的规范化文本;不需要回复返回 None。"""
|
||||||
|
cfg = self._config()
|
||||||
|
if not self.enabled:
|
||||||
|
return None
|
||||||
|
text = (text or "").strip()
|
||||||
|
raw = cfg.get("min_text_len", 2)
|
||||||
|
min_len = int(raw) if raw is not None else 2
|
||||||
|
if len(text) < min_len:
|
||||||
|
return None
|
||||||
|
for prefix in cfg.get("ignore_prefixes", []) or []:
|
||||||
|
prefix = str(prefix or "").strip()
|
||||||
|
if prefix and text.startswith(prefix):
|
||||||
|
return None
|
||||||
|
blacklist = {str(u) for u in (cfg.get("blacklist_uids", []) or [])}
|
||||||
|
if str(uid) in blacklist:
|
||||||
|
return None
|
||||||
|
# 单人冷却(0 表示禁用冷却)
|
||||||
|
raw = cfg.get("per_user_cooldown_sec", 30)
|
||||||
|
cooldown = float(raw) if raw is not None else 30.0
|
||||||
|
if cooldown > 0:
|
||||||
|
last = self._user_last_reply.get(int(uid), 0.0)
|
||||||
|
if time.time() - last < cooldown:
|
||||||
|
return None
|
||||||
|
# 全局限流(0 表示不限流)
|
||||||
|
raw = cfg.get("replies_per_minute", 6)
|
||||||
|
rpm = int(raw) if raw is not None else 6
|
||||||
|
if rpm > 0:
|
||||||
|
now = time.time()
|
||||||
|
while self._reply_times and now - self._reply_times[0] > 60:
|
||||||
|
self._reply_times.popleft()
|
||||||
|
if len(self._reply_times) >= rpm:
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
# -- 上下文 ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_messages(self, text: str, uname: str) -> list[dict]:
|
||||||
|
cfg = self._config()
|
||||||
|
system_prompt = str(cfg.get("system_prompt", "") or "").strip()
|
||||||
|
messages: list[dict] = []
|
||||||
|
emotion_instruction = (
|
||||||
|
"每次回复必须先输出一个情绪标签,只能从[开心]、[很开心]、[平静]、[生气]、[很生气]中选一个,"
|
||||||
|
"紧接着输出给观众的回复正文。标签只用于桌宠状态,不属于口播内容。"
|
||||||
|
)
|
||||||
|
messages.append({"role": "system", "content": "\n".join(
|
||||||
|
part for part in (system_prompt, emotion_instruction) if part
|
||||||
|
)})
|
||||||
|
for speaker, content in list(self._context):
|
||||||
|
role = "assistant" if speaker == "bot" else "user"
|
||||||
|
content = f"{speaker}: {content}" if role == "user" else content
|
||||||
|
messages.append({"role": role, "content": content})
|
||||||
|
messages.append({"role": "user", "content": f"{uname}: {text}"})
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def _remember(self, uname: str, text: str, reply: str | None):
|
||||||
|
max_turns = int(self._config().get("context_turns", 6) or 6)
|
||||||
|
if max_turns <= 0:
|
||||||
|
return
|
||||||
|
self._context.append((uname, text))
|
||||||
|
if reply:
|
||||||
|
self._context.append(("bot", reply))
|
||||||
|
while len(self._context) > max_turns * 2:
|
||||||
|
self._context.popleft()
|
||||||
|
|
||||||
|
# -- 主入口 ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def generate_reply(self, text: str, uid: int, uname: str) -> str | None:
|
||||||
|
"""生成回复;被过滤/限流/失败返回 None。非阻塞——忙时立即放弃。"""
|
||||||
|
text = self.should_reply(text, uid, uname)
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
if self._inflight.locked():
|
||||||
|
# 已有请求在途:丢弃本条,避免弹幕高峰打爆 LLM。
|
||||||
|
return None
|
||||||
|
async with self._inflight:
|
||||||
|
cfg = self._config()
|
||||||
|
provider = self._get_provider()
|
||||||
|
messages = self._build_messages(text, uname or "观众")
|
||||||
|
temperature = float(cfg.get("temperature", 0.8) or 0.8)
|
||||||
|
max_tokens = int(cfg.get("max_tokens", 128) or 128)
|
||||||
|
attempts = max(1, int(cfg.get("retry_attempts", 2) or 1))
|
||||||
|
last_err: Exception | None = None
|
||||||
|
for i in range(attempts):
|
||||||
|
try:
|
||||||
|
raw = await provider.chat(
|
||||||
|
messages, temperature=temperature, max_tokens=max_tokens)
|
||||||
|
reply = self._sanitize(raw)
|
||||||
|
if reply:
|
||||||
|
now = time.time()
|
||||||
|
self._reply_times.append(now)
|
||||||
|
self._user_last_reply[int(uid)] = now
|
||||||
|
self._remember(uname or "观众", text, reply)
|
||||||
|
return reply
|
||||||
|
return None # 模型返回空内容,不重试
|
||||||
|
except LLMError as e:
|
||||||
|
last_err = e
|
||||||
|
if i < attempts - 1:
|
||||||
|
await asyncio.sleep(0.8 * (2 ** i) + random.random() * 0.4)
|
||||||
|
self.logger.warning(f"[LLM] 回复失败({last_err})")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _sanitize(self, raw: str) -> str:
|
||||||
|
"""清洗模型输出:去空白、截断到 max_reply_chars、去 markdown 粗体。
|
||||||
|
|
||||||
|
开头的情绪标签(如 [开心])豁免截断长度,保证不会被截坏;
|
||||||
|
标签本身由调用方(danmu_queue)在弹幕/语音输出前剥离。
|
||||||
|
"""
|
||||||
|
cfg = self._config()
|
||||||
|
text = str(raw or "").strip()
|
||||||
|
# 直播口播场景不需要 markdown 标记
|
||||||
|
for token in ("**", "__", "```"):
|
||||||
|
text = text.replace(token, "")
|
||||||
|
text = " ".join(text.split())
|
||||||
|
m = re.match(r"^(\[[^\[\]]{1,8}\])\s*", text)
|
||||||
|
tag = m.group(1) if m else ""
|
||||||
|
body = text[m.end():] if m else text
|
||||||
|
raw = cfg.get("max_reply_chars", 60)
|
||||||
|
max_chars = int(raw) if raw is not None else 60
|
||||||
|
if max_chars > 0 and len(tag) + len(body) > max_chars:
|
||||||
|
body = body[:max(1, max_chars - len(tag))].rstrip() + "…"
|
||||||
|
return tag + body
|
||||||
+5
-11
@@ -165,12 +165,6 @@ async def _run_queue(host: str, port: int):
|
|||||||
await danmu_queue.main(host=host, port=port)
|
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():
|
def _run_tts_monitor():
|
||||||
import 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.
|
# Refuse stale/conflicting listeners before creating any helper process.
|
||||||
_assert_port_available(host, port)
|
_assert_port_available(host, port)
|
||||||
queue_task = asyncio.create_task(_run_queue(host, port), name="queue-main")
|
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))
|
children.append(_spawn_role("tts", port, host, visible=True))
|
||||||
for child in children:
|
for child in children:
|
||||||
job.assign(child)
|
job.assign(child)
|
||||||
@@ -222,7 +219,7 @@ async def _run_all(host: str, port: int):
|
|||||||
def main():
|
def main():
|
||||||
ensure_runtime_dirs()
|
ensure_runtime_dirs()
|
||||||
parser = argparse.ArgumentParser(description="BetterGI 直播联动统一入口")
|
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("--port", type=int, default=8086)
|
||||||
parser.add_argument("--host", default="0.0.0.0", help="Web service bind address")
|
parser.add_argument("--host", default="0.0.0.0", help="Web service bind address")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -238,9 +235,6 @@ def main():
|
|||||||
if args.role == "tts":
|
if args.role == "tts":
|
||||||
_run_tts_monitor()
|
_run_tts_monitor()
|
||||||
return 0
|
return 0
|
||||||
if args.role == "music":
|
|
||||||
asyncio.run(_run_music(args.port))
|
|
||||||
return 0
|
|
||||||
if args.role == "queue":
|
if args.role == "queue":
|
||||||
asyncio.run(_run_queue(args.host, args.port))
|
asyncio.run(_run_queue(args.host, args.port))
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Persistent Kirara state that decays only during an active live session."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
EMOTION_DELTAS = {
|
||||||
|
"开心": 1,
|
||||||
|
"很开心": 3,
|
||||||
|
"平静": 0,
|
||||||
|
"生气": -1,
|
||||||
|
"很生气": -3,
|
||||||
|
}
|
||||||
|
EMOTION_PREFIX = re.compile(r"^\s*\[(很开心|很生气|开心|平静|生气)\]\s*")
|
||||||
|
GIFT_RECOVERY = {
|
||||||
|
"饭团": (5, 0),
|
||||||
|
"薯条": (5, 0),
|
||||||
|
"人气票": (0, 5),
|
||||||
|
"粉丝团灯牌": (0, 5),
|
||||||
|
"灯牌": (0, 5), # 直播事件中常用的简写
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clamp(value: int | float) -> int:
|
||||||
|
return max(0, min(100, int(value)))
|
||||||
|
|
||||||
|
|
||||||
|
def gift_recovery(gift_name: str, quantity: int = 1) -> tuple[int, int]:
|
||||||
|
"""Return nominal stamina/cleanliness points for a gift batch."""
|
||||||
|
stamina, cleanliness = GIFT_RECOVERY.get(str(gift_name).strip(), (0, 0))
|
||||||
|
count = max(0, int(quantity))
|
||||||
|
return stamina * count, cleanliness * count
|
||||||
|
|
||||||
|
|
||||||
|
def split_emotion(reply: str) -> tuple[str, str | None]:
|
||||||
|
"""Return the spoken reply and its recognized leading emotion tag."""
|
||||||
|
match = EMOTION_PREFIX.match(reply)
|
||||||
|
if not match:
|
||||||
|
return reply, None
|
||||||
|
return reply[match.end():].strip(), match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
class PetState:
|
||||||
|
def __init__(self, path: str | Path):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.values = {"stamina": 100, "cleanliness": 100, "mood": 100}
|
||||||
|
# Short-lived events let open frontends play each AI mood reaction once.
|
||||||
|
self.recent_emotions: list[dict] = []
|
||||||
|
self.live_seconds = 0.0
|
||||||
|
# A restart must never count the offline gap as live time.
|
||||||
|
self.live_active = False
|
||||||
|
self.updated_at = time.time()
|
||||||
|
try:
|
||||||
|
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
self.values = {key: clamp(data.get(key, 100)) for key in self.values}
|
||||||
|
self.live_seconds = max(0.0, min(3599.999, float(data.get("live_seconds", 0))))
|
||||||
|
self.updated_at = min(self.updated_at, float(data.get("updated_at", self.updated_at)))
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
self._saved_at = self.updated_at
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
"""持久化。保存失败只降级为日志,绝不影响礼物/直播主流程。"""
|
||||||
|
try:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = json.dumps({**self.values, "live_seconds": self.live_seconds,
|
||||||
|
"updated_at": self.updated_at}, ensure_ascii=False)
|
||||||
|
try:
|
||||||
|
pending = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||||
|
pending.write_text(payload, encoding="utf-8")
|
||||||
|
pending.replace(self.path)
|
||||||
|
except OSError:
|
||||||
|
# 原子替换被拒(杀软锁定/权限)时退回直接覆写
|
||||||
|
self.path.write_text(payload, encoding="utf-8")
|
||||||
|
Path(str(self.path) + ".tmp").unlink(missing_ok=True)
|
||||||
|
self._saved_at = self.updated_at
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger("pet_state").warning(
|
||||||
|
"[桌宠] 状态保存失败(不影响礼物处理): %s", self.path, exc_info=True)
|
||||||
|
|
||||||
|
def _record_emotion(self, delta: int, now: float) -> None:
|
||||||
|
if not delta:
|
||||||
|
return
|
||||||
|
self.recent_emotions.append({
|
||||||
|
"id": uuid.uuid4().hex,
|
||||||
|
"ts": now,
|
||||||
|
"delta": delta,
|
||||||
|
})
|
||||||
|
del self.recent_emotions[:-32]
|
||||||
|
|
||||||
|
def sync(self, now: float | None = None) -> dict[str, int]:
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
if now > self.updated_at:
|
||||||
|
added = now - self.updated_at if self.live_active else 0.0
|
||||||
|
self.live_seconds += added
|
||||||
|
hours = int(self.live_seconds // 3600)
|
||||||
|
self.live_seconds %= 3600
|
||||||
|
for _ in range(hours):
|
||||||
|
self.values["stamina"] = clamp(self.values["stamina"] - 10)
|
||||||
|
self.values["cleanliness"] = clamp(self.values["cleanliness"] - 10)
|
||||||
|
if self.values["stamina"] < 40 and self.values["cleanliness"] < 40:
|
||||||
|
self.values["mood"] = clamp(self.values["mood"] - 5)
|
||||||
|
elif self.values["stamina"] > 80 and self.values["cleanliness"] > 80:
|
||||||
|
self.values["mood"] = clamp(self.values["mood"] + 3)
|
||||||
|
self._record_emotion(3, now)
|
||||||
|
self.updated_at = now
|
||||||
|
if hours or (added and now - self._saved_at >= 60):
|
||||||
|
self._save()
|
||||||
|
return dict(self.values)
|
||||||
|
|
||||||
|
def set_live_active(self, active: bool, now: float | None = None) -> dict[str, int]:
|
||||||
|
"""Settle the previous session state before switching at this instant."""
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
self.sync(now)
|
||||||
|
if self.live_active != active:
|
||||||
|
self.live_active = active
|
||||||
|
self._save()
|
||||||
|
return dict(self.values)
|
||||||
|
|
||||||
|
def apply_emotion(self, emotion: str) -> dict[str, int]:
|
||||||
|
self.sync()
|
||||||
|
if emotion in EMOTION_DELTAS:
|
||||||
|
delta = EMOTION_DELTAS[emotion]
|
||||||
|
self.values["mood"] = clamp(self.values["mood"] + delta)
|
||||||
|
self._record_emotion(delta, time.time())
|
||||||
|
self._save()
|
||||||
|
return dict(self.values)
|
||||||
|
|
||||||
|
def restore_gift(self, stamina: int = 0, cleanliness: int = 0) -> dict[str, int]:
|
||||||
|
"""Apply gift recovery while keeping stored values within 0–100."""
|
||||||
|
self.sync()
|
||||||
|
if stamina or cleanliness:
|
||||||
|
self.values["stamina"] = clamp(self.values["stamina"] + stamina)
|
||||||
|
self.values["cleanliness"] = clamp(self.values["cleanliness"] + cleanliness)
|
||||||
|
self._save()
|
||||||
|
return dict(self.values)
|
||||||
@@ -208,6 +208,30 @@ class RedemptionCodeStore:
|
|||||||
if starts_utc >= ends_utc:
|
if starts_utc >= ends_utc:
|
||||||
raise ValueError("失效时间必须晚于生效时间")
|
raise ValueError("失效时间必须晚于生效时间")
|
||||||
now = utc_now()
|
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:
|
try:
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
"""INSERT INTO redemption_codes (
|
"""INSERT INTO redemption_codes (
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ from core.runtime_paths import DATA_DIR, ensure_runtime_dirs
|
|||||||
|
|
||||||
|
|
||||||
def clear():
|
def clear():
|
||||||
|
# 无控制台(pythonw)时执行 cls 会不断弹出新 cmd 窗口,直接跳过。
|
||||||
|
if sys.stdout is None:
|
||||||
|
return
|
||||||
os.system("cls" if os.name == "nt" else "clear")
|
os.system("cls" if os.name == "nt" else "clear")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,31 @@ const ensureDraftShape = (draft) => {
|
|||||||
draft.rules ||= [];
|
draft.rules ||= [];
|
||||||
draft.commands ||= {};
|
draft.commands ||= {};
|
||||||
draft.music_monitor.targets ||= [];
|
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_uids ||= [];
|
||||||
draft.global.admin_uidsText = draft.global.admin_uids.join(",");
|
draft.global.admin_uidsText = draft.global.admin_uids.join(",");
|
||||||
draft.music_monitor.targetsText = draft.music_monitor.targets.join(",");
|
draft.music_monitor.targetsText = draft.music_monitor.targets.join(",");
|
||||||
@@ -178,6 +203,7 @@ createApp({
|
|||||||
["redemption", "兑换码"],
|
["redemption", "兑换码"],
|
||||||
["songs", "点歌"],
|
["songs", "点歌"],
|
||||||
["media", "音乐/TTS"],
|
["media", "音乐/TTS"],
|
||||||
|
["llm", "AI回复"],
|
||||||
["system", "系统定时"],
|
["system", "系统定时"],
|
||||||
["logs", "日志"],
|
["logs", "日志"],
|
||||||
["raw", "JSON"]
|
["raw", "JSON"]
|
||||||
@@ -219,6 +245,8 @@ createApp({
|
|||||||
giftTestNum: 1,
|
giftTestNum: 1,
|
||||||
giftTestValue: 0,
|
giftTestValue: 0,
|
||||||
giftTestResult: "",
|
giftTestResult: "",
|
||||||
|
llmTestText: "主播在玩什么游戏呀?",
|
||||||
|
llmTestResult: "",
|
||||||
lastLoadedAt: "",
|
lastLoadedAt: "",
|
||||||
songKeyword: "",
|
songKeyword: "",
|
||||||
songResults: [],
|
songResults: [],
|
||||||
@@ -288,6 +316,22 @@ createApp({
|
|||||||
lanAdminUrls() {
|
lanAdminUrls() {
|
||||||
return this.state.access_urls?.lan_admin || [];
|
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() {
|
songQueue() {
|
||||||
return this.music.song_requests?.queue || this.music.requests || [];
|
return this.music.song_requests?.queue || this.music.requests || [];
|
||||||
},
|
},
|
||||||
@@ -1025,6 +1069,41 @@ createApp({
|
|||||||
this.giftTestResult = err.message || String(err);
|
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() {
|
async saveSystemSchedule() {
|
||||||
if (!this.draft?.system) return;
|
if (!this.draft?.system) return;
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
@@ -1665,6 +1744,47 @@ createApp({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section v-if="activeTab==='system' && draft" class="form-grid">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>直播时间</h2>
|
<h2>直播时间</h2>
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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"))
|
||||||
+1
-1
@@ -34,7 +34,7 @@ function Get-ProjectPython {
|
|||||||
$env:LIVE_STREAMING_PYTHON,
|
$env:LIVE_STREAMING_PYTHON,
|
||||||
(Join-Path $Root ".venv-tts\Scripts\python.exe"),
|
(Join-Path $Root ".venv-tts\Scripts\python.exe"),
|
||||||
(Join-Path $Root ".venv\Scripts\python.exe"),
|
(Join-Path $Root ".venv\Scripts\python.exe"),
|
||||||
"E:\Programs\Anaconda3\envs\Live-streaming\python.exe",
|
$(if ($env:CONDA_PREFIX) { Join-Path $env:CONDA_PREFIX "python.exe" }),
|
||||||
"python"
|
"python"
|
||||||
) | Where-Object { $_ }
|
) | Where-Object { $_ }
|
||||||
foreach ($candidate in $candidates) {
|
foreach ($candidate in $candidates) {
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -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()
|
||||||
@@ -111,5 +111,48 @@ class BgiLogCompletionTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
callback.assert_not_awaited()
|
callback.assert_not_awaited()
|
||||||
self.assertEqual(monitor._current_group, "自动每日")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from app.faster_qwen_worker import (
|
from app.faster_qwen_worker import (
|
||||||
DEFAULT_CPU_AFFINITY_COUNT,
|
DEFAULT_CPU_AFFINITY_COUNT,
|
||||||
@@ -338,5 +339,56 @@ class FasterQwenWorkerTests(unittest.TestCase):
|
|||||||
client.close()
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import ctypes
|
import ctypes
|
||||||
import logging
|
import logging
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
@@ -319,5 +321,105 @@ class ManualLiveOverrideTests(unittest.TestCase):
|
|||||||
self.assertTrue(handler._is_live_time())
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -4,10 +4,11 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>直播联动后台</title>
|
<title>直播联动后台</title>
|
||||||
<script type="module" crossorigin src="/admin/assets/index-BdYZGJMQ.js"></script>
|
<script type="module" crossorigin src="/admin/assets/index-DzEqvM4m.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-BB8VhdaG.css">
|
<link rel="stylesheet" crossorigin href="/admin/assets/index-BB8VhdaG.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+715
-18
@@ -46,10 +46,10 @@
|
|||||||
#viewport {
|
#viewport {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start; background: transparent;
|
||||||
background: transparent;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,9 +62,13 @@
|
|||||||
#canvas {
|
#canvas {
|
||||||
width: 1920px;
|
width: 1920px;
|
||||||
height: 1080px;
|
height: 1080px;
|
||||||
position: relative;
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
margin-left: -960px;
|
||||||
|
margin-top: -540px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
transform-origin: top left;
|
transform-origin: center center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#frame {
|
#frame {
|
||||||
@@ -1728,6 +1732,85 @@
|
|||||||
body.theme-aurora .tutorial-text {
|
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);
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -1749,6 +1832,20 @@
|
|||||||
<!-- game-cutout 放在 #frame 外面, 不受 clip-path 裁剪, 保证镂空区域完全透明(OBS 穿透) -->
|
<!-- game-cutout 放在 #frame 外面, 不受 clip-path 裁剪, 保证镂空区域完全透明(OBS 穿透) -->
|
||||||
<div id="game-cutout"></div>
|
<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">
|
<section class="panel available-groups-panel">
|
||||||
<div class="panel-title">可用配置组</div>
|
<div class="panel-title">可用配置组</div>
|
||||||
@@ -2120,14 +2217,603 @@
|
|||||||
let performanceState = {cpu: null, memory: null, gpu: null, gpu_available: false};
|
let performanceState = {cpu: null, memory: null, gpu: null, gpu_available: false};
|
||||||
let lastCoverKey = "";
|
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() {
|
async function fetchState() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch("/api/state");
|
const r = await fetch("/api/state");
|
||||||
const d = await r.json();
|
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);
|
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);
|
renderQueue(d.queue, d.users, d.current_admin);
|
||||||
initGiftFxBaseline(d.recent_gifts);
|
initGiftFxBaseline(d.server_ts);
|
||||||
giftFx.sync(d.recent_gifts);
|
giftFx.sync(d.recent_gifts);
|
||||||
|
renderKirara(d.pet);
|
||||||
|
syncKiraraEmotionEvents(d.pet_emotion_events, d.server_ts);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("state", e);
|
console.error("state", e);
|
||||||
}
|
}
|
||||||
@@ -2516,17 +3202,16 @@
|
|||||||
const noteEl = document.getElementById("music-note");
|
const noteEl = document.getElementById("music-note");
|
||||||
const listEl = document.getElementById("playlist");
|
const listEl = document.getElementById("playlist");
|
||||||
|
|
||||||
const monitor = musicState.monitor || {};
|
// 以 mpv 播放器真实状态为准(已移除 SMTC 系统媒体监听):
|
||||||
const updatedAtMs = Number(monitor.updated_at || 0) * 1000;
|
// idle 或没有曲目时为“未播放”,不再显示“音乐源离线 / SMTC”。
|
||||||
const monitorStale = !updatedAtMs || Date.now() - updatedAtMs > 10000;
|
const isIdle = !!musicState.idle || !cur.title || cur.title === "暂无歌曲";
|
||||||
const monitorOffline = monitor.online === false || monitorStale;
|
|
||||||
|
|
||||||
if (monitorOffline) {
|
if (isIdle) {
|
||||||
titleEl.textContent = "音乐源离线";
|
titleEl.textContent = "未播放";
|
||||||
artistEl.textContent = "等待网易云 SMTC 状态";
|
artistEl.textContent = "等待点歌或背景歌单";
|
||||||
coverEl.innerHTML = "♪";
|
coverEl.innerHTML = "♪";
|
||||||
lastCoverKey = "";
|
lastCoverKey = "";
|
||||||
noteEl.textContent = "监听未运行或状态已过期";
|
noteEl.textContent = "点歌或开启背景歌单后将自动播放";
|
||||||
} else if (!cur.title && !cur.artist) {
|
} else if (!cur.title && !cur.artist) {
|
||||||
titleEl.textContent = "未配置";
|
titleEl.textContent = "未配置";
|
||||||
artistEl.textContent = "等待数据源";
|
artistEl.textContent = "等待数据源";
|
||||||
@@ -2578,6 +3263,8 @@
|
|||||||
particles: [],
|
particles: [],
|
||||||
rafId: null,
|
rafId: null,
|
||||||
lastTs: 0,
|
lastTs: 0,
|
||||||
|
baselineInitialized: false,
|
||||||
|
openedAtClientTs: Date.now() / 1000,
|
||||||
bannerTimer: null,
|
bannerTimer: null,
|
||||||
queue: [],
|
queue: [],
|
||||||
playing: false,
|
playing: false,
|
||||||
@@ -2743,14 +3430,22 @@
|
|||||||
const fresh = gifts.filter(g => Number(g.ts || 0) > this.lastTs);
|
const fresh = gifts.filter(g => Number(g.ts || 0) > this.lastTs);
|
||||||
if (fresh.length === 0) return;
|
if (fresh.length === 0) return;
|
||||||
this.lastTs = Math.max(...gifts.map(g => Number(g.ts || 0)));
|
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) {
|
function initGiftFxBaseline(serverTs) {
|
||||||
if (giftFx.lastTs > 0 || !Array.isArray(gifts) || gifts.length === 0) return;
|
if (giftFx.baselineInitialized) return;
|
||||||
giftFx.lastTs = Math.max(...gifts.map(g => Number(g.ts || 0)));
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2764,6 +3459,8 @@
|
|||||||
|
|
||||||
applyCommandHints(fallbackCommandHints);
|
applyCommandHints(fallbackCommandHints);
|
||||||
startTutorialCarousel();
|
startTutorialCarousel();
|
||||||
|
renderKirara({stamina: 100, cleanliness: 100, mood: 100});
|
||||||
|
requestAnimationFrame(animateKirara);
|
||||||
fetchFrontendConfig();
|
fetchFrontendConfig();
|
||||||
fetchState();
|
fetchState();
|
||||||
fetchLog();
|
fetchLog();
|
||||||
|
|||||||
@@ -386,5 +386,17 @@
|
|||||||
{
|
{
|
||||||
"name": "霜仙花",
|
"name": "霜仙花",
|
||||||
"image": "/uploads/group_霜仙花.webp"
|
"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.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user