entari-plugin-webui 1.0.0__py3-none-any.whl

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.
Files changed (170) hide show
  1. entari_plugin_webui/__init__.py +131 -0
  2. entari_plugin_webui/adapter.py +232 -0
  3. entari_plugin_webui/api/__init__.py +3 -0
  4. entari_plugin_webui/api/auth.py +107 -0
  5. entari_plugin_webui/api/config.py +39 -0
  6. entari_plugin_webui/api/deps.py +24 -0
  7. entari_plugin_webui/api/extensions.py +21 -0
  8. entari_plugin_webui/api/health.py +21 -0
  9. entari_plugin_webui/api/logs.py +40 -0
  10. entari_plugin_webui/api/market.py +63 -0
  11. entari_plugin_webui/api/menus.py +29 -0
  12. entari_plugin_webui/api/plugins.py +51 -0
  13. entari_plugin_webui/api/router.py +101 -0
  14. entari_plugin_webui/api/stats.py +13 -0
  15. entari_plugin_webui/config.py +16 -0
  16. entari_plugin_webui/core/__init__.py +1 -0
  17. entari_plugin_webui/core/audit.py +8 -0
  18. entari_plugin_webui/core/error.py +58 -0
  19. entari_plugin_webui/core/extension.py +134 -0
  20. entari_plugin_webui/core/i18n.py +21 -0
  21. entari_plugin_webui/core/log_stream.py +71 -0
  22. entari_plugin_webui/core/permissions.py +17 -0
  23. entari_plugin_webui/core/security.py +89 -0
  24. entari_plugin_webui/core/session.py +55 -0
  25. entari_plugin_webui/models/__init__.py +1 -0
  26. entari_plugin_webui/models/stats.py +13 -0
  27. entari_plugin_webui/services/__init__.py +1 -0
  28. entari_plugin_webui/services/config_service.py +180 -0
  29. entari_plugin_webui/services/market_service.py +181 -0
  30. entari_plugin_webui/services/package_manager.py +113 -0
  31. entari_plugin_webui/services/plugin_service.py +96 -0
  32. entari_plugin_webui/services/stats_service.py +80 -0
  33. entari_plugin_webui/static/.gitkeep +0 -0
  34. entari_plugin_webui/static/frontend/assets/Chat-CaGJsFaQ.css +1 -0
  35. entari_plugin_webui/static/frontend/assets/Chat-CtSM30GL.js +3 -0
  36. entari_plugin_webui/static/frontend/assets/Dashboard-DWOzkbJ2.css +1 -0
  37. entari_plugin_webui/static/frontend/assets/Dashboard-VhlTN5Pp.js +1 -0
  38. entari_plugin_webui/static/frontend/assets/ExtensionPage-CqCQibPd.js +1 -0
  39. entari_plugin_webui/static/frontend/assets/ExtensionPage-jLULI21o.css +1 -0
  40. entari_plugin_webui/static/frontend/assets/Login-DWplnA3S.js +1 -0
  41. entari_plugin_webui/static/frontend/assets/Login-Z9pRTEPM.css +1 -0
  42. entari_plugin_webui/static/frontend/assets/Logs-ZKt_DKBY.js +3 -0
  43. entari_plugin_webui/static/frontend/assets/Logs-woONf-h0.css +1 -0
  44. entari_plugin_webui/static/frontend/assets/Market-CBFEZqhH.css +1 -0
  45. entari_plugin_webui/static/frontend/assets/Market-DRJ1QS_i.js +1 -0
  46. entari_plugin_webui/static/frontend/assets/Settings-BMb0Nxa4.js +944 -0
  47. entari_plugin_webui/static/frontend/assets/Settings-DZ6dtk9x.css +1 -0
  48. entari_plugin_webui/static/frontend/assets/abap-DRC6TkPh.js +6 -0
  49. entari_plugin_webui/static/frontend/assets/apex-BuapDI9Y.js +6 -0
  50. entari_plugin_webui/static/frontend/assets/azcli-BypH-vXm.js +6 -0
  51. entari_plugin_webui/static/frontend/assets/bat-BY6pwuIY.js +6 -0
  52. entari_plugin_webui/static/frontend/assets/bicep-gRuQeaLk.js +7 -0
  53. entari_plugin_webui/static/frontend/assets/c-BIGW1oBm.js +1 -0
  54. entari_plugin_webui/static/frontend/assets/cameligo-ul-Lp4lw.js +6 -0
  55. entari_plugin_webui/static/frontend/assets/clojure-DeYg-96x.js +6 -0
  56. entari_plugin_webui/static/frontend/assets/codicon-DCmgc-ay.ttf +0 -0
  57. entari_plugin_webui/static/frontend/assets/coffee-CfnpWUYo.js +6 -0
  58. entari_plugin_webui/static/frontend/assets/cpp-C9L3yaDO.js +6 -0
  59. entari_plugin_webui/static/frontend/assets/cpp-DIPi6g--.js +1 -0
  60. entari_plugin_webui/static/frontend/assets/csharp-DWGz5Zuj.js +6 -0
  61. entari_plugin_webui/static/frontend/assets/csp-DrRCxMg5.js +6 -0
  62. entari_plugin_webui/static/frontend/assets/css-BfLuTCmN.js +8 -0
  63. entari_plugin_webui/static/frontend/assets/css-CLj8gQPS.js +1 -0
  64. entari_plugin_webui/static/frontend/assets/cssMode-Bb6LZ5kP.js +9 -0
  65. entari_plugin_webui/static/frontend/assets/cypher-DoFvH58O.js +6 -0
  66. entari_plugin_webui/static/frontend/assets/dart-DIovg4uR.js +6 -0
  67. entari_plugin_webui/static/frontend/assets/docker-BcOcwvcX.js +1 -0
  68. entari_plugin_webui/static/frontend/assets/dockerfile-D2PfwrvU.js +6 -0
  69. entari_plugin_webui/static/frontend/assets/ecl-C_scCXcs.js +6 -0
  70. entari_plugin_webui/static/frontend/assets/editor.worker-Z-F9bRfX.js +11 -0
  71. entari_plugin_webui/static/frontend/assets/elixir-BRk-K-rg.js +6 -0
  72. entari_plugin_webui/static/frontend/assets/flow9-DLs3tTet.js +6 -0
  73. entari_plugin_webui/static/frontend/assets/freemarker2-C6tiMqiu.js +8 -0
  74. entari_plugin_webui/static/frontend/assets/fsharp-D0UiDa5C.js +6 -0
  75. entari_plugin_webui/static/frontend/assets/go-C27-OAKa.js +1 -0
  76. entari_plugin_webui/static/frontend/assets/go-CyVeKkvQ.js +6 -0
  77. entari_plugin_webui/static/frontend/assets/graphql-BygKL3ZF.js +6 -0
  78. entari_plugin_webui/static/frontend/assets/handlebars-BaFdx4N6.js +6 -0
  79. entari_plugin_webui/static/frontend/assets/hcl-D_OY6ada.js +6 -0
  80. entari_plugin_webui/static/frontend/assets/html-kcteW3br.js +6 -0
  81. entari_plugin_webui/static/frontend/assets/html-pp8916En.js +1 -0
  82. entari_plugin_webui/static/frontend/assets/htmlMode-DX-hmsgL.js +9 -0
  83. entari_plugin_webui/static/frontend/assets/index-CNTptdPF.js +178 -0
  84. entari_plugin_webui/static/frontend/assets/index-DGqFI5c2.css +1 -0
  85. entari_plugin_webui/static/frontend/assets/ini-BEwlwnbL.js +1 -0
  86. entari_plugin_webui/static/frontend/assets/ini-BTpWsGps.js +6 -0
  87. entari_plugin_webui/static/frontend/assets/java-3TATJI7h.js +6 -0
  88. entari_plugin_webui/static/frontend/assets/java-CylS5w8V.js +1 -0
  89. entari_plugin_webui/static/frontend/assets/javascript-BmsOxqGW.js +6 -0
  90. entari_plugin_webui/static/frontend/assets/javascript-wDzz0qaB.js +1 -0
  91. entari_plugin_webui/static/frontend/assets/json-Cp-IABpG.js +1 -0
  92. entari_plugin_webui/static/frontend/assets/json.worker-aYIWSs1k.js +48 -0
  93. entari_plugin_webui/static/frontend/assets/jsonMode-DKWQYMua.js +15 -0
  94. entari_plugin_webui/static/frontend/assets/jsx-g9-lgVsj.js +1 -0
  95. entari_plugin_webui/static/frontend/assets/julia-DDpSJMW6.js +6 -0
  96. entari_plugin_webui/static/frontend/assets/kotlin-DVYH6Lj_.js +6 -0
  97. entari_plugin_webui/static/frontend/assets/less-CuFlys0T.js +7 -0
  98. entari_plugin_webui/static/frontend/assets/lexon-m09vb5r-.js +6 -0
  99. entari_plugin_webui/static/frontend/assets/liquid-u0eWJ0Zm.js +6 -0
  100. entari_plugin_webui/static/frontend/assets/logo-BLRhia6f.svg +71 -0
  101. entari_plugin_webui/static/frontend/assets/lua-D2Z7JJdl.js +6 -0
  102. entari_plugin_webui/static/frontend/assets/m3-B2Cf9XSq.js +6 -0
  103. entari_plugin_webui/static/frontend/assets/markdown-BXYnMxBe.js +6 -0
  104. entari_plugin_webui/static/frontend/assets/markdown-Cvjx9yec.js +1 -0
  105. entari_plugin_webui/static/frontend/assets/mdx-2iLb9JPW.js +6 -0
  106. entari_plugin_webui/static/frontend/assets/mips-Ckkbw-AO.js +6 -0
  107. entari_plugin_webui/static/frontend/assets/msdax-B5uW3Zvf.js +6 -0
  108. entari_plugin_webui/static/frontend/assets/mysql-B8ssZoUh.js +6 -0
  109. entari_plugin_webui/static/frontend/assets/objective-c-CrrKwR0a.js +6 -0
  110. entari_plugin_webui/static/frontend/assets/pascal-BWBTHuhh.js +6 -0
  111. entari_plugin_webui/static/frontend/assets/pascaligo-BGLI1Hdo.js +6 -0
  112. entari_plugin_webui/static/frontend/assets/perl-DDrv2Hr-.js +6 -0
  113. entari_plugin_webui/static/frontend/assets/pgsql-DLPipH_Q.js +6 -0
  114. entari_plugin_webui/static/frontend/assets/php-CTNlIIiR.js +6 -0
  115. entari_plugin_webui/static/frontend/assets/pla-2oJWbEOo.js +6 -0
  116. entari_plugin_webui/static/frontend/assets/postiats-DOk3G3cc.js +6 -0
  117. entari_plugin_webui/static/frontend/assets/powerquery-Dgyr3wWZ.js +6 -0
  118. entari_plugin_webui/static/frontend/assets/powershell-B_i9asfM.js +6 -0
  119. entari_plugin_webui/static/frontend/assets/powershell-Dpen1YoG.js +1 -0
  120. entari_plugin_webui/static/frontend/assets/protobuf-CV9EbfTh.js +7 -0
  121. entari_plugin_webui/static/frontend/assets/pug-CCBS_C5_.js +6 -0
  122. entari_plugin_webui/static/frontend/assets/python-B6aJPvgy.js +1 -0
  123. entari_plugin_webui/static/frontend/assets/python-DRt4FMw4.js +6 -0
  124. entari_plugin_webui/static/frontend/assets/qsharp-BLuZWbUW.js +6 -0
  125. entari_plugin_webui/static/frontend/assets/r-CzF1MCbP.js +6 -0
  126. entari_plugin_webui/static/frontend/assets/razor-BhGEOgFX.js +6 -0
  127. entari_plugin_webui/static/frontend/assets/redis-C75U4IDy.js +6 -0
  128. entari_plugin_webui/static/frontend/assets/redshift-Bc5xkKR1.js +6 -0
  129. entari_plugin_webui/static/frontend/assets/restructuredtext-DmdQbaLT.js +6 -0
  130. entari_plugin_webui/static/frontend/assets/ruby-DB0RB20n.js +6 -0
  131. entari_plugin_webui/static/frontend/assets/rust-B1yitclQ.js +1 -0
  132. entari_plugin_webui/static/frontend/assets/rust-UMmp-gVE.js +6 -0
  133. entari_plugin_webui/static/frontend/assets/sb-DVG02705.js +6 -0
  134. entari_plugin_webui/static/frontend/assets/scala-DvSxYeG4.js +6 -0
  135. entari_plugin_webui/static/frontend/assets/scheme-yf5bffbF.js +6 -0
  136. entari_plugin_webui/static/frontend/assets/scss-Bzb7OGdO.js +8 -0
  137. entari_plugin_webui/static/frontend/assets/shell-CEILq0vU.js +1 -0
  138. entari_plugin_webui/static/frontend/assets/shell-FNqbgIOG.js +6 -0
  139. entari_plugin_webui/static/frontend/assets/solidity-DyKutqhl.js +6 -0
  140. entari_plugin_webui/static/frontend/assets/sophia-B4VqtPa2.js +6 -0
  141. entari_plugin_webui/static/frontend/assets/sparql-B7alP455.js +6 -0
  142. entari_plugin_webui/static/frontend/assets/sql-CRqJ_cUM.js +1 -0
  143. entari_plugin_webui/static/frontend/assets/sql-D7lU1fdU.js +6 -0
  144. entari_plugin_webui/static/frontend/assets/st-VuadG5SK.js +6 -0
  145. entari_plugin_webui/static/frontend/assets/swift-BYtUz8ZP.js +8 -0
  146. entari_plugin_webui/static/frontend/assets/systemverilog-DOAuugfS.js +6 -0
  147. entari_plugin_webui/static/frontend/assets/tcl-CXKOl_mN.js +6 -0
  148. entari_plugin_webui/static/frontend/assets/toml-vGWfd6FD.js +1 -0
  149. entari_plugin_webui/static/frontend/assets/tsMode-D_fzWOEs.js +16 -0
  150. entari_plugin_webui/static/frontend/assets/tsx-COt5Ahok.js +1 -0
  151. entari_plugin_webui/static/frontend/assets/twig-D9yiNO04.js +6 -0
  152. entari_plugin_webui/static/frontend/assets/typescript-BPQ3VLAy.js +1 -0
  153. entari_plugin_webui/static/frontend/assets/typescript-C809Del3.js +6 -0
  154. entari_plugin_webui/static/frontend/assets/typespec-BupSXVCO.js +6 -0
  155. entari_plugin_webui/static/frontend/assets/vb-ZlaFEk-P.js +6 -0
  156. entari_plugin_webui/static/frontend/assets/vue-DMJtu8ND.js +1 -0
  157. entari_plugin_webui/static/frontend/assets/wgsl-B-lZjTdr.js +303 -0
  158. entari_plugin_webui/static/frontend/assets/xml-CRM7UJ29.js +6 -0
  159. entari_plugin_webui/static/frontend/assets/xml-sdJ4AIDG.js +1 -0
  160. entari_plugin_webui/static/frontend/assets/yaml-Buea-lGh.js +1 -0
  161. entari_plugin_webui/static/frontend/assets/yaml-C3lZ9fhp.js +6 -0
  162. entari_plugin_webui/static/frontend/favicon.ico +0 -0
  163. entari_plugin_webui/static/frontend/favicon.svg +71 -0
  164. entari_plugin_webui/static/frontend/index.html +3 -0
  165. entari_plugin_webui/static/marketplace.json +74 -0
  166. entari_plugin_webui/utils.py +3 -0
  167. entari_plugin_webui-1.0.0.dist-info/METADATA +131 -0
  168. entari_plugin_webui-1.0.0.dist-info/RECORD +170 -0
  169. entari_plugin_webui-1.0.0.dist-info/WHEEL +4 -0
  170. entari_plugin_webui-1.0.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,131 @@
