@sophiacyl/dsh-plugin-waves 0.0.0-stage → 0.1.0-beta.2
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,720 @@
|
|
|
1
|
+
"""对外接口。前端与 dsh 侧都经这里下命令、取媒体与通知。"""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import json
|
|
6
|
+
import mimetypes
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from fastapi import HTTPException, Request
|
|
12
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
13
|
+
from msgspec import to_builtins
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from rover.api_render import render_segments, summarize
|
|
17
|
+
from rover.app_life import app
|
|
18
|
+
from rover.config import CONFIG_DEFAULT, core_config
|
|
19
|
+
from rover.handler import build_message, dispatch, normalize_command
|
|
20
|
+
from rover.logger import logger
|
|
21
|
+
from rover.media import cleanup_media, media_path, render_png
|
|
22
|
+
|
|
23
|
+
DEFAULT_GROUP = "dsh"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _default_user() -> str:
|
|
27
|
+
masters = core_config.get_config("masters") or []
|
|
28
|
+
return str(masters[0]) if masters else "dsh-user"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _local_image_url(image: str) -> str:
|
|
32
|
+
"""插件按 URL 取图, 上传来的 base64 先落盘再给回本机地址。"""
|
|
33
|
+
if not image.startswith("base64://"):
|
|
34
|
+
return image
|
|
35
|
+
import base64
|
|
36
|
+
|
|
37
|
+
from rover.api_render import _guess_ext
|
|
38
|
+
from rover.media import save_media
|
|
39
|
+
|
|
40
|
+
raw = base64.b64decode(image[len("base64://") :])
|
|
41
|
+
name = save_media(raw, _guess_ext(raw))
|
|
42
|
+
host = core_config.get_config("HOST") or "127.0.0.1"
|
|
43
|
+
if host in ("0.0.0.0", ""):
|
|
44
|
+
host = "127.0.0.1"
|
|
45
|
+
return f"http://{host}:{core_config.get_config('PORT')}/api/media/{name}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ChatRequest(BaseModel):
|
|
49
|
+
command: str
|
|
50
|
+
scene: str = "group"
|
|
51
|
+
session_id: str = "dsh"
|
|
52
|
+
user_id: Optional[str] = None
|
|
53
|
+
images: Optional[List[str]] = None
|
|
54
|
+
file_name: Optional[str] = None
|
|
55
|
+
file_data: Optional[str] = None
|
|
56
|
+
native: bool = False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.get("/api/health")
|
|
60
|
+
async def health() -> Dict[str, Any]:
|
|
61
|
+
from rover.sv import SL
|
|
62
|
+
|
|
63
|
+
from rover.sv import all_prefixes
|
|
64
|
+
|
|
65
|
+
from rover.app_life import _plugins_loaded, plugin_load_errors
|
|
66
|
+
|
|
67
|
+
commands = sum(len(t) for sv in SL.lst.values() for t in sv.TL.values())
|
|
68
|
+
return {
|
|
69
|
+
"ok": _plugins_loaded and not plugin_load_errors and commands > 0,
|
|
70
|
+
"plugins": list(SL.plugins.keys()),
|
|
71
|
+
"commands": commands,
|
|
72
|
+
"prefixes": all_prefixes(),
|
|
73
|
+
"load_errors": list(plugin_load_errors),
|
|
74
|
+
"resources_ready": bool(getattr(app.state, "resources_ready", False)),
|
|
75
|
+
"resource_error": getattr(app.state, "resource_error", None),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.post("/api/chat")
|
|
80
|
+
async def chat(req: ChatRequest) -> Dict[str, Any]:
|
|
81
|
+
command = normalize_command(req.command.strip())
|
|
82
|
+
if not command:
|
|
83
|
+
raise HTTPException(status_code=400, detail="命令为空")
|
|
84
|
+
|
|
85
|
+
user_id = req.user_id or _default_user()
|
|
86
|
+
user_type = "direct" if req.scene == "direct" else "group"
|
|
87
|
+
message = build_message(
|
|
88
|
+
command,
|
|
89
|
+
user_id=user_id,
|
|
90
|
+
group_id=DEFAULT_GROUP,
|
|
91
|
+
user_type=user_type,
|
|
92
|
+
images=[_local_image_url(i) for i in (req.images or [])],
|
|
93
|
+
file_name=req.file_name,
|
|
94
|
+
file_data=req.file_data,
|
|
95
|
+
)
|
|
96
|
+
logger.info(f"[命令] {command} (scene={user_type})")
|
|
97
|
+
result = await dispatch(message)
|
|
98
|
+
rendered = await render_segments(result["segments"])
|
|
99
|
+
if req.native:
|
|
100
|
+
from rover.native_media import native_segments
|
|
101
|
+
rendered = await native_segments(rendered)
|
|
102
|
+
|
|
103
|
+
if result["unmatched"]:
|
|
104
|
+
return {
|
|
105
|
+
"ok": False,
|
|
106
|
+
"summary": f"未匹配到命令: {command}。可用 ww帮助 查看全部命令。",
|
|
107
|
+
"matched": [],
|
|
108
|
+
"segments": [],
|
|
109
|
+
"elapsed": result["elapsed"],
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
"ok": True,
|
|
114
|
+
"summary": summarize(rendered) or "已执行",
|
|
115
|
+
"matched": result["matched"],
|
|
116
|
+
"segments": rendered,
|
|
117
|
+
"elapsed": round(result["elapsed"], 3),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class PanelRenderRequest(BaseModel):
|
|
122
|
+
char: str
|
|
123
|
+
uid: Optional[str] = None
|
|
124
|
+
echoes: Optional[List[Dict[str, Any]]] = None
|
|
125
|
+
change: Optional[str] = None
|
|
126
|
+
native: bool = False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.post("/api/render/panel")
|
|
130
|
+
async def render_panel(req: PanelRenderRequest) -> Dict[str, Any]:
|
|
131
|
+
"""自定义 ctx 重组面板。只渲染, 不落盘、不进总排行上传队列。"""
|
|
132
|
+
import time
|
|
133
|
+
|
|
134
|
+
from rover.custom_panel import render_custom_panel
|
|
135
|
+
|
|
136
|
+
started = time.perf_counter()
|
|
137
|
+
err, sink = await render_custom_panel(req.model_dump())
|
|
138
|
+
if err:
|
|
139
|
+
return {
|
|
140
|
+
"ok": False,
|
|
141
|
+
"summary": err,
|
|
142
|
+
"matched": [],
|
|
143
|
+
"segments": [],
|
|
144
|
+
"elapsed": round(time.perf_counter() - started, 3),
|
|
145
|
+
}
|
|
146
|
+
rendered = await render_segments(sink)
|
|
147
|
+
if req.native:
|
|
148
|
+
from rover.native_media import native_segments
|
|
149
|
+
rendered = await native_segments(rendered)
|
|
150
|
+
return {
|
|
151
|
+
"ok": True,
|
|
152
|
+
"summary": summarize(rendered) or "已渲染",
|
|
153
|
+
"matched": ["自定义面板"],
|
|
154
|
+
"segments": rendered,
|
|
155
|
+
"elapsed": round(time.perf_counter() - started, 3),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@app.get("/api/config/media")
|
|
160
|
+
async def media_config() -> Dict[str, Any]:
|
|
161
|
+
"""前端渲染要用的少量取值, 单独给一个轻接口。"""
|
|
162
|
+
return {
|
|
163
|
+
"image_portrait_max_percent": core_config.get_config("image_portrait_max_percent") or 50,
|
|
164
|
+
"image_landscape_max_percent": core_config.get_config("image_landscape_max_percent") or 75,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_ALIAS_INDEX: Optional[List[tuple]] = None
|
|
169
|
+
# 单字别名(角/春)误命中太多; 只认两字起
|
|
170
|
+
ALIAS_MIN_LEN = 2
|
|
171
|
+
ALIAS_MAX_HITS = 8
|
|
172
|
+
_CJK = re.compile(r"[一-鿿]")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _worth_matching(alias: str) -> bool:
|
|
176
|
+
"""纯英文/拼音缩写(bl、sh、cl)在正常英文里到处都是, 子串扫必然误报, 一律不收。"""
|
|
177
|
+
return bool(_CJK.search(alias))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _alias_index() -> List[tuple]:
|
|
181
|
+
"""(别名, 类别, 正式名) 一张表, 按别名长度倒序, 长的先命中。"""
|
|
182
|
+
global _ALIAS_INDEX
|
|
183
|
+
if _ALIAS_INDEX is not None:
|
|
184
|
+
return _ALIAS_INDEX
|
|
185
|
+
from rover.data_store import data_path
|
|
186
|
+
|
|
187
|
+
kinds = {
|
|
188
|
+
"char_alias.json": "角色",
|
|
189
|
+
"sonata_alias.json": "套装",
|
|
190
|
+
"echo_alias.json": "声骸",
|
|
191
|
+
"weapon_alias.json": "武器",
|
|
192
|
+
}
|
|
193
|
+
entries: List[tuple] = []
|
|
194
|
+
root = data_path / "XutheringWavesUID" / "alias"
|
|
195
|
+
for file_name, kind in kinds.items():
|
|
196
|
+
path = root / file_name
|
|
197
|
+
if not path.is_file():
|
|
198
|
+
continue
|
|
199
|
+
try:
|
|
200
|
+
table = json.loads(path.read_text("utf-8"))
|
|
201
|
+
except (OSError, ValueError):
|
|
202
|
+
continue
|
|
203
|
+
for canonical, aliases in table.items():
|
|
204
|
+
for alias in aliases if isinstance(aliases, list) else [aliases]:
|
|
205
|
+
alias = str(alias)
|
|
206
|
+
if len(alias) < ALIAS_MIN_LEN or alias == canonical:
|
|
207
|
+
continue
|
|
208
|
+
if not _worth_matching(alias):
|
|
209
|
+
continue
|
|
210
|
+
entries.append((alias, kind, canonical))
|
|
211
|
+
entries.sort(key=lambda e: -len(e[0]))
|
|
212
|
+
_ALIAS_INDEX = entries
|
|
213
|
+
return entries
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@app.get("/api/alias/lookup")
|
|
217
|
+
async def alias_lookup(text: str = "") -> Dict[str, Any]:
|
|
218
|
+
"""在一段话里找本地别名。只报命中, 不做判断, 调用方自己决定信不信。"""
|
|
219
|
+
lowered = (text or "").lower()
|
|
220
|
+
if not lowered:
|
|
221
|
+
return {"hits": []}
|
|
222
|
+
hits: List[Dict[str, str]] = []
|
|
223
|
+
seen_canonical = set()
|
|
224
|
+
taken: List[str] = []
|
|
225
|
+
for alias, kind, canonical in _alias_index():
|
|
226
|
+
if canonical in seen_canonical or alias.lower() not in lowered:
|
|
227
|
+
continue
|
|
228
|
+
# 正式名已经在原话里了, 再报一遍别名是废话
|
|
229
|
+
if canonical.lower() in lowered:
|
|
230
|
+
continue
|
|
231
|
+
# 已命中的更长别名包含了它, 就不再重复报
|
|
232
|
+
if any(alias in longer for longer in taken):
|
|
233
|
+
continue
|
|
234
|
+
taken.append(alias)
|
|
235
|
+
seen_canonical.add(canonical)
|
|
236
|
+
hits.append({"alias": alias, "kind": kind, "name": canonical})
|
|
237
|
+
if len(hits) >= ALIAS_MAX_HITS:
|
|
238
|
+
break
|
|
239
|
+
return {"hits": hits}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
_ATT_HEX = re.compile(r"^[0-9a-f]{64}$")
|
|
243
|
+
_ATT_MAGIC = {b"\x89PNG": "image/png", b"\xff\xd8\xff": "image/jpeg",
|
|
244
|
+
b"RIFF": "image/webp", b"GIF8": "image/gif"}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@app.get("/api/attachment/{sha}")
|
|
248
|
+
async def attachment_file(sha: str):
|
|
249
|
+
"""按内容哈希回吐 dsh 附件库里的图片, 给前端把剥图提示渲染回图用。"""
|
|
250
|
+
import os
|
|
251
|
+
|
|
252
|
+
if not _ATT_HEX.match(sha):
|
|
253
|
+
raise HTTPException(status_code=400, detail="不是合法的附件哈希")
|
|
254
|
+
home = Path(os.environ.get("DSH_HOME") or Path.home() / ".dsh")
|
|
255
|
+
path = home / "attachments" / "v1" / "objects" / sha[:2] / sha
|
|
256
|
+
if not path.is_file():
|
|
257
|
+
raise HTTPException(status_code=404, detail="附件不存在")
|
|
258
|
+
head = path.open("rb").read(4)
|
|
259
|
+
media_type = next((t for m, t in _ATT_MAGIC.items() if head.startswith(m)), "application/octet-stream")
|
|
260
|
+
return FileResponse(path, media_type=media_type, headers={"Cache-Control": "public, max-age=86400"})
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@app.get("/api/knowledge/search")
|
|
264
|
+
async def knowledge_search(
|
|
265
|
+
q: str = "", source: str = "", tags: str = "", limit: int = 5, full: int = 0
|
|
266
|
+
) -> Dict[str, Any]:
|
|
267
|
+
from rover import knowledge
|
|
268
|
+
|
|
269
|
+
items = knowledge.search(
|
|
270
|
+
q,
|
|
271
|
+
source=source or None,
|
|
272
|
+
tags=[t for t in tags.split(",") if t.strip()] or None,
|
|
273
|
+
limit=max(1, min(limit, 20)),
|
|
274
|
+
full=bool(full),
|
|
275
|
+
)
|
|
276
|
+
return {"total": len(items), "items": items}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@app.get("/api/knowledge/entry/{kid}")
|
|
280
|
+
async def knowledge_entry(kid: str) -> Dict[str, Any]:
|
|
281
|
+
from rover import knowledge
|
|
282
|
+
|
|
283
|
+
rec = knowledge.get(kid)
|
|
284
|
+
if rec is None:
|
|
285
|
+
raise HTTPException(status_code=404, detail=f"条目不存在: {kid}")
|
|
286
|
+
return {k: v for k, v in rec.items() if not k.startswith("_")}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@app.get("/api/knowledge/lookup")
|
|
290
|
+
async def knowledge_lookup(text: str = "", exclude: str = "", limit: int = 2) -> Dict[str, Any]:
|
|
291
|
+
from rover import knowledge
|
|
292
|
+
|
|
293
|
+
if core_config.get_config("knowledge_enhance") is False:
|
|
294
|
+
return {"hits": []}
|
|
295
|
+
hits = knowledge.lookup(
|
|
296
|
+
text,
|
|
297
|
+
exclude={e for e in exclude.split(",") if e.strip()},
|
|
298
|
+
limit=max(1, min(limit, 4)),
|
|
299
|
+
)
|
|
300
|
+
return {"hits": hits}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@app.get("/api/media/{name}/png")
|
|
304
|
+
async def media_png(name: str):
|
|
305
|
+
"""HTML 卡片的截图版, 按原始设计宽度渲染, 供前端"保存"按钮下载。"""
|
|
306
|
+
try:
|
|
307
|
+
path = await render_png(name)
|
|
308
|
+
except Exception as e:
|
|
309
|
+
logger.warning(f"[媒体] 截图失败 {name}: {e}")
|
|
310
|
+
raise HTTPException(status_code=500, detail=f"截图失败: {e}")
|
|
311
|
+
if path is None:
|
|
312
|
+
raise HTTPException(status_code=404, detail="卡片不存在或不是 HTML")
|
|
313
|
+
return FileResponse(path, media_type="image/png", filename=path.name)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@app.get("/api/media/{name}")
|
|
317
|
+
async def media(name: str):
|
|
318
|
+
path = media_path(name)
|
|
319
|
+
if path is None:
|
|
320
|
+
raise HTTPException(status_code=404, detail="媒体不存在")
|
|
321
|
+
media_type = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
|
322
|
+
headers = {"Cache-Control": "public, max-age=3600"}
|
|
323
|
+
if name.endswith(".html"):
|
|
324
|
+
headers["Content-Security-Policy"] = "sandbox allow-scripts allow-same-origin"
|
|
325
|
+
return FileResponse(path, media_type=media_type, headers=headers)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@app.get("/api/commands")
|
|
329
|
+
async def commands() -> Dict[str, Any]:
|
|
330
|
+
from rover.sv import AI_COMMANDS, SL
|
|
331
|
+
|
|
332
|
+
out = []
|
|
333
|
+
for sv in SL.lst.values():
|
|
334
|
+
for ttype, triggers in sv.TL.items():
|
|
335
|
+
for key, trigger in triggers.items():
|
|
336
|
+
out.append({"sv": sv.name, "type": ttype, "trigger": key, "block": trigger.block})
|
|
337
|
+
return {"total": len(out), "commands": out, "ai": AI_COMMANDS}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@app.get("/api/status")
|
|
341
|
+
async def status() -> Dict[str, Any]:
|
|
342
|
+
from rover.status.plugin_status import plugins_status
|
|
343
|
+
|
|
344
|
+
data: Dict[str, Any] = {}
|
|
345
|
+
for plugin_name, items in plugins_status.items():
|
|
346
|
+
# register_status 存的是 {icon: PIL.Image, status: {标签: 协程}}
|
|
347
|
+
# icon 不能进 JSON, 只跑指标函数
|
|
348
|
+
metrics: Dict[str, Any] = {}
|
|
349
|
+
raw = (items or {}).get("status") or {}
|
|
350
|
+
if not isinstance(raw, dict):
|
|
351
|
+
raw = {}
|
|
352
|
+
for label, func in raw.items():
|
|
353
|
+
try:
|
|
354
|
+
value = await func() if callable(func) else func
|
|
355
|
+
except Exception as e:
|
|
356
|
+
value = f"错误: {e}"
|
|
357
|
+
metrics[str(label)] = value
|
|
358
|
+
data[plugin_name] = metrics
|
|
359
|
+
return {"status": data}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@app.post("/api/cleanup")
|
|
363
|
+
async def cleanup() -> Dict[str, Any]:
|
|
364
|
+
return {"removed": cleanup_media()}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
CORE_GROUP = "服务配置"
|
|
368
|
+
|
|
369
|
+
# 服务自身配置没有 UI 元数据, 这里补类型与文案
|
|
370
|
+
CORE_META: Dict[str, tuple] = {
|
|
371
|
+
"expose_to_model": ("BoolConfig", "AI 可使用鸣潮插件", "作为工具暴露给模型, 占少量额外上下文; 只用 /ww 命令时可关闭省上下文"),
|
|
372
|
+
"knowledge_enhance": ("BoolConfig", "内置知识增强(测试)", "用户消息命中游戏常识关键词时自动附带内置知识条目; 关闭只停自动注入, AI 主动检索(kb)不受影响, 即时生效"),
|
|
373
|
+
"image_ocr": ("BoolConfig", "图片文字摘要(OCR)", "评分/面板等图片卡用本地轻量 OCR 抽出文字随摘要给模型, 纯文本模型也能读到卡上数值; 即时生效"),
|
|
374
|
+
"avatar": ("StrConfig", "头像", "卡片头部的头像: 直接上传图片, 或填 http(s) 链接 / QQ 号 / 本地路径; 置空用插件自带的"),
|
|
375
|
+
"HOST": ("StrConfig", "监听地址", "服务绑定的地址"),
|
|
376
|
+
"PORT": ("StrConfig", "监听端口", "服务监听的端口"),
|
|
377
|
+
"command_prefix": ("StrConfig", "命令前缀", "多个用逗号分隔; 置空则不需要前缀"),
|
|
378
|
+
"image_portrait_max_percent": ("IntConfig", "竖图最大宽度(%)", "高大于宽的图片, 最多占会话区宽度的百分比"),
|
|
379
|
+
"image_landscape_max_percent": ("IntConfig", "横图最大宽度(%)", "宽大于高的图片, 最多占会话区宽度的百分比"),
|
|
380
|
+
"media_max_mb": ("IntConfig", "产物目录上限(MB)", "超出时按最旧优先淘汰, 0 表示不限"),
|
|
381
|
+
"log_level": ("StrConfig", "日志级别", "日志同时写终端和 data/logs, 文件按 2MB 滚动、最多留 4 个"),
|
|
382
|
+
"sv": ("DictConfig", "功能项覆盖", '如 {"功能名": {"enabled": false, "pm": 0}}'),
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
# 有固定取值的服务配置
|
|
386
|
+
CORE_OPTIONS: Dict[str, List] = {
|
|
387
|
+
"log_level": ["TRACE", "DEBUG", "INFO", "SUCCESS", "WARNING", "ERROR"],
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
# 改了要重启才生效的服务配置
|
|
391
|
+
CORE_RESTART_KEYS = {"expose_to_model", "HOST", "PORT", "log_level"}
|
|
392
|
+
|
|
393
|
+
# 值是文件路径、设置页给上传入口的服务配置
|
|
394
|
+
CORE_UPLOAD_KEYS = {"avatar"}
|
|
395
|
+
|
|
396
|
+
# 有效但不适合放设置页的内部项(身份归属/超时细节/原始 JSON 覆盖)
|
|
397
|
+
CORE_HIDDEN = {"masters", "misfire_grace_time", "command_timeout", "sv", "seeded"}
|
|
398
|
+
|
|
399
|
+
# 整组隐藏: 本插件只用 SQLite, 数据库连接参数没有调整意义
|
|
400
|
+
HIDDEN_GROUPS = {"数据库配置"}
|
|
401
|
+
|
|
402
|
+
# 各插件里在本地场景无效/已被运行时接管的键, 不进设置页
|
|
403
|
+
PLUGIN_HIDDEN = {
|
|
404
|
+
"XutheringWavesUID": {
|
|
405
|
+
# 群排行已裁, 这些只被死路径读
|
|
406
|
+
"WavesRankUseTokenGroup", "WavesRankNoLimitGroup", "RankUseToken",
|
|
407
|
+
"GachaRankMin", "QQPicCache", "RankActiveFilterGroup",
|
|
408
|
+
# 聊天平台语义, 浏览器里无意义
|
|
409
|
+
"WavesTencentWord", "WavesQRLogin", "WavesLoginForward", "AtCheck",
|
|
410
|
+
"WavesOnlySelfCk", "MaxBindNum", "WavesLoginUrlSelf",
|
|
411
|
+
# 单用户无需请求限速
|
|
412
|
+
"RefreshInterval", "RefreshSingleCharInterval",
|
|
413
|
+
"RefreshIntervalNotify", "RefreshSingleCharIntervalNotify",
|
|
414
|
+
"UseGlobalSemaphore",
|
|
415
|
+
# 被运行时接管或强制
|
|
416
|
+
"UseHtmlRender", "RemoteRenderEnable", "RemoteRenderUrl", "FontCssUrl",
|
|
417
|
+
"HelpExtraModules",
|
|
418
|
+
"ActiveUserDays", "AnnActiveGroupDays", "WavesAnnOpen",
|
|
419
|
+
# 公告订阅已裁, 这两项只被公告推送读
|
|
420
|
+
"WavesAnnBBSSub", "AnnMinuteCheck",
|
|
421
|
+
# 上传审核已裁 / 上游注明暂无用
|
|
422
|
+
"WavesUploadAudit", "WavesUploadAuditKeepLocal",
|
|
423
|
+
"CaptchaProvider", "CaptchaAppKey", "WavesPanelEditGuestView",
|
|
424
|
+
},
|
|
425
|
+
"鸣潮展示配置": {
|
|
426
|
+
# 与同名上传项重复: 页面模板一律走上传, 不再手填路径
|
|
427
|
+
"LoginIndexHtmlPath", "LoginIndexEmailHtmlPath", "LoginIndexCloudHtmlPath",
|
|
428
|
+
"LoginIndexTokenHtmlPath", "Login404HtmlPath",
|
|
429
|
+
# 帮助已改为自绘 HTML, 列数不再生效
|
|
430
|
+
"HelpColumn",
|
|
431
|
+
},
|
|
432
|
+
"RoverSign": {
|
|
433
|
+
# 活跃筛选在本地无意义, 启动时固定关闭
|
|
434
|
+
"ActiveUserDays", "SignActiveUserOnly", "SigninMasterSkipInactive",
|
|
435
|
+
# 与查询侧同名重复, 由运行时统一读查询侧的值
|
|
436
|
+
"KuroUrlProxyUrl", "LocalProxyUrl", "NeedProxyFunc", "HideUid",
|
|
437
|
+
# 推送报告在本地无收发对象
|
|
438
|
+
"PrivateSignReport", "GroupSignReport", "GroupSignReportPic",
|
|
439
|
+
},
|
|
440
|
+
"ScoreEcho": {
|
|
441
|
+
# 与总排行同一个 token, 由运行时直接复用
|
|
442
|
+
"xwtoken",
|
|
443
|
+
# 别名文件内部路径
|
|
444
|
+
"localalias",
|
|
445
|
+
},
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
SECRET_KEY_RE = re.compile(r"token|password|passwd|secret|cookie|api_?key|密码|密钥", re.I)
|
|
449
|
+
# 形如 scheme://user:pass@host 的带账密代理地址
|
|
450
|
+
CREDENTIAL_URL_RE = re.compile(r"^\w+://[^/\s:@]+:[^/\s@]+@")
|
|
451
|
+
# 文件路径不是敏感值, 名字里带 token 也不打码
|
|
452
|
+
PATH_RE = re.compile(r"^(/|\./|\.\./|~|[A-Za-z]:[\\/])")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class ConfigSetRequest(BaseModel):
|
|
456
|
+
group: str
|
|
457
|
+
key: str
|
|
458
|
+
value: Any = None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _is_secret(key: str, title: str, flag: bool, value: Any) -> bool:
|
|
462
|
+
"""配置项自带 secret 标记, 或名字/值看着像凭据(开关与列表不算)。"""
|
|
463
|
+
if flag:
|
|
464
|
+
return True
|
|
465
|
+
if not isinstance(value, str) or PATH_RE.match(value):
|
|
466
|
+
return False
|
|
467
|
+
if SECRET_KEY_RE.search(key) or SECRET_KEY_RE.search(title):
|
|
468
|
+
return True
|
|
469
|
+
return bool(CREDENTIAL_URL_RE.match(value))
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _mask(value: Any) -> str:
|
|
473
|
+
if isinstance(value, str) and value:
|
|
474
|
+
return f"{value[:3]}***{value[-3:]}" if len(value) > 12 else "已设置"
|
|
475
|
+
return "已设置" if value else "未设置"
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _needs_restart(title: str, desc: str) -> bool:
|
|
479
|
+
text = f"{title}{desc}"
|
|
480
|
+
return "重启" in text or "重载" in text
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _view(
|
|
484
|
+
key: str,
|
|
485
|
+
itype: str,
|
|
486
|
+
title: str,
|
|
487
|
+
desc: str,
|
|
488
|
+
value: Any,
|
|
489
|
+
*,
|
|
490
|
+
options: Optional[List] = None,
|
|
491
|
+
secret_flag: bool = False,
|
|
492
|
+
needs_restart: bool = False,
|
|
493
|
+
) -> Dict[str, Any]:
|
|
494
|
+
secret = _is_secret(key, title, secret_flag, value)
|
|
495
|
+
item: Dict[str, Any] = {
|
|
496
|
+
"key": key,
|
|
497
|
+
"type": itype,
|
|
498
|
+
"title": title,
|
|
499
|
+
"desc": desc,
|
|
500
|
+
"value": _mask(value) if secret else value,
|
|
501
|
+
"needs_restart": needs_restart,
|
|
502
|
+
}
|
|
503
|
+
if options:
|
|
504
|
+
item["options"] = options
|
|
505
|
+
if secret:
|
|
506
|
+
item["secret"] = True
|
|
507
|
+
item["is_set"] = bool(value)
|
|
508
|
+
return item
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _coerce(itype: str, value: Any) -> Any:
|
|
512
|
+
"""把前端传来的值归一到配置项要求的 python 类型。"""
|
|
513
|
+
if itype == "BoolConfig":
|
|
514
|
+
if isinstance(value, str):
|
|
515
|
+
return value.strip().lower() in ("1", "true", "on", "yes")
|
|
516
|
+
return bool(value)
|
|
517
|
+
if itype == "IntConfig":
|
|
518
|
+
return int(value)
|
|
519
|
+
if itype == "FloatConfig":
|
|
520
|
+
return float(value)
|
|
521
|
+
if itype in ("StrConfig", "ColorConfig"):
|
|
522
|
+
return value if isinstance(value, str) else str(value)
|
|
523
|
+
if itype in ("ListStrConfig", "ListConfig"):
|
|
524
|
+
if isinstance(value, str):
|
|
525
|
+
value = [p.strip() for p in value.split(",") if p.strip()]
|
|
526
|
+
if not isinstance(value, list):
|
|
527
|
+
raise ValueError("需要列表")
|
|
528
|
+
return [int(v) for v in value] if itype == "ListConfig" else [str(v) for v in value]
|
|
529
|
+
if itype == "DictConfig":
|
|
530
|
+
if isinstance(value, str):
|
|
531
|
+
value = json.loads(value)
|
|
532
|
+
if not isinstance(value, dict):
|
|
533
|
+
raise ValueError("需要字典")
|
|
534
|
+
return value
|
|
535
|
+
return value
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _infer_type(value: Any) -> str:
|
|
539
|
+
if isinstance(value, bool):
|
|
540
|
+
return "BoolConfig"
|
|
541
|
+
if isinstance(value, int):
|
|
542
|
+
return "IntConfig"
|
|
543
|
+
if isinstance(value, float):
|
|
544
|
+
return "FloatConfig"
|
|
545
|
+
if isinstance(value, list):
|
|
546
|
+
return "ListStrConfig"
|
|
547
|
+
if isinstance(value, dict):
|
|
548
|
+
return "DictConfig"
|
|
549
|
+
return "StrConfig"
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _core_meta(key: str, value: Any) -> tuple:
|
|
553
|
+
"""没写进 CORE_META 的新增服务配置按值形态兜底。"""
|
|
554
|
+
return CORE_META.get(key) or (_infer_type(value), key, "")
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _core_view(key: str) -> Dict[str, Any]:
|
|
558
|
+
value = core_config.get_config(key)
|
|
559
|
+
itype, title, desc = _core_meta(key, value)
|
|
560
|
+
item = _view(
|
|
561
|
+
key,
|
|
562
|
+
itype,
|
|
563
|
+
title,
|
|
564
|
+
desc,
|
|
565
|
+
value,
|
|
566
|
+
options=CORE_OPTIONS.get(key),
|
|
567
|
+
needs_restart=key in CORE_RESTART_KEYS,
|
|
568
|
+
)
|
|
569
|
+
if key in CORE_UPLOAD_KEYS:
|
|
570
|
+
# 既能填路径/链接/QQ号, 也能直接传图
|
|
571
|
+
item["upload"] = {"suffix": "", "with_text": True, "exists": _looks_like_file(value)}
|
|
572
|
+
return item
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _looks_like_file(value: Any) -> bool:
|
|
576
|
+
return bool(value) and isinstance(value, str) and Path(value).is_file()
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _core_items() -> List[Dict[str, Any]]:
|
|
580
|
+
return [_core_view(key) for key in CONFIG_DEFAULT if key not in CORE_HIDDEN]
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
# 值是落盘文件的配置项: 设置页给上传控件, 不让手填路径
|
|
584
|
+
UPLOAD_TYPES = {"ImageConfig", "FileUploadConfig"}
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _plugin_item(key: str, item: Any) -> Dict[str, Any]:
|
|
588
|
+
title = getattr(item, "title", "") or key
|
|
589
|
+
desc = getattr(item, "desc", "") or ""
|
|
590
|
+
itype = type(item).__name__
|
|
591
|
+
view = _view(
|
|
592
|
+
key,
|
|
593
|
+
itype,
|
|
594
|
+
title,
|
|
595
|
+
desc,
|
|
596
|
+
to_builtins(getattr(item, "data", None)),
|
|
597
|
+
options=list(getattr(item, "options", None) or []),
|
|
598
|
+
secret_flag=bool(getattr(item, "secret", False)),
|
|
599
|
+
needs_restart=_needs_restart(title, desc),
|
|
600
|
+
)
|
|
601
|
+
if itype in UPLOAD_TYPES:
|
|
602
|
+
view["upload"] = {
|
|
603
|
+
"suffix": getattr(item, "suffix", "") or "",
|
|
604
|
+
"exists": Path(str(getattr(item, "data", "") or "")).is_file(),
|
|
605
|
+
}
|
|
606
|
+
return view
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
@app.get("/api/config")
|
|
610
|
+
async def get_config() -> Dict[str, Any]:
|
|
611
|
+
from rover.utils.plugins_config.gs_config import all_config_list
|
|
612
|
+
|
|
613
|
+
groups: List[Dict[str, Any]] = [{"name": CORE_GROUP, "items": _core_items()}]
|
|
614
|
+
for name, inst in all_config_list.items():
|
|
615
|
+
if name == CORE_GROUP or name in HIDDEN_GROUPS:
|
|
616
|
+
continue
|
|
617
|
+
hidden = PLUGIN_HIDDEN.get(name, set())
|
|
618
|
+
items = [_plugin_item(k, v) for k, v in inst.config.items() if k not in hidden]
|
|
619
|
+
groups.append({"name": name, "items": items})
|
|
620
|
+
return {"groups": groups}
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@app.post("/api/config")
|
|
624
|
+
async def set_config(req: ConfigSetRequest) -> Dict[str, Any]:
|
|
625
|
+
from rover.utils.plugins_config.gs_config import all_config_list
|
|
626
|
+
|
|
627
|
+
if req.group == CORE_GROUP:
|
|
628
|
+
if req.key not in CONFIG_DEFAULT:
|
|
629
|
+
raise HTTPException(status_code=404, detail=f"配置项不存在: {req.key}")
|
|
630
|
+
itype, title, desc = _core_meta(req.key, core_config.get_config(req.key))
|
|
631
|
+
try:
|
|
632
|
+
value = _coerce(itype, req.value)
|
|
633
|
+
except (TypeError, ValueError) as e:
|
|
634
|
+
raise HTTPException(status_code=400, detail=f"值格式不正确: {e}")
|
|
635
|
+
if not core_config.set_config(req.key, value):
|
|
636
|
+
raise HTTPException(status_code=400, detail="写入失败")
|
|
637
|
+
item = _core_view(req.key)
|
|
638
|
+
else:
|
|
639
|
+
inst = all_config_list.get(req.group)
|
|
640
|
+
if inst is None:
|
|
641
|
+
raise HTTPException(status_code=404, detail=f"配置分组不存在: {req.group}")
|
|
642
|
+
if req.key not in inst.config:
|
|
643
|
+
raise HTTPException(status_code=404, detail=f"配置项不存在: {req.key}")
|
|
644
|
+
current = inst.config[req.key]
|
|
645
|
+
try:
|
|
646
|
+
value = _coerce(type(current).__name__, req.value)
|
|
647
|
+
except (TypeError, ValueError) as e:
|
|
648
|
+
raise HTTPException(status_code=400, detail=f"值格式不正确: {e}")
|
|
649
|
+
max_value = getattr(current, "max_value", None)
|
|
650
|
+
if max_value is not None and isinstance(value, (int, float)) and value > max_value:
|
|
651
|
+
raise HTTPException(status_code=400, detail=f"超过最大值 {max_value}")
|
|
652
|
+
if not inst.set_config(req.key, value):
|
|
653
|
+
raise HTTPException(status_code=400, detail="写入失败, 类型不符")
|
|
654
|
+
item = _plugin_item(req.key, inst.config[req.key])
|
|
655
|
+
|
|
656
|
+
logger.info(f"[配置] {req.group}.{req.key} 已更新")
|
|
657
|
+
return {"ok": True, "group": req.group, "item": item, "needs_restart": item["needs_restart"]}
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
class ConfigUploadRequest(BaseModel):
|
|
661
|
+
group: str
|
|
662
|
+
key: str
|
|
663
|
+
data: str
|
|
664
|
+
"""文件内容, base64; 带不带 data: 前缀都收"""
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
@app.post("/api/config/upload")
|
|
668
|
+
async def upload_config_file(req: ConfigUploadRequest) -> Dict[str, Any]:
|
|
669
|
+
"""页面模板/图片一类配置项直接传文件, 落到写死的目标路径, 路径不暴露给前端改。"""
|
|
670
|
+
from rover.api_render import _guess_ext
|
|
671
|
+
from rover.data_store import data_path
|
|
672
|
+
from rover.utils.plugins_config.gs_config import all_config_list
|
|
673
|
+
|
|
674
|
+
payload = req.data.split(",", 1)[-1] if req.data.startswith("data:") else req.data
|
|
675
|
+
try:
|
|
676
|
+
raw = base64.b64decode(payload, validate=True)
|
|
677
|
+
except (binascii.Error, ValueError):
|
|
678
|
+
raise HTTPException(status_code=400, detail="文件内容不是合法的 base64")
|
|
679
|
+
if not raw:
|
|
680
|
+
raise HTTPException(status_code=400, detail="文件为空")
|
|
681
|
+
|
|
682
|
+
if req.group == CORE_GROUP:
|
|
683
|
+
if req.key not in CORE_UPLOAD_KEYS:
|
|
684
|
+
raise HTTPException(status_code=400, detail="该配置项不支持上传")
|
|
685
|
+
target = data_path / f"{req.key}.{_guess_ext(raw)}"
|
|
686
|
+
inst = None
|
|
687
|
+
else:
|
|
688
|
+
inst = all_config_list.get(req.group)
|
|
689
|
+
if inst is None or req.key not in inst.config:
|
|
690
|
+
raise HTTPException(status_code=404, detail=f"配置项不存在: {req.group}.{req.key}")
|
|
691
|
+
item = inst.config[req.key]
|
|
692
|
+
if type(item).__name__ not in UPLOAD_TYPES:
|
|
693
|
+
raise HTTPException(status_code=400, detail="该配置项不支持上传")
|
|
694
|
+
suffix = (getattr(item, "suffix", "") or "bin").lstrip(".")
|
|
695
|
+
target = (
|
|
696
|
+
Path(str(getattr(item, "upload_to", ""))) / f"{getattr(item, 'filename', req.key)}.{suffix}"
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
root = data_path.resolve()
|
|
700
|
+
try:
|
|
701
|
+
target.resolve().relative_to(root)
|
|
702
|
+
except ValueError:
|
|
703
|
+
raise HTTPException(status_code=400, detail="上传目标不在数据目录内")
|
|
704
|
+
|
|
705
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
706
|
+
target.write_bytes(raw)
|
|
707
|
+
logger.info(f"[配置] {req.group}.{req.key} 已上传 {len(raw)} 字节 → {target}")
|
|
708
|
+
if inst is None:
|
|
709
|
+
core_config.set_config(req.key, str(target))
|
|
710
|
+
view = _core_view(req.key)
|
|
711
|
+
else:
|
|
712
|
+
inst.set_config(req.key, str(target))
|
|
713
|
+
view = _plugin_item(req.key, inst.config[req.key])
|
|
714
|
+
return {"ok": True, "group": req.group, "item": view, "needs_restart": view["needs_restart"]}
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
@app.exception_handler(Exception)
|
|
718
|
+
async def on_error(request, exc: Exception):
|
|
719
|
+
logger.exception(exc)
|
|
720
|
+
return JSONResponse(status_code=500, content={"ok": False, "error": str(exc)})
|