@sophiacyl/dsh-plugin-waves 0.0.0-stage → 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DSH-GO-VERIFICATION.md +57 -0
- package/ICON.png +0 -0
- package/LICENSE +674 -0
- package/README.md +251 -2
- package/cordis.patch.yml +8 -0
- package/install.ps1 +0 -0
- package/install.sh +209 -0
- package/lib/client.js +1511 -0
- package/lib/host/aliashint.js +70 -0
- package/lib/host/attachments.js +25 -0
- package/lib/host/command.js +34 -0
- package/lib/host/consts.js +16 -0
- package/lib/host/dataguide.js +35 -0
- package/lib/host/imagebridge.js +41 -0
- package/lib/host/native-result.js +91 -0
- package/lib/host/native.js +191 -0
- package/lib/host/proxy.js +59 -0
- package/lib/host/results.js +67 -0
- package/lib/host/service.js +263 -0
- package/lib/host/tool.js +185 -0
- package/lib/host/troubleshoot.js +31 -0
- package/lib/host/worldbook.js +80 -0
- package/lib/index.js +74 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +136 -4
- package/service/ICON.png +0 -0
- package/service/ai/AI_DATA.md +221 -0
- package/service/knowledge/additional/manual.jsonl +5 -0
- package/service/knowledge/wiki/baike.jsonl +1231 -0
- package/service/knowledge/wiki/chengjiu.jsonl +36 -0
- package/service/knowledge/wiki/gonglve.jsonl +383 -0
- package/service/knowledge/wiki/huodong.jsonl +427 -0
- package/service/knowledge/wiki/renwu.jsonl +412 -0
- package/service/knowledge/wiki/tujian.jsonl +2889 -0
- package/service/knowledge/wiki/zixun.jsonl +47 -0
- package/service/plugins/__init__.py +0 -0
- package/service/pyproject.toml +40 -0
- package/service/rover/__init__.py +0 -0
- package/service/rover/ai_core/__init__.py +0 -0
- package/service/rover/ai_core/models.py +50 -0
- package/service/rover/ai_core/rag/__init__.py +5 -0
- package/service/rover/ai_core/rag/knowledge.py +23 -0
- package/service/rover/ai_core/register.py +86 -0
- package/service/rover/ai_core/resource.py +6 -0
- package/service/rover/ai_core/skills/__init__.py +0 -0
- package/service/rover/ai_core/skills/operations.py +8 -0
- package/service/rover/api.py +720 -0
- package/service/rover/api_render.py +161 -0
- package/service/rover/app_life.py +154 -0
- package/service/rover/aps.py +47 -0
- package/service/rover/avatar.py +85 -0
- package/service/rover/bot.py +133 -0
- package/service/rover/builtin.py +152 -0
- package/service/rover/cards/__init__.py +26 -0
- package/service/rover/cards/_shared.py +129 -0
- package/service/rover/cards/char_list.py +258 -0
- package/service/rover/cards/char_panel.py +1353 -0
- package/service/rover/cards/echo_list.py +206 -0
- package/service/rover/cards/matrix_roles.py +25 -0
- package/service/rover/cards/rank_boards.py +714 -0
- package/service/rover/cards/rank_total.py +664 -0
- package/service/rover/compat.py +215 -0
- package/service/rover/config.py +163 -0
- package/service/rover/custom_panel.py +220 -0
- package/service/rover/data_store.py +31 -0
- package/service/rover/gss.py +19 -0
- package/service/rover/handler.py +191 -0
- package/service/rover/help/__init__.py +0 -0
- package/service/rover/help/draw_new_plugin_help.py +332 -0
- package/service/rover/help/model.py +16 -0
- package/service/rover/help/utils.py +7 -0
- package/service/rover/hooks.py +420 -0
- package/service/rover/html_actions.py +236 -0
- package/service/rover/i18n.py +19 -0
- package/service/rover/knowledge.py +235 -0
- package/service/rover/logger.py +102 -0
- package/service/rover/main.py +33 -0
- package/service/rover/media.py +222 -0
- package/service/rover/message_models.py +23 -0
- package/service/rover/models.py +92 -0
- package/service/rover/native_media.py +64 -0
- package/service/rover/ocr.py +79 -0
- package/service/rover/pool.py +43 -0
- package/service/rover/restart.py +36 -0
- package/service/rover/segment.py +143 -0
- package/service/rover/server.py +131 -0
- package/service/rover/status/__init__.py +0 -0
- package/service/rover/status/models.py +10 -0
- package/service/rover/status/plugin_status.py +17 -0
- package/service/rover/subscribe.py +148 -0
- package/service/rover/sv.py +403 -0
- package/service/rover/templates/char_list.html +182 -0
- package/service/rover/templates/char_panel.html +465 -0
- package/service/rover/templates/echo_list.html +221 -0
- package/service/rover/templates/rank_all_total.html +193 -0
- package/service/rover/templates/rank_board.html +107 -0
- package/service/rover/templates/rank_level_total.html +57 -0
- package/service/rover/templates/rank_matrix_total.html +66 -0
- package/service/rover/templates/rank_phantom_total.html +180 -0
- package/service/rover/templates/rank_slash_total.html +38 -0
- package/service/rover/trigger.py +98 -0
- package/service/rover/utils/__init__.py +0 -0
- package/service/rover/utils/boardcast/__init__.py +0 -0
- package/service/rover/utils/boardcast/models.py +15 -0
- package/service/rover/utils/cookie_manager/__init__.py +0 -0
- package/service/rover/utils/cookie_manager/qrlogin.py +24 -0
- package/service/rover/utils/database/__init__.py +0 -0
- package/service/rover/utils/database/base_models.py +1771 -0
- package/service/rover/utils/database/models.py +80 -0
- package/service/rover/utils/database/startup.py +60 -0
- package/service/rover/utils/default_bg/bg.jpg +0 -0
- package/service/rover/utils/download_resource/__init__.py +0 -0
- package/service/rover/utils/download_resource/download_core.py +164 -0
- package/service/rover/utils/download_resource/download_file.py +46 -0
- package/service/rover/utils/fonts/__init__.py +0 -0
- package/service/rover/utils/fonts/fonts.py +33 -0
- package/service/rover/utils/image/__init__.py +0 -0
- package/service/rover/utils/image/convert.py +51 -0
- package/service/rover/utils/image/image_tools.py +30 -0
- package/service/rover/utils/image/utils.py +70 -0
- package/service/rover/utils/plugins_config/__init__.py +0 -0
- package/service/rover/utils/plugins_config/gs_config.py +506 -0
- package/service/rover/utils/plugins_config/models.py +182 -0
- package/service/rover/web_app.py +3 -0
- package/service/rover/webconsole/__init__.py +0 -0
- package/service/rover/webconsole/mount_app.py +26 -0
- package/service/tools/migrate.py +351 -0
- package/service/tools/regression.py +87 -0
- package/service/tools/smoke.py +63 -0
- package/service/tools/update_plugins.py +212 -0
- package/service/tools/verify_actions.py +104 -0
- package/service/tools/verify_card.py +82 -0
- package/service/tools/wwdata.py +665 -0
- package/tests/compat.test.js +181 -0
- package/tests/imagebridge.test.js +58 -0
- package/tests/media.test.js +68 -0
- package/tests/native.test.js +127 -0
- package/tests/runtime.test.js +69 -0
- package/tests/service.test.js +48 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""游戏常识知识库: service/knowledge/ 下 jsonl 的加载、词法检索与世界书触发查询。
|
|
2
|
+
|
|
3
|
+
纯 stdlib、不依赖 rover 其它模块, wwdata 脚本可直接 import 复用。
|
|
4
|
+
存储粒度是"小节": {id: "<entry>#<n>", entry, title: "条目/小节", tags, source, triggers, content}
|
|
5
|
+
检索按小节返回; kb_get 传小节 id 取小节、传条目 id 取整条拼装。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
12
|
+
|
|
13
|
+
SERVICE = Path(__file__).resolve().parents[1]
|
|
14
|
+
KB_DIR = SERVICE / "knowledge"
|
|
15
|
+
CHAR_ALIAS = SERVICE / "data" / "XutheringWavesUID" / "alias" / "char_alias.json"
|
|
16
|
+
|
|
17
|
+
_CJK = re.compile(r"[一-鿿]")
|
|
18
|
+
TRIGGER_MIN_LEN = 2
|
|
19
|
+
# 过泛词不做自动注入触发(检索不受影响)
|
|
20
|
+
BROAD_TRIGGERS = {"声骸", "资源", "补给", "头像", "武器", "角色", "攻略", "活动", "任务", "成就", "套装"}
|
|
21
|
+
# 只有这些层参与自动注入; event/article/story 只可检索
|
|
22
|
+
INJECT_TIERS = {"core", "guide"}
|
|
23
|
+
LOOKUP_MAX_HITS = 2
|
|
24
|
+
SNIPPET_LEN = 160
|
|
25
|
+
INJECT_CONTENT_LEN = 600
|
|
26
|
+
|
|
27
|
+
# tags → 触发优先级; 数值大者先注入
|
|
28
|
+
_PRIORITY_TAGS = [
|
|
29
|
+
(10, ("共鸣者", "武器", "声骸", "合鸣效果")),
|
|
30
|
+
(8, ("角色攻略", "游戏机制", "名词注释")),
|
|
31
|
+
(7, ("游戏百科", "教程百科", "玩法攻略")),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
_state: Optional[Dict[str, Any]] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _canonical_chars() -> Set[str]:
|
|
38
|
+
try:
|
|
39
|
+
return set(json.loads(CHAR_ALIAS.read_text("utf-8")).keys())
|
|
40
|
+
except (OSError, ValueError):
|
|
41
|
+
return set()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _priority(rec: dict) -> int:
|
|
45
|
+
tags = set(rec.get("tags") or [])
|
|
46
|
+
for score, keys in _PRIORITY_TAGS:
|
|
47
|
+
if tags & set(keys):
|
|
48
|
+
return score
|
|
49
|
+
return 5
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _entry_title(sec: dict) -> str:
|
|
53
|
+
return str(sec.get("entry_title") or "").strip() or str(sec.get("title") or "").rsplit("/", 1)[0]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _load() -> Dict[str, Any]:
|
|
57
|
+
global _state
|
|
58
|
+
if _state is not None:
|
|
59
|
+
return _state
|
|
60
|
+
sections: Dict[str, dict] = {}
|
|
61
|
+
entry_secs: Dict[str, List[str]] = {}
|
|
62
|
+
for path in sorted(KB_DIR.rglob("*.jsonl")):
|
|
63
|
+
source_kind = path.parent.name if path.parent != KB_DIR else path.stem
|
|
64
|
+
for line in path.read_text("utf-8").splitlines():
|
|
65
|
+
try:
|
|
66
|
+
rec = json.loads(line)
|
|
67
|
+
except ValueError:
|
|
68
|
+
continue
|
|
69
|
+
if not rec.get("id") or not rec.get("content"):
|
|
70
|
+
continue
|
|
71
|
+
rec["_kind"] = source_kind
|
|
72
|
+
sections[rec["id"]] = rec
|
|
73
|
+
entry_secs.setdefault(rec.get("entry") or rec["id"], []).append(rec["id"])
|
|
74
|
+
|
|
75
|
+
chars = _canonical_chars()
|
|
76
|
+
# (触发词, 条目 id, 注入的小节 id, 优先级); 每条目同一词只登记最先声明它的小节
|
|
77
|
+
triggers: List[Tuple[str, str, str, int]] = []
|
|
78
|
+
for entry_id, sec_ids in entry_secs.items():
|
|
79
|
+
first = sections[sec_ids[0]]
|
|
80
|
+
if first.get("tier") and first["tier"] not in INJECT_TIERS:
|
|
81
|
+
continue
|
|
82
|
+
prio = int(first.get("priority") or _priority(first))
|
|
83
|
+
title = _entry_title(first)
|
|
84
|
+
word_sec: Dict[str, str] = {}
|
|
85
|
+
for sid in sec_ids:
|
|
86
|
+
for word in sections[sid].get("triggers") or []:
|
|
87
|
+
word_sec.setdefault(str(word).strip(), sid)
|
|
88
|
+
if title:
|
|
89
|
+
word_sec.setdefault(title, sec_ids[0])
|
|
90
|
+
# 标题里含角色正式名时(如"【WIKI】椿角色攻略"), 允许按角色名触发
|
|
91
|
+
for char_name in chars:
|
|
92
|
+
if char_name and char_name in title:
|
|
93
|
+
word_sec.setdefault(char_name, sec_ids[0])
|
|
94
|
+
for word, sid in word_sec.items():
|
|
95
|
+
if not word or not _CJK.search(word) or word in BROAD_TRIGGERS:
|
|
96
|
+
continue
|
|
97
|
+
if len(word) < TRIGGER_MIN_LEN and word not in chars:
|
|
98
|
+
continue
|
|
99
|
+
triggers.append((word, entry_id, sid, prio))
|
|
100
|
+
triggers.sort(key=lambda t: (-len(t[0]), -t[3]))
|
|
101
|
+
_state = {"sections": sections, "entries": entry_secs, "triggers": triggers}
|
|
102
|
+
return _state
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def reload() -> int:
|
|
106
|
+
global _state
|
|
107
|
+
_state = None
|
|
108
|
+
return len(_load()["sections"])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get(kid: str) -> Optional[dict]:
|
|
112
|
+
"""小节 id → 小节记录; 条目 id → 全部小节拼装的整条。"""
|
|
113
|
+
state = _load()
|
|
114
|
+
if kid in state["sections"]:
|
|
115
|
+
return {k: v for k, v in state["sections"][kid].items() if not k.startswith("_")}
|
|
116
|
+
sec_ids = state["entries"].get(kid)
|
|
117
|
+
if not sec_ids:
|
|
118
|
+
return None
|
|
119
|
+
first = state["sections"][sec_ids[0]]
|
|
120
|
+
parts = []
|
|
121
|
+
for sid in sec_ids:
|
|
122
|
+
sec = state["sections"][sid]
|
|
123
|
+
sec_name = str(sec.get("section_title") or "").strip() or str(sec.get("title") or "").rsplit("/", 1)[-1]
|
|
124
|
+
parts.append(f"## {sec_name}\n{sec['content']}")
|
|
125
|
+
return {
|
|
126
|
+
"id": kid,
|
|
127
|
+
"title": _entry_title(first),
|
|
128
|
+
"tags": first.get("tags") or [],
|
|
129
|
+
"source": first.get("source"),
|
|
130
|
+
"sections": [
|
|
131
|
+
{"id": sid, "title": state["sections"][sid].get("title")} for sid in sec_ids
|
|
132
|
+
],
|
|
133
|
+
"content": "\n\n".join(parts),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def lookup(text: str, exclude: Optional[Set[str]] = None,
|
|
138
|
+
limit: int = LOOKUP_MAX_HITS) -> List[Dict[str, Any]]:
|
|
139
|
+
"""世界书触发: 在一段话里扫触发词, 长词优先, 注入声明该触发词的小节。
|
|
140
|
+
|
|
141
|
+
exclude 是条目 id 集合; 已排除条目的同名条目一并跳过(多源同名词条只注入一次)。
|
|
142
|
+
"""
|
|
143
|
+
text = (text or "").strip()
|
|
144
|
+
if not text:
|
|
145
|
+
return []
|
|
146
|
+
exclude = exclude or set()
|
|
147
|
+
state = _load()
|
|
148
|
+
excluded_titles = set()
|
|
149
|
+
for entry_id in exclude:
|
|
150
|
+
sec_ids = state["entries"].get(entry_id)
|
|
151
|
+
if sec_ids:
|
|
152
|
+
excluded_titles.add(_entry_title(state["sections"][sec_ids[0]]))
|
|
153
|
+
hits: List[Dict[str, Any]] = []
|
|
154
|
+
seen_entries: Set[str] = set()
|
|
155
|
+
taken_words: List[str] = []
|
|
156
|
+
for word, entry_id, sec_id, _prio in state["triggers"]:
|
|
157
|
+
if entry_id in exclude or entry_id in seen_entries or word not in text:
|
|
158
|
+
continue
|
|
159
|
+
sec = state["sections"][sec_id]
|
|
160
|
+
if _entry_title(sec) in excluded_titles:
|
|
161
|
+
continue
|
|
162
|
+
if any(word in longer for longer in taken_words):
|
|
163
|
+
continue
|
|
164
|
+
content = sec["content"]
|
|
165
|
+
if len(content) > INJECT_CONTENT_LEN:
|
|
166
|
+
content = content[:INJECT_CONTENT_LEN] + f"…(截断, 整条 kb_get('{entry_id}'))"
|
|
167
|
+
hits.append({"id": sec["id"], "entry": entry_id, "title": sec.get("title"),
|
|
168
|
+
"trigger": word, "tags": sec.get("tags") or [], "content": content})
|
|
169
|
+
seen_entries.add(entry_id)
|
|
170
|
+
taken_words.append(word)
|
|
171
|
+
if len(hits) >= limit:
|
|
172
|
+
break
|
|
173
|
+
return hits
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _terms(q: str) -> List[str]:
|
|
177
|
+
parts = [p for p in re.split(r"[\s,,、/]+", (q or "").strip()) if p]
|
|
178
|
+
return parts or ([q.strip()] if q.strip() else [])
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def search(q: str, source: Optional[str] = None, tags: Optional[List[str]] = None,
|
|
182
|
+
limit: int = 5, full: bool = False) -> List[Dict[str, Any]]:
|
|
183
|
+
"""按小节词法检索: title/triggers/tags 命中权重高于 content, 多词全含加分。"""
|
|
184
|
+
terms = _terms(q)
|
|
185
|
+
if not terms:
|
|
186
|
+
return []
|
|
187
|
+
state = _load()
|
|
188
|
+
want_tags = set(tags or [])
|
|
189
|
+
scored: List[Tuple[float, dict]] = []
|
|
190
|
+
for rec in state["sections"].values():
|
|
191
|
+
if source and rec.get("_kind") != source:
|
|
192
|
+
continue
|
|
193
|
+
if want_tags and not (want_tags & set(rec.get("tags") or [])):
|
|
194
|
+
continue
|
|
195
|
+
title = str(rec.get("title") or "")
|
|
196
|
+
trig = " ".join(rec.get("triggers") or [])
|
|
197
|
+
tag_text = " ".join(rec.get("tags") or [])
|
|
198
|
+
content = rec["content"]
|
|
199
|
+
score = 0.0
|
|
200
|
+
matched = 0
|
|
201
|
+
for term in terms:
|
|
202
|
+
hit = False
|
|
203
|
+
if term == _entry_title(rec):
|
|
204
|
+
score += 100; hit = True
|
|
205
|
+
elif term in title:
|
|
206
|
+
score += 30; hit = True
|
|
207
|
+
if term in trig:
|
|
208
|
+
score += 25; hit = True
|
|
209
|
+
if term in tag_text:
|
|
210
|
+
score += 15; hit = True
|
|
211
|
+
occurrences = content.count(term)
|
|
212
|
+
if occurrences:
|
|
213
|
+
score += 5 * min(occurrences, 3); hit = True
|
|
214
|
+
if hit:
|
|
215
|
+
matched += 1
|
|
216
|
+
if not matched:
|
|
217
|
+
continue
|
|
218
|
+
if matched == len(terms) and len(terms) > 1:
|
|
219
|
+
score *= 1.5
|
|
220
|
+
scored.append((score, rec))
|
|
221
|
+
scored.sort(key=lambda x: -x[0])
|
|
222
|
+
out = []
|
|
223
|
+
for score, rec in scored[:limit]:
|
|
224
|
+
item = {"id": rec["id"], "entry": rec.get("entry"), "title": rec.get("title"),
|
|
225
|
+
"source": rec.get("source"), "kind": rec.get("_kind"),
|
|
226
|
+
"tags": rec.get("tags") or [], "score": round(score, 1)}
|
|
227
|
+
if full:
|
|
228
|
+
item["content"] = rec["content"]
|
|
229
|
+
else:
|
|
230
|
+
pos = min((rec["content"].find(t) for t in terms
|
|
231
|
+
if rec["content"].find(t) >= 0), default=0)
|
|
232
|
+
start = max(0, pos - 40)
|
|
233
|
+
item["snippet"] = rec["content"][start:start + SNIPPET_LEN].replace("\n", " ")
|
|
234
|
+
out.append(item)
|
|
235
|
+
return out
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""日志。标准 logging + success/trace 两个额外级别。"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
SUCCESS = 25
|
|
9
|
+
TRACE = 5
|
|
10
|
+
logging.addLevelName(SUCCESS, "SUCCESS")
|
|
11
|
+
logging.addLevelName(TRACE, "TRACE")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RoverLogger(logging.Logger):
|
|
15
|
+
def success(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
|
16
|
+
if self.isEnabledFor(SUCCESS):
|
|
17
|
+
self._log(SUCCESS, msg, args, **kwargs)
|
|
18
|
+
|
|
19
|
+
def trace(self, msg: Any, *args: Any, **kwargs: Any) -> None:
|
|
20
|
+
if self.isEnabledFor(TRACE):
|
|
21
|
+
self._log(TRACE, msg, args, **kwargs)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
logging.setLoggerClass(RoverLogger)
|
|
25
|
+
|
|
26
|
+
_COLORS = {
|
|
27
|
+
"TRACE": "\033[36m",
|
|
28
|
+
"DEBUG": "\033[36m",
|
|
29
|
+
"INFO": "\033[0m",
|
|
30
|
+
"SUCCESS": "\033[32m",
|
|
31
|
+
"WARNING": "\033[33m",
|
|
32
|
+
"ERROR": "\033[31m",
|
|
33
|
+
"CRITICAL": "\033[35m",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Formatter(logging.Formatter):
|
|
38
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
39
|
+
color = _COLORS.get(record.levelname, "")
|
|
40
|
+
plain = record.levelname
|
|
41
|
+
record.levelname = f"{color}{plain:<8}\033[0m"
|
|
42
|
+
try:
|
|
43
|
+
return super().format(record)
|
|
44
|
+
finally:
|
|
45
|
+
# 同一条记录还要交给文件处理器, 不能把颜色码留在上面
|
|
46
|
+
record.levelname = plain
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# 落盘日志的容量上限: 单个文件 2MB, 连备份最多 4 个文件
|
|
50
|
+
FILE_MAX_BYTES = 2 * 1024 * 1024
|
|
51
|
+
FILE_BACKUPS = 3
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _level() -> str:
|
|
55
|
+
try:
|
|
56
|
+
from rover.config import core_config
|
|
57
|
+
|
|
58
|
+
value = core_config.get_config("log_level")
|
|
59
|
+
except Exception:
|
|
60
|
+
value = ""
|
|
61
|
+
return str(os.getenv("ROVER_LOG_LEVEL") or value or "INFO").upper()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _file_handler() -> logging.Handler:
|
|
65
|
+
from logging.handlers import RotatingFileHandler
|
|
66
|
+
|
|
67
|
+
from rover.data_store import get_res_path
|
|
68
|
+
|
|
69
|
+
return RotatingFileHandler(
|
|
70
|
+
get_res_path("logs") / "service.log",
|
|
71
|
+
maxBytes=FILE_MAX_BYTES,
|
|
72
|
+
backupCount=FILE_BACKUPS,
|
|
73
|
+
encoding="utf-8",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _build() -> RoverLogger:
|
|
78
|
+
lg = logging.getLogger("rover")
|
|
79
|
+
if not isinstance(lg, RoverLogger):
|
|
80
|
+
# 本模块之前已有人建过同名 logger, 换成带 success/trace 的实现
|
|
81
|
+
lg.__class__ = RoverLogger
|
|
82
|
+
if lg.handlers:
|
|
83
|
+
return lg # type: ignore[return-value]
|
|
84
|
+
|
|
85
|
+
lg.setLevel(_level())
|
|
86
|
+
fmt = "%(asctime)s | %(levelname)s | %(message)s"
|
|
87
|
+
|
|
88
|
+
stdout = logging.StreamHandler(sys.stdout)
|
|
89
|
+
stdout.setFormatter(_Formatter(fmt, "%m-%d %H:%M:%S"))
|
|
90
|
+
lg.addHandler(stdout)
|
|
91
|
+
try:
|
|
92
|
+
handler = _file_handler()
|
|
93
|
+
# 文件里不要 ANSI 颜色码
|
|
94
|
+
handler.setFormatter(logging.Formatter(fmt, "%m-%d %H:%M:%S"))
|
|
95
|
+
lg.addHandler(handler)
|
|
96
|
+
except OSError:
|
|
97
|
+
pass
|
|
98
|
+
lg.propagate = False
|
|
99
|
+
return lg # type: ignore[return-value]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
logger: RoverLogger = _build()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""服务入口: python -m rover.main [--host 127.0.0.1] [--port 9777]"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
parser = argparse.ArgumentParser(description="鸣潮服务")
|
|
13
|
+
parser.add_argument("--host", default=os.getenv("ROVER_HOST", "127.0.0.1"))
|
|
14
|
+
parser.add_argument("--port", type=int, default=int(os.getenv("ROVER_PORT", "9777")))
|
|
15
|
+
args = parser.parse_args()
|
|
16
|
+
|
|
17
|
+
from rover.config import core_config
|
|
18
|
+
|
|
19
|
+
if str(core_config.get_config("PORT")) != str(args.port):
|
|
20
|
+
core_config.set_config("PORT", str(args.port))
|
|
21
|
+
if core_config.get_config("HOST") != args.host:
|
|
22
|
+
core_config.set_config("HOST", args.host)
|
|
23
|
+
|
|
24
|
+
import uvicorn
|
|
25
|
+
|
|
26
|
+
import rover.api # noqa: F401 注册路由
|
|
27
|
+
from rover.app_life import app
|
|
28
|
+
|
|
29
|
+
uvicorn.run(app, host=args.host, port=args.port, log_level="warning", access_log=False)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
main()
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""媒体落盘与 HTML 载体。渲染产物存文件、对外给 /api/media/<id> 链接。"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from stat import S_ISREG
|
|
9
|
+
from typing import Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from rover.data_store import get_res_path
|
|
12
|
+
from rover.logger import logger
|
|
13
|
+
|
|
14
|
+
MEDIA_DIR: Path = get_res_path() / "media"
|
|
15
|
+
|
|
16
|
+
# 刚落盘的产物在此秒数内不参与超限淘汰, 避免删掉前端正在加载的卡片
|
|
17
|
+
GRACE_SECONDS = 300
|
|
18
|
+
# 超限淘汰的目标水位
|
|
19
|
+
TARGET_RATIO = 0.8
|
|
20
|
+
# 累计写入量达到此值才做一次目录扫描清理
|
|
21
|
+
CHECK_BYTES = 32 * 1024 * 1024
|
|
22
|
+
# 访问时刷新 mtime 的最小间隔
|
|
23
|
+
TOUCH_INTERVAL = 60
|
|
24
|
+
|
|
25
|
+
_written_bytes = 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _max_bytes() -> int:
|
|
29
|
+
from rover.config import core_config
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
return int(core_config.get_config("media_max_mb") or 0) * 1024 * 1024
|
|
33
|
+
except Exception:
|
|
34
|
+
return 512 * 1024 * 1024
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class HtmlBytes(bytes):
|
|
38
|
+
"""HTML 渲染产物。是 bytes 子类, 因此能穿透插件内所有 `if img_bytes:` 判断。"""
|
|
39
|
+
|
|
40
|
+
html: str
|
|
41
|
+
template_name: str
|
|
42
|
+
|
|
43
|
+
def __new__(cls, html: str, template_name: str = ""):
|
|
44
|
+
obj = super().__new__(cls, html.encode("utf-8"))
|
|
45
|
+
obj.html = html
|
|
46
|
+
obj.template_name = template_name
|
|
47
|
+
return obj
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save_media(data: bytes, ext: str = "jpg") -> str:
|
|
51
|
+
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
digest = hashlib.sha1(data).hexdigest()[:20]
|
|
53
|
+
name = f"{digest}.{ext}"
|
|
54
|
+
path = MEDIA_DIR / name
|
|
55
|
+
if path.exists():
|
|
56
|
+
_touch(path)
|
|
57
|
+
else:
|
|
58
|
+
path.write_bytes(data)
|
|
59
|
+
_on_written(len(data))
|
|
60
|
+
return name
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def render_png(name: str) -> Optional[Path]:
|
|
64
|
+
"""把存好的 HTML 卡片用无头浏览器拍成 PNG, 按原始设计宽度出图, 结果缓存复用。"""
|
|
65
|
+
source = media_path(name)
|
|
66
|
+
if source is None or source.suffix != ".html":
|
|
67
|
+
return None
|
|
68
|
+
target = source.with_suffix(".png")
|
|
69
|
+
if target.is_file() and target.stat().st_mtime >= source.stat().st_mtime:
|
|
70
|
+
_touch(target)
|
|
71
|
+
return target
|
|
72
|
+
|
|
73
|
+
from playwright.async_api import async_playwright
|
|
74
|
+
|
|
75
|
+
async with async_playwright() as driver:
|
|
76
|
+
browser = await driver.chromium.launch(args=["--disable-gpu", "--disable-dev-shm-usage"])
|
|
77
|
+
try:
|
|
78
|
+
page = await browser.new_page(viewport={"width": 1600, "height": 1200})
|
|
79
|
+
await page.goto(source.as_uri(), wait_until="load")
|
|
80
|
+
# 桥接脚本没收到宿主宽度时不缩放, 量到的就是设计尺寸
|
|
81
|
+
await page.wait_for_timeout(600)
|
|
82
|
+
size = await page.evaluate(
|
|
83
|
+
"() => { const el = document.querySelector('.container') || document.body;"
|
|
84
|
+
" return { w: Math.ceil(el.scrollWidth), h: Math.ceil(el.scrollHeight) }; }"
|
|
85
|
+
)
|
|
86
|
+
width = max(1, min(int(size["w"]), 4000))
|
|
87
|
+
height = max(1, min(int(size["h"]), 30000))
|
|
88
|
+
await page.set_viewport_size({"width": width, "height": min(height, 2000)})
|
|
89
|
+
await page.wait_for_timeout(400)
|
|
90
|
+
data = await page.screenshot(
|
|
91
|
+
full_page=True, clip={"x": 0, "y": 0, "width": width, "height": height}
|
|
92
|
+
)
|
|
93
|
+
finally:
|
|
94
|
+
await browser.close()
|
|
95
|
+
|
|
96
|
+
target.write_bytes(data)
|
|
97
|
+
_on_written(len(data))
|
|
98
|
+
logger.info(f"[媒体] 卡片截图 {name} → {width}x{height} {len(data) // 1024}KB")
|
|
99
|
+
return target
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def media_url(name: str) -> str:
|
|
103
|
+
return f"/api/media/{name}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def media_path(name: str) -> Optional[Path]:
|
|
107
|
+
if "/" in name or "\\" in name or name.startswith("."):
|
|
108
|
+
return None
|
|
109
|
+
path = MEDIA_DIR / name
|
|
110
|
+
if not path.is_file():
|
|
111
|
+
return None
|
|
112
|
+
_touch(path)
|
|
113
|
+
return path
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _touch(path: Path) -> None:
|
|
117
|
+
"""刷新 mtime, 使淘汰顺序按最近使用时间。"""
|
|
118
|
+
try:
|
|
119
|
+
if time.time() - path.stat().st_mtime > TOUCH_INTERVAL:
|
|
120
|
+
os.utime(path, None)
|
|
121
|
+
except OSError:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _on_written(size: int) -> None:
|
|
126
|
+
global _written_bytes
|
|
127
|
+
_written_bytes += size
|
|
128
|
+
if _written_bytes < CHECK_BYTES:
|
|
129
|
+
return
|
|
130
|
+
_written_bytes = 0
|
|
131
|
+
try:
|
|
132
|
+
cleanup_media()
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.warning(f"[媒体清理] 写入触发的清理失败: {e}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _scan() -> Tuple[List[Tuple[float, int, Path]], int]:
|
|
138
|
+
entries: List[Tuple[float, int, Path]] = []
|
|
139
|
+
total = 0
|
|
140
|
+
try:
|
|
141
|
+
it = list(MEDIA_DIR.iterdir())
|
|
142
|
+
except OSError:
|
|
143
|
+
return entries, total
|
|
144
|
+
for path in it:
|
|
145
|
+
try:
|
|
146
|
+
st = path.stat()
|
|
147
|
+
except OSError:
|
|
148
|
+
continue
|
|
149
|
+
if not S_ISREG(st.st_mode):
|
|
150
|
+
continue
|
|
151
|
+
entries.append((st.st_mtime, st.st_size, path))
|
|
152
|
+
total += st.st_size
|
|
153
|
+
return entries, total
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def media_usage() -> Dict[str, float]:
|
|
157
|
+
if not MEDIA_DIR.is_dir():
|
|
158
|
+
return {"files": 0, "bytes": 0, "mb": 0.0}
|
|
159
|
+
files = 0
|
|
160
|
+
total = 0
|
|
161
|
+
for path in MEDIA_DIR.iterdir():
|
|
162
|
+
if path.is_file():
|
|
163
|
+
files += 1
|
|
164
|
+
total += path.stat().st_size
|
|
165
|
+
return {"files": files, "bytes": total, "mb": total / 1024 / 1024}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def cleanup_media() -> Dict[str, int]:
|
|
169
|
+
if not MEDIA_DIR.is_dir():
|
|
170
|
+
return {"removed": 0, "freed": 0, "remain": 0}
|
|
171
|
+
"""超容量上限时按最久未使用淘汰到目标水位; 不按时间删, 历史卡片还引用着。"""
|
|
172
|
+
now = time.time()
|
|
173
|
+
limit = _max_bytes()
|
|
174
|
+
entries, total = _scan()
|
|
175
|
+
removed = 0
|
|
176
|
+
freed = 0
|
|
177
|
+
alive: List[Tuple[float, int, Path]] = list(entries)
|
|
178
|
+
|
|
179
|
+
if limit > 0 and total > limit:
|
|
180
|
+
target = int(limit * TARGET_RATIO)
|
|
181
|
+
alive.sort(key=lambda item: item[0])
|
|
182
|
+
for mtime, size, path in alive:
|
|
183
|
+
if total <= target:
|
|
184
|
+
break
|
|
185
|
+
if now - mtime < GRACE_SECONDS:
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
path.unlink()
|
|
189
|
+
except OSError:
|
|
190
|
+
continue
|
|
191
|
+
removed += 1
|
|
192
|
+
freed += size
|
|
193
|
+
total -= size
|
|
194
|
+
|
|
195
|
+
if removed:
|
|
196
|
+
logger.info(
|
|
197
|
+
f"[媒体清理] 删除 {removed} 个, 释放 {freed / 1048576:.1f}MB, "
|
|
198
|
+
f"剩余 {total / 1048576:.1f}MB / 上限 {limit / 1048576:.0f}MB"
|
|
199
|
+
)
|
|
200
|
+
return {
|
|
201
|
+
"removed": removed,
|
|
202
|
+
"freed_bytes": freed,
|
|
203
|
+
"remaining_bytes": total,
|
|
204
|
+
"limit_bytes": limit,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def _scheduled_cleanup() -> None:
|
|
209
|
+
await asyncio.to_thread(cleanup_media)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def register_cleanup_job() -> None:
|
|
213
|
+
"""注册周期清理, 由 app_life 在调度器启动前调用。"""
|
|
214
|
+
from rover.aps import scheduler
|
|
215
|
+
|
|
216
|
+
scheduler.add_job(
|
|
217
|
+
_scheduled_cleanup,
|
|
218
|
+
"interval",
|
|
219
|
+
minutes=30,
|
|
220
|
+
id="media_cleanup",
|
|
221
|
+
replace_existing=True,
|
|
222
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from typing import List, Union, Literal, TypeVar, Optional
|
|
2
|
+
|
|
3
|
+
from msgspec import Struct
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Button(Struct):
|
|
7
|
+
text: str
|
|
8
|
+
data: str # 具体数据
|
|
9
|
+
pressed_text: Optional[str] = None # 按下之后显示的值
|
|
10
|
+
style: Literal[0, 1] = 1 # 0灰色线框,1蓝色线框
|
|
11
|
+
action: Literal[-1, 0, 1, 2] = -1 # 0跳转按钮,1回调按钮,2命令按钮, 【-1自适应】
|
|
12
|
+
permisson: Literal[0, 1, 2, 3] = 2 # 0指定用户,1管理者,2所有人可按,3指定身份组
|
|
13
|
+
specify_role_ids: List[str] = [] # 仅限频道可用
|
|
14
|
+
specify_user_ids: List[str] = [] # 指定用户
|
|
15
|
+
unsupport_tips: str = "您的客户端暂不支持该功能, 请升级后适配..."
|
|
16
|
+
prefix: str = "" # 命令前缀, 使用时请直接继承该类重写该值, 避免重复定义
|
|
17
|
+
_edited: bool = False # 是否编辑过
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T", bound=Button)
|
|
21
|
+
|
|
22
|
+
ButtonType = Union[List[str], List[T], List[List[str]], List[List[T]]]
|
|
23
|
+
ButtonList = Union[List[T], List[List[T]]]
|