1
+ """Entari Plugin: WebUI 服务(重设计版)"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from arclet.entari import plugin
8
+ from arclet.entari.event.lifespan import Startup
9
+ from arclet.entari.event.send import SendResponse
10
+ from arclet.entari.plugin import PluginRole, plugin_config
11
+ from entari_plugin_server import add_route, add_websocket_route, replace_asgi, server
12
+ from fastapi import Request
13
+ from fastapi.staticfiles import StaticFiles
14
+ from starlette.responses import FileResponse, Response
15
+
16
+ from .config import Config
17
+ from .core.extension import MenuItem as MenuItem
18
+ from .core.extension import WebUIExtension as WebUIExtension
19
+ from .core.extension import get_all_extension_routes
20
+ from .core.extension import webui_extend as webui_extend
21
+ from .core.security import (
22
+ LoginThrottle,
23
+ generate_random_password,
24
+ hash_password,
25
+ is_local_deployment,
26
+ parse_rate_limit,
27
+ set_local_mode,
28
+ )
29
+ from .core.session import SessionStore
30
+ from .utils import logger
31
+
32
+ __version__ = "1.0.0"
33
+ _STATIC_DIR = Path(__file__).parent / "static"
34
+ _FRONTEND_DIR = _STATIC_DIR / "frontend"
35
+
36
+ webui_config = plugin_config(Config, bind=True)
37
+
38
+ from .adapter import WebUIAdapter
39
+ from .api import create_app as _create_app # noqa: E402
40
+ from .api import logs # noqa: F401
41
+ from .models.stats import MessageStat # noqa: F401
42
+ from .services.stats_service import increment # noqa: PLC0415
43
+
44
+ _session_store: SessionStore = SessionStore(ttl=webui_config.session_ttl)
45
+ _login_throttle = LoginThrottle(*parse_rate_limit(webui_config.login_rate_limit))
46
+
47
+ if not server.path:
48
+ logger.warning("未检测到 Server 插件的 path 配置,WebUI 可能无法正常工作")
49
+ logger.warning("已自动设置 Server 插件的 path 为 /satori")
50
+ server.path = "/satori"
51
+
52
+ server.apply(WebUIAdapter())
53
+
54
+
55
+ plugin.metadata(
56
+ "WebUI 服务",
57
+ PluginRole.UTILITY,
58
+ [{"name": "RF-Tar-Railt", "email": "rf_tar_railt@qq.com"}],
59
+ __version__,
60
+ description="基于 Vite + Vue 3 + Element Plus 的可视化管理面板",
61
+ urls={"homepage": "https://github.com/ArcletProject/entari-plugin-webui"},
62
+ config=Config,
63
+ )
64
+
65
+
66
+ # ---------- SPA fallback handlers ----------
67
+ async def _root(request: Request) -> Response:
68
+ if not _FRONTEND_DIR.exists():
69
+ return Response(
70
+ content="Frontend not built. Run 'pdm run build-frontend'.",
71
+ status_code=503,
72
+ )
73
+ path = request.url.path.lstrip("/")
74
+ file = _FRONTEND_DIR / (path or "index.html")
75
+ if file.exists():
76
+ return FileResponse(file)
77
+ return Response(status_code=404)
78
+
79
+
80
+ replace_asgi(app := _create_app())
81
+
82
+ if _FRONTEND_DIR.exists() and (_FRONTEND_DIR / "assets").exists():
83
+ app.mount("/assets", StaticFiles(directory=_FRONTEND_DIR / "assets", html=True))
84
+
85
+ add_route("/", methods=["GET"], include_in_schema=False)(_root)
86
+ add_route("/favicon.ico", methods=["GET"], include_in_schema=False)(_root)
87
+ add_route("/favicon.svg", methods=["GET"], include_in_schema=False)(_root)
88
+
89
+
90
+ # ---------- SendResponse listener (message counting) ----------
91
+ @plugin.listen(SendResponse)
92
+ async def _on_message_sent(event: SendResponse) -> None:
93
+ platform = event.account.platform
94
+ try:
95
+ await increment(platform)
96
+ except Exception: # noqa: BLE001
97
+ logger.debug("消息计数失败")
98
+
99
+
100
+ # ---------- Startup listener ----------
101
+ @plugin.listen(Startup, priority=100)
102
+ async def _on_startup() -> None:
103
+ is_local = is_local_deployment(server.host)
104
+ set_local_mode(is_local)
105
+ if is_local:
106
+ logger.info("本地部署模式,无需认证")
107
+ else:
108
+ logger.info("远程部署模式,需要认证")
109
+ if not webui_config.password:
110
+ raw = generate_random_password(16)
111
+ webui_config.password = hash_password(raw)
112
+ logger.warning("已生成管理员密码:" + raw)
113
+
114
+ import arclet.entari.logger as entari_log
115
+ from loguru import logger as loguru_logger
116
+
117
+ from .core.log_stream import LogWriter, get_log_buffer
118
+
119
+ log_buffer = get_log_buffer()
120
+ loguru_logger.add(
121
+ LogWriter(log_buffer), level=0, diagnose=True, backtrace=True, colorize=True, filter=entari_log.default_filter
122
+ ) # type: ignore[call-overload]
123
+
124
+ routes, ws_routes = get_all_extension_routes()
125
+ for r in routes:
126
+ add_route(r.path, methods=r.methods)(r.handler)
127
+ for w in ws_routes:
128
+ add_websocket_route(w.path)(w.handler)
129
+
130
+ host = server.host or "127.0.0.1"
131
+ logger.info(f"管理面板已启动: http://{host}:{server.port}/")
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from datetime import datetime
5
+ from secrets import token_hex
6
+
7
+ from launart import Launart, any_completed
8
+ from launart.status import Phase
9
+ from satori import Api, Event, EventType, LoginStatus
10
+ from satori.exception import ActionFailed
11
+ from satori.model import Channel, ChannelType, Login, MessageObject, User
12
+ from satori.parser import Element, parse
13
+ from satori.server import Request
14
+ from satori.server.adapter import Adapter as BaseAdapter
15
+ from satori.server.route import MessageParam
16
+ from satori.utils import decode, encode
17
+ from starlette.responses import JSONResponse, Response
18
+ from starlette.routing import WebSocketRoute
19
+ from starlette.websockets import WebSocket
20
+
21
+ from .core.security import is_local_mode
22
+
23
+
24
+ def serialize_element(element: Element):
25
+ return {
26
+ "type": element.type,
27
+ "attrs": element.attrs,
28
+ "children": [serialize_element(child) for child in element.children],
29
+ }
30
+
31
+
32
+ def deserialize_element(data: dict) -> Element:
33
+ return Element(
34
+ data["type"],
35
+ data.get("attrs", {}),
36
+ *(deserialize_element(child) for child in data.get("children", [])),
37
+ )
38
+
39
+
40
+ def elements_to_content(elements: list[dict] | None) -> str:
41
+ if not elements:
42
+ return ""
43
+ return "".join(str(deserialize_element(e)) for e in elements)
44
+
45
+
46
+ async def message_receive(raw: dict, conn: WebsocketConnection):
47
+ peer_type = raw.get("peer_type", "user")
48
+ peer_id = f"private:{conn.user_id}" if peer_type == "user" else (raw.get("peer_id") or f"channel:{token_hex(8)}")
49
+ message_id = raw["message_id"]
50
+ message_content = raw.get("message_content") or elements_to_content(raw.get("elements"))
51
+ channel = Channel(peer_id, ChannelType.DIRECT if peer_type == "user" else ChannelType.TEXT)
52
+ return Event(
53
+ EventType.MESSAGE_CREATED,
54
+ datetime.now(),
55
+ conn.adapter.logins[conn.session_id],
56
+ channel=channel,
57
+ user=User(conn.user_id, conn.user_id),
58
+ message=MessageObject(id=message_id, content=message_content),
59
+ )
60
+
61
+
62
+ class WebsocketConnection:
63
+ connection: WebSocket
64
+
65
+ def __init__(self, adapter: WebUIAdapter, connection: WebSocket, session_id: str = "entari"):
66
+ self.adapter = adapter
67
+ self.connection = connection
68
+ self.session_id = session_id
69
+ self.user_id: str = f"user_{token_hex(8)}"
70
+ self.close_signal: asyncio.Event = asyncio.Event()
71
+ self.response_waiters: dict[str, asyncio.Future] = {}
72
+
73
+ @property
74
+ def alive(self) -> bool:
75
+ return not self.close_signal.is_set()
76
+
77
+ async def message_receive(self):
78
+
79
+ while True:
80
+ message = await self.connection.receive()
81
+
82
+ if message["type"] == "websocket.disconnect":
83
+ break
84
+ yield self, decode(message["text"])
85
+ self.close_signal.set()
86
+
87
+ async def message_handle(self):
88
+ async for _, data in self.message_receive():
89
+ if token := data.get("token"):
90
+ if token in self.response_waiters:
91
+ self.response_waiters[token].set_result(data)
92
+ continue
93
+ event_type = data.get("type")
94
+ if event_type == "message_create":
95
+ event = await message_receive(data["data"], self)
96
+ else:
97
+ event = Event(EventType.INTERNAL, datetime.now(), self.adapter.logins[self.session_id])
98
+ event._type = event_type
99
+ event._data = data.get("data")
100
+ asyncio.create_task(self.adapter.server.post(event))
101
+
102
+ async def connection_closed(self):
103
+ self.close_signal.set()
104
+
105
+ async def call_api(self, action: str, params: dict | None = None) -> dict:
106
+ future = asyncio.get_running_loop().create_future()
107
+ token = token_hex(16)
108
+ self.response_waiters[token] = future
109
+ try:
110
+ await self.send({"action": action, "data": params or {}, "token": token})
111
+ result = await asyncio.wait_for(future, timeout=60)
112
+ finally:
113
+ self.response_waiters.pop(token, None)
114
+ if result["status"] != "ok":
115
+ raise ActionFailed(result.get("error", "Unknown error"), result)
116
+ return result.get("data", {})
117
+
118
+ async def send(self, payload: dict) -> None:
119
+ return await self.connection.send_text(encode(payload))
120
+
121
+
122
+ def apply(adapter: WebUIAdapter):
123
+ @adapter.route(Api.MESSAGE_CREATE)
124
+ async def _message_create(request: Request[MessageParam]):
125
+ conn = adapter.connections[request.self_id]
126
+ channel_id = request.params["channel_id"]
127
+ if "elements" in request.params:
128
+ elements = request.params["elements"]
129
+ content = elements_to_content(elements)
130
+ else:
131
+ content = request.params["content"]
132
+ elements = [serialize_element(e) for e in parse(content)]
133
+ result = await conn.call_api("message_create", {"channel_id": channel_id, "elements": elements})
134
+ return [MessageObject(result["id"], content)]
135
+
136
+
137
+ class WebUIAdapter(BaseAdapter):
138
+ def __init__(self):
139
+ super().__init__()
140
+ self.connections: dict[str, WebsocketConnection] = {}
141
+ self.logins: dict[str, Login] = {}
142
+
143
+ apply(self)
144
+
145
+ def ensure(self, platform: str, self_id: str) -> bool:
146
+ return platform == "webui" and self_id in self.connections
147
+
148
+ async def get_logins(self) -> list[Login]:
149
+ logins = list(self.logins.values())
150
+ for index, login in enumerate(logins):
151
+ login.sn = index
152
+ return logins
153
+
154
+ @property
155
+ def required(self) -> set[str]:
156
+ return {"satori-python.server", "asgi.service/uvicorn"}
157
+
158
+ @property
159
+ def stages(self) -> set[Phase]:
160
+ return {"preparing", "blocking", "cleanup"}
161
+
162
+ def get_routes(self):
163
+ return [
164
+ WebSocketRoute("/api/chat", self.websocket_server_handler),
165
+ ]
166
+
167
+ async def websocket_server_handler(self, ws: WebSocket):
168
+ sid = None
169
+ if not is_local_mode():
170
+ sid = ws.cookies.get("webui_sid")
171
+ from entari_plugin_webui import _session_store
172
+
173
+ if _session_store.get(sid) is None:
174
+ await ws.close(code=1008, reason="Invalid session")
175
+ return
176
+
177
+ sid = sid or f"entari_{token_hex(8)}"
178
+ if sid in self.connections:
179
+ old = self.connections[sid]
180
+ if old.alive:
181
+ await ws.close(code=1008, reason="Duplicate connection")
182
+ return
183
+ self.connections.pop(sid, None)
184
+
185
+ await ws.accept()
186
+ conn = WebsocketConnection(self, ws, session_id=sid)
187
+ self.connections[sid] = conn
188
+
189
+ if sid not in self.logins:
190
+ self.logins[sid] = Login(
191
+ platform="webui",
192
+ user=User(id=sid, name="Entari"),
193
+ status=LoginStatus.ONLINE,
194
+ )
195
+ await self.server.post(Event(EventType.LOGIN_ADDED, datetime.now(), self.logins[sid]))
196
+ else:
197
+ self.logins[sid].status = LoginStatus.ONLINE
198
+ await self.server.post(Event(EventType.LOGIN_UPDATED, datetime.now(), self.logins[sid]))
199
+
200
+ try:
201
+ await any_completed(
202
+ conn.message_handle(),
203
+ conn.close_signal.wait(),
204
+ )
205
+ finally:
206
+ await conn.connection_closed()
207
+ self.connections.pop(sid, None)
208
+ if conn.response_waiters:
209
+ for future in conn.response_waiters.values():
210
+ future.cancel()
211
+ self.logins[sid].status = LoginStatus.OFFLINE
212
+ await self.server.post(Event(EventType.LOGIN_REMOVED, datetime.now(), self.logins[sid]))
213
+
214
+ def get_platform(self) -> str:
215
+ return "webui"
216
+
217
+ async def handle_internal(self, request: Request, path: str) -> Response:
218
+ if path.startswith("_api"):
219
+ self_id = request.self_id
220
+ return JSONResponse(await self.connections[self_id].call_api(path[5:], await request.origin.json()))
221
+ async with self.server.session.get(path) as resp:
222
+ return Response(await resp.read())
223
+
224
+ async def launch(self, manager: Launart):
225
+ async with self.stage("preparing"):
226
+ pass
227
+
228
+ async with self.stage("blocking"):
229
+ await manager.status.wait_for_sigexit()
230
+
231
+ async with self.stage("cleanup"):
232
+ pass
@@ -0,0 +1,3 @@
1
+ from .router import create_app
2
+
3
+ __all__ = ["create_app"]
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
4
+ from pydantic import BaseModel, Field
5
+ from starlette.responses import JSONResponse
6
+
7
+ from entari_plugin_webui import webui_config
8
+
9
+ from ..core.audit import audit
10
+ from ..core.error import AuthRequired, TooManyRequests
11
+ from ..core.security import (
12
+ hash_password,
13
+ is_local_mode,
14
+ verify_password,
15
+ )
16
+ from ..core.session import SessionStore
17
+ from .deps import get_session_store, require_auth
18
+
19
+ router = APIRouter(prefix="/api/auth", tags=["auth"])
20
+
21
+ _COOKIE = "webui_sid"
22
+
23
+
24
+ class LoginRequest(BaseModel):
25
+ password: str = Field(min_length=1)
26
+
27
+
28
+ class PasswordRequest(BaseModel):
29
+ old_password: str
30
+ new_password: str = Field(min_length=6)
31
+
32
+
33
+ def _client_ip(request: Request) -> str:
34
+ return request.client.host if request.client else "unknown"
35
+
36
+
37
+ @router.get("/check")
38
+ async def check_auth():
39
+ return {"local_mode": is_local_mode(), "initialized": bool(webui_config.password)}
40
+
41
+
42
+ @router.post("/login")
43
+ async def login(body: LoginRequest, request: Request, store: SessionStore = Depends(get_session_store)):
44
+ ip = _client_ip(request)
45
+ from .. import _login_throttle # type: ignore # noqa: PLC0415
46
+
47
+ if is_local_mode():
48
+ sid = store.create(ip=ip)
49
+ return _json_with_cookie({"success": True, "local_mode": True}, sid)
50
+
51
+ if _login_throttle.is_limited(ip):
52
+ audit("login.failed", ip=ip, reason="rate_limited")
53
+ raise TooManyRequests(retry_after=_login_throttle.retry_after(ip), message="尝试过于频繁,请稍后再试")
54
+
55
+ if not webui_config.password:
56
+ raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "管理员密码尚未初始化")
57
+
58
+ if not verify_password(body.password, webui_config.password):
59
+ _login_throttle.record_failure(ip)
60
+ audit("login.failed", ip=ip, reason="wrong_password")
61
+ raise AuthRequired(message="密码错误")
62
+
63
+ _login_throttle.reset(ip)
64
+ sid = store.create(ip=ip)
65
+ audit("login.success", ip=ip)
66
+ return _json_with_cookie({"success": True, "local_mode": False}, sid)
67
+
68
+
69
+ @router.post("/logout")
70
+ async def logout(request: Request, store: SessionStore = Depends(get_session_store)):
71
+ sid = request.cookies.get(_COOKIE)
72
+ if sid:
73
+ store.destroy(sid)
74
+ if not is_local_mode():
75
+ audit("logout", ip=_client_ip(request))
76
+ resp = JSONResponse({"success": True})
77
+ resp.delete_cookie(_COOKIE, path="/")
78
+ return resp
79
+
80
+
81
+ @router.put("/password")
82
+ async def change_password(
83
+ body: PasswordRequest,
84
+ request: Request,
85
+ _sess=Depends(require_auth),
86
+ ):
87
+ if not is_local_mode() and not verify_password(body.old_password, webui_config.password):
88
+ audit("password.change.failed", ip=_client_ip(request), reason="wrong_old")
89
+ raise AuthRequired(message="旧密码错误")
90
+ webui_config.password = hash_password(body.new_password)
91
+ audit("password.change", ip=_client_ip(request))
92
+ return {"success": True}
93
+
94
+
95
+ def _json_with_cookie(payload: dict, sid: str) -> JSONResponse:
96
+ resp = JSONResponse(payload)
97
+ secure = True
98
+ resp.set_cookie(
99
+ _COOKIE,
100
+ sid,
101
+ httponly=True,
102
+ samesite="lax",
103
+ secure=secure,
104
+ max_age=webui_config.session_ttl,
105
+ path="/",
106
+ )
107
+ return resp
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends
4
+ from pydantic import BaseModel
5
+
6
+ from ..services.config_service import (
7
+ get_schema_for_section,
8
+ get_section,
9
+ list_sections,
10
+ update_section,
11
+ )
12
+ from .deps import require_auth
13
+
14
+ router = APIRouter(prefix="/api/config", tags=["config"], dependencies=[Depends(require_auth)])
15
+
16
+
17
+ class SectionBody(BaseModel):
18
+ data: dict | list
19
+
20
+
21
+ @router.get("/{section}/schema")
22
+ async def schema(section: str):
23
+ return {"success": True, "section": section, **get_schema_for_section(section)}
24
+
25
+
26
+ @router.get("")
27
+ async def list_():
28
+ return {"success": True, **list_sections()}
29
+
30
+
31
+ @router.get("/{section}")
32
+ async def get_(section: str):
33
+ return {"success": True, "section": section, "data": get_section(section)}
34
+
35
+
36
+ @router.put("/{section}")
37
+ async def put_(section: str, body: SectionBody):
38
+ update_section(section, body.data)
39
+ return {"success": True, "message": "已保存"}
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import Depends, Request
4
+
5
+ from ..core.error import AuthRequired
6
+ from ..core.security import is_local_mode
7
+ from ..core.session import Session, SessionStore
8
+
9
+
10
+ def get_session_store() -> SessionStore:
11
+ from .. import _session_store # type: ignore # noqa: PLC0415
12
+
13
+ return _session_store
14
+
15
+
16
+ def require_auth(request: Request, store: SessionStore = Depends(get_session_store)) -> Session | None:
17
+ if is_local_mode():
18
+ return None
19
+ sid = request.cookies.get("webui_sid")
20
+ sess = store.get(sid)
21
+ if sess is None:
22
+ raise AuthRequired("未登录或会话已过期")
23
+ store.refresh_if_needed(sess)
24
+ return sess
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends
4
+
5
+ from ..api.deps import require_auth
6
+ from ..core.extension import get_all_menus, get_all_pages
7
+ from ..core.i18n import I18nRegistry
8
+ from ..core.permissions import get_permissions
9
+
10
+ router = APIRouter(prefix="/api/extensions", tags=["extensions"], dependencies=[Depends(require_auth)])
11
+
12
+
13
+ @router.get("/manifest")
14
+ async def manifest():
15
+ return {
16
+ "success": True,
17
+ "menus": get_all_menus(),
18
+ "pages": get_all_pages(),
19
+ "i18n": I18nRegistry.all(),
20
+ "permissions": get_permissions(),
21
+ }
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ from fastapi import APIRouter
7
+
8
+ router = APIRouter(prefix="/api", tags=["health"])
9
+
10
+ _START = datetime.now()
11
+
12
+
13
+ @router.get("/health")
14
+ async def health() -> dict:
15
+
16
+ frontend_built = (Path(__file__).resolve().parent.parent / "static" / "frontend" / "index.html").exists()
17
+ return {
18
+ "status": "ok",
19
+ "uptime_seconds": int((datetime.now() - _START).total_seconds()),
20
+ "frontend_built": frontend_built,
21
+ }
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from entari_plugin_server import add_websocket_route
6
+ from starlette.websockets import WebSocket, WebSocketDisconnect
7
+
8
+ from ..core.log_stream import get_log_buffer
9
+ from ..core.security import is_local_mode
10
+
11
+
12
+ @add_websocket_route("/ws/logs")
13
+ async def websocket_logs(ws: WebSocket) -> None:
14
+ if not is_local_mode():
15
+ sid = ws.cookies.get("webui_sid")
16
+ from entari_plugin_webui import _session_store
17
+
18
+ if _session_store.get(sid) is None:
19
+ await ws.close(code=1008)
20
+ return
21
+
22
+ await ws.accept()
23
+ buffer = get_log_buffer()
24
+ history_data, last_pos = buffer.get_recent(100)
25
+ await ws.send_json({"type": "history", "data": history_data})
26
+ try:
27
+ while True:
28
+ text, last_pos = buffer.get_new_since(last_pos)
29
+ if text:
30
+ await ws.send_json({"type": "log", "data": text})
31
+ await asyncio.sleep(0.5)
32
+ except (WebSocketDisconnect, asyncio.CancelledError):
33
+ pass
34
+ except Exception: # noqa: BLE001
35
+ pass
36
+ finally:
37
+ from contextlib import suppress
38
+
39
+ with suppress(Exception):
40
+ await ws.close()
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Depends
4
+ from fastapi.responses import JSONResponse
5
+ from pydantic import BaseModel
6
+ from starlette.status import HTTP_404_NOT_FOUND
7
+
8
+ from ..api.deps import require_auth
9
+ from ..core.error import PluginNotFound
10
+ from ..services.market_service import (
11
+ get_plugin,
12
+ get_task,
13
+ list_plugins,
14
+ start_install,
15
+ start_uninstall,
16
+ )
17
+
18
+ router = APIRouter(prefix="/api/market", tags=["market"], dependencies=[Depends(require_auth)])
19
+
20
+
21
+ class NameBody(BaseModel):
22
+ name: str
23
+
24
+
25
+ @router.get("/plugins")
26
+ async def list_():
27
+ return {"success": True, **(await list_plugins())}
28
+
29
+
30
+ @router.get("/plugins/{name}")
31
+ async def detail(name: str):
32
+ p = await get_plugin(name)
33
+ if p is None:
34
+ raise PluginNotFound(name)
35
+ return {"success": True, "data": p}
36
+
37
+
38
+ @router.post("/install")
39
+ async def install(body: NameBody):
40
+ tid = await start_install(body.name)
41
+ return {"success": True, "task_id": tid}
42
+
43
+
44
+ @router.post("/uninstall")
45
+ async def uninstall(body: NameBody):
46
+ tid = await start_uninstall(body.name)
47
+ return {"success": True, "task_id": tid}
48
+
49
+
50
+ @router.get("/tasks/{task_id}")
51
+ async def task(task_id: str):
52
+ t = get_task(task_id)
53
+ if t is None:
54
+ return JSONResponse({"success": False, "code": "task_not_found"}, status_code=HTTP_404_NOT_FOUND)
55
+ return {
56
+ "success": True,
57
+ "task_id": t.task_id,
58
+ "pip_name": t.pip_name,
59
+ "action": t.action,
60
+ "status": t.status,
61
+ "percent": t.percent,
62
+ "message": t.message,
63
+ }