@yunzai-ng/core 0.1.0

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 (357) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +51 -0
  3. package/dist/adapter/accounts.d.ts +174 -0
  4. package/dist/adapter/accounts.d.ts.map +1 -0
  5. package/dist/adapter/accounts.js +653 -0
  6. package/dist/adapter/accounts.js.map +1 -0
  7. package/dist/adapter/bots.d.ts +142 -0
  8. package/dist/adapter/bots.d.ts.map +1 -0
  9. package/dist/adapter/bots.js +454 -0
  10. package/dist/adapter/bots.js.map +1 -0
  11. package/dist/adapter/host.d.ts +114 -0
  12. package/dist/adapter/host.d.ts.map +1 -0
  13. package/dist/adapter/host.js +90 -0
  14. package/dist/adapter/host.js.map +1 -0
  15. package/dist/adapter/login.d.ts +174 -0
  16. package/dist/adapter/login.d.ts.map +1 -0
  17. package/dist/adapter/login.js +345 -0
  18. package/dist/adapter/login.js.map +1 -0
  19. package/dist/adapter/registry.d.ts +102 -0
  20. package/dist/adapter/registry.d.ts.map +1 -0
  21. package/dist/adapter/registry.js +150 -0
  22. package/dist/adapter/registry.js.map +1 -0
  23. package/dist/config/core-config.d.ts +151 -0
  24. package/dist/config/core-config.d.ts.map +1 -0
  25. package/dist/config/core-config.js +338 -0
  26. package/dist/config/core-config.js.map +1 -0
  27. package/dist/config/schema.d.ts +449 -0
  28. package/dist/config/schema.d.ts.map +1 -0
  29. package/dist/config/schema.js +871 -0
  30. package/dist/config/schema.js.map +1 -0
  31. package/dist/config/store.d.ts +184 -0
  32. package/dist/config/store.d.ts.map +1 -0
  33. package/dist/config/store.js +428 -0
  34. package/dist/config/store.js.map +1 -0
  35. package/dist/config/yaml.d.ts +23 -0
  36. package/dist/config/yaml.d.ts.map +1 -0
  37. package/dist/config/yaml.js +108 -0
  38. package/dist/config/yaml.js.map +1 -0
  39. package/dist/http/client.d.ts +82 -0
  40. package/dist/http/client.d.ts.map +1 -0
  41. package/dist/http/client.js +658 -0
  42. package/dist/http/client.js.map +1 -0
  43. package/dist/index.d.ts +77 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +94 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/kernel/app.d.ts +244 -0
  48. package/dist/kernel/app.d.ts.map +1 -0
  49. package/dist/kernel/app.js +654 -0
  50. package/dist/kernel/app.js.map +1 -0
  51. package/dist/kernel/kv-sink.d.ts +34 -0
  52. package/dist/kernel/kv-sink.d.ts.map +1 -0
  53. package/dist/kernel/kv-sink.js +25 -0
  54. package/dist/kernel/kv-sink.js.map +1 -0
  55. package/dist/kernel/policy.d.ts +77 -0
  56. package/dist/kernel/policy.d.ts.map +1 -0
  57. package/dist/kernel/policy.js +99 -0
  58. package/dist/kernel/policy.js.map +1 -0
  59. package/dist/kernel/runtime.d.ts +117 -0
  60. package/dist/kernel/runtime.d.ts.map +1 -0
  61. package/dist/kernel/runtime.js +197 -0
  62. package/dist/kernel/runtime.js.map +1 -0
  63. package/dist/kernel/sql-sink.d.ts +36 -0
  64. package/dist/kernel/sql-sink.d.ts.map +1 -0
  65. package/dist/kernel/sql-sink.js +114 -0
  66. package/dist/kernel/sql-sink.js.map +1 -0
  67. package/dist/logger/format.d.ts +67 -0
  68. package/dist/logger/format.d.ts.map +1 -0
  69. package/dist/logger/format.js +218 -0
  70. package/dist/logger/format.js.map +1 -0
  71. package/dist/logger/index.d.ts +97 -0
  72. package/dist/logger/index.d.ts.map +1 -0
  73. package/dist/logger/index.js +363 -0
  74. package/dist/logger/index.js.map +1 -0
  75. package/dist/logger/rotate.d.ts +47 -0
  76. package/dist/logger/rotate.d.ts.map +1 -0
  77. package/dist/logger/rotate.js +242 -0
  78. package/dist/logger/rotate.js.map +1 -0
  79. package/dist/message/segment.d.ts +255 -0
  80. package/dist/message/segment.d.ts.map +1 -0
  81. package/dist/message/segment.js +510 -0
  82. package/dist/message/segment.js.map +1 -0
  83. package/dist/message/split.d.ts +34 -0
  84. package/dist/message/split.d.ts.map +1 -0
  85. package/dist/message/split.js +79 -0
  86. package/dist/message/split.js.map +1 -0
  87. package/dist/message/target.d.ts +29 -0
  88. package/dist/message/target.d.ts.map +1 -0
  89. package/dist/message/target.js +34 -0
  90. package/dist/message/target.js.map +1 -0
  91. package/dist/pipeline/cooldown.d.ts +84 -0
  92. package/dist/pipeline/cooldown.d.ts.map +1 -0
  93. package/dist/pipeline/cooldown.js +94 -0
  94. package/dist/pipeline/cooldown.js.map +1 -0
  95. package/dist/pipeline/dispatch.d.ts +98 -0
  96. package/dist/pipeline/dispatch.d.ts.map +1 -0
  97. package/dist/pipeline/dispatch.js +323 -0
  98. package/dist/pipeline/dispatch.js.map +1 -0
  99. package/dist/pipeline/event.d.ts +130 -0
  100. package/dist/pipeline/event.d.ts.map +1 -0
  101. package/dist/pipeline/event.js +538 -0
  102. package/dist/pipeline/event.js.map +1 -0
  103. package/dist/pipeline/middleware.d.ts +63 -0
  104. package/dist/pipeline/middleware.d.ts.map +1 -0
  105. package/dist/pipeline/middleware.js +174 -0
  106. package/dist/pipeline/middleware.js.map +1 -0
  107. package/dist/pipeline/prompt.d.ts +80 -0
  108. package/dist/pipeline/prompt.d.ts.map +1 -0
  109. package/dist/pipeline/prompt.js +155 -0
  110. package/dist/pipeline/prompt.js.map +1 -0
  111. package/dist/pipeline/router.d.ts +98 -0
  112. package/dist/pipeline/router.d.ts.map +1 -0
  113. package/dist/pipeline/router.js +489 -0
  114. package/dist/pipeline/router.js.map +1 -0
  115. package/dist/platform/detect.d.ts +24 -0
  116. package/dist/platform/detect.d.ts.map +1 -0
  117. package/dist/platform/detect.js +178 -0
  118. package/dist/platform/detect.js.map +1 -0
  119. package/dist/platform/paths.d.ts +38 -0
  120. package/dist/platform/paths.d.ts.map +1 -0
  121. package/dist/platform/paths.js +129 -0
  122. package/dist/platform/paths.js.map +1 -0
  123. package/dist/platform/system.d.ts +71 -0
  124. package/dist/platform/system.d.ts.map +1 -0
  125. package/dist/platform/system.js +242 -0
  126. package/dist/platform/system.js.map +1 -0
  127. package/dist/plugin/context.d.ts +92 -0
  128. package/dist/plugin/context.d.ts.map +1 -0
  129. package/dist/plugin/context.js +573 -0
  130. package/dist/plugin/context.js.map +1 -0
  131. package/dist/plugin/define.d.ts +92 -0
  132. package/dist/plugin/define.d.ts.map +1 -0
  133. package/dist/plugin/define.js +82 -0
  134. package/dist/plugin/define.js.map +1 -0
  135. package/dist/plugin/discover.d.ts +106 -0
  136. package/dist/plugin/discover.d.ts.map +1 -0
  137. package/dist/plugin/discover.js +254 -0
  138. package/dist/plugin/discover.js.map +1 -0
  139. package/dist/plugin/events.d.ts +109 -0
  140. package/dist/plugin/events.d.ts.map +1 -0
  141. package/dist/plugin/events.js +209 -0
  142. package/dist/plugin/events.js.map +1 -0
  143. package/dist/plugin/hooks.d.ts +266 -0
  144. package/dist/plugin/hooks.d.ts.map +1 -0
  145. package/dist/plugin/hooks.js +84 -0
  146. package/dist/plugin/hooks.js.map +1 -0
  147. package/dist/plugin/host.d.ts +160 -0
  148. package/dist/plugin/host.d.ts.map +1 -0
  149. package/dist/plugin/host.js +546 -0
  150. package/dist/plugin/host.js.map +1 -0
  151. package/dist/plugin/market.d.ts +264 -0
  152. package/dist/plugin/market.d.ts.map +1 -0
  153. package/dist/plugin/market.js +700 -0
  154. package/dist/plugin/market.js.map +1 -0
  155. package/dist/plugin/services.d.ts +116 -0
  156. package/dist/plugin/services.d.ts.map +1 -0
  157. package/dist/plugin/services.js +213 -0
  158. package/dist/plugin/services.js.map +1 -0
  159. package/dist/plugin/tar.d.ts +65 -0
  160. package/dist/plugin/tar.d.ts.map +1 -0
  161. package/dist/plugin/tar.js +277 -0
  162. package/dist/plugin/tar.js.map +1 -0
  163. package/dist/render/registry.d.ts +127 -0
  164. package/dist/render/registry.d.ts.map +1 -0
  165. package/dist/render/registry.js +306 -0
  166. package/dist/render/registry.js.map +1 -0
  167. package/dist/scheduler/index.d.ts +67 -0
  168. package/dist/scheduler/index.d.ts.map +1 -0
  169. package/dist/scheduler/index.js +279 -0
  170. package/dist/scheduler/index.js.map +1 -0
  171. package/dist/server/api.d.ts +179 -0
  172. package/dist/server/api.d.ts.map +1 -0
  173. package/dist/server/api.js +602 -0
  174. package/dist/server/api.js.map +1 -0
  175. package/dist/server/auth.d.ts +83 -0
  176. package/dist/server/auth.d.ts.map +1 -0
  177. package/dist/server/auth.js +178 -0
  178. package/dist/server/auth.js.map +1 -0
  179. package/dist/server/browse.d.ts +60 -0
  180. package/dist/server/browse.d.ts.map +1 -0
  181. package/dist/server/browse.js +187 -0
  182. package/dist/server/browse.js.map +1 -0
  183. package/dist/server/files.d.ts +27 -0
  184. package/dist/server/files.d.ts.map +1 -0
  185. package/dist/server/files.js +76 -0
  186. package/dist/server/files.js.map +1 -0
  187. package/dist/server/index.d.ts +189 -0
  188. package/dist/server/index.d.ts.map +1 -0
  189. package/dist/server/index.js +1123 -0
  190. package/dist/server/index.js.map +1 -0
  191. package/dist/server/table.d.ts +105 -0
  192. package/dist/server/table.d.ts.map +1 -0
  193. package/dist/server/table.js +289 -0
  194. package/dist/server/table.js.map +1 -0
  195. package/dist/store/index.d.ts +50 -0
  196. package/dist/store/index.d.ts.map +1 -0
  197. package/dist/store/index.js +120 -0
  198. package/dist/store/index.js.map +1 -0
  199. package/dist/store/json.d.ts +73 -0
  200. package/dist/store/json.d.ts.map +1 -0
  201. package/dist/store/json.js +174 -0
  202. package/dist/store/json.js.map +1 -0
  203. package/dist/store/kv.d.ts +126 -0
  204. package/dist/store/kv.d.ts.map +1 -0
  205. package/dist/store/kv.js +232 -0
  206. package/dist/store/kv.js.map +1 -0
  207. package/dist/store/level.d.ts +103 -0
  208. package/dist/store/level.d.ts.map +1 -0
  209. package/dist/store/level.js +119 -0
  210. package/dist/store/level.js.map +1 -0
  211. package/dist/store/memory.d.ts +61 -0
  212. package/dist/store/memory.d.ts.map +1 -0
  213. package/dist/store/memory.js +94 -0
  214. package/dist/store/memory.js.map +1 -0
  215. package/dist/store/sql.d.ts +142 -0
  216. package/dist/store/sql.d.ts.map +1 -0
  217. package/dist/store/sql.js +246 -0
  218. package/dist/store/sql.js.map +1 -0
  219. package/dist/testing/fake.d.ts +109 -0
  220. package/dist/testing/fake.d.ts.map +1 -0
  221. package/dist/testing/fake.js +196 -0
  222. package/dist/testing/fake.js.map +1 -0
  223. package/dist/testing/index.d.ts +16 -0
  224. package/dist/testing/index.d.ts.map +1 -0
  225. package/dist/testing/index.js +16 -0
  226. package/dist/testing/index.js.map +1 -0
  227. package/dist/testing/mock-adapter.d.ts +205 -0
  228. package/dist/testing/mock-adapter.d.ts.map +1 -0
  229. package/dist/testing/mock-adapter.js +309 -0
  230. package/dist/testing/mock-adapter.js.map +1 -0
  231. package/dist/util/deep.d.ts +108 -0
  232. package/dist/util/deep.d.ts.map +1 -0
  233. package/dist/util/deep.js +275 -0
  234. package/dist/util/deep.js.map +1 -0
  235. package/dist/util/defer.d.ts +107 -0
  236. package/dist/util/defer.d.ts.map +1 -0
  237. package/dist/util/defer.js +163 -0
  238. package/dist/util/defer.js.map +1 -0
  239. package/dist/util/dispose.d.ts +88 -0
  240. package/dist/util/dispose.d.ts.map +1 -0
  241. package/dist/util/dispose.js +143 -0
  242. package/dist/util/dispose.js.map +1 -0
  243. package/dist/util/duration.d.ts +28 -0
  244. package/dist/util/duration.d.ts.map +1 -0
  245. package/dist/util/duration.js +77 -0
  246. package/dist/util/duration.js.map +1 -0
  247. package/dist/util/fs.d.ts +110 -0
  248. package/dist/util/fs.d.ts.map +1 -0
  249. package/dist/util/fs.js +269 -0
  250. package/dist/util/fs.js.map +1 -0
  251. package/dist/util/id.d.ts +49 -0
  252. package/dist/util/id.d.ts.map +1 -0
  253. package/dist/util/id.js +87 -0
  254. package/dist/util/id.js.map +1 -0
  255. package/dist/util/lru.d.ts +74 -0
  256. package/dist/util/lru.d.ts.map +1 -0
  257. package/dist/util/lru.js +156 -0
  258. package/dist/util/lru.js.map +1 -0
  259. package/dist/util/queue.d.ts +64 -0
  260. package/dist/util/queue.d.ts.map +1 -0
  261. package/dist/util/queue.js +147 -0
  262. package/dist/util/queue.js.map +1 -0
  263. package/dist/util/text.d.ts +86 -0
  264. package/dist/util/text.d.ts.map +1 -0
  265. package/dist/util/text.js +182 -0
  266. package/dist/util/text.js.map +1 -0
  267. package/package.json +44 -0
  268. package/src/adapter/accounts.ts +758 -0
  269. package/src/adapter/bots.ts +582 -0
  270. package/src/adapter/host.ts +224 -0
  271. package/src/adapter/login.ts +481 -0
  272. package/src/adapter/registry.ts +202 -0
  273. package/src/config/core-config.test.ts +60 -0
  274. package/src/config/core-config.ts +401 -0
  275. package/src/config/schema.test.ts +179 -0
  276. package/src/config/schema.ts +1036 -0
  277. package/src/config/store.test.ts +245 -0
  278. package/src/config/store.ts +506 -0
  279. package/src/config/yaml.ts +119 -0
  280. package/src/http/client.test.ts +568 -0
  281. package/src/http/client.ts +777 -0
  282. package/src/index.ts +106 -0
  283. package/src/kernel/app.test.ts +267 -0
  284. package/src/kernel/app.ts +843 -0
  285. package/src/kernel/kv-sink.ts +58 -0
  286. package/src/kernel/policy.ts +120 -0
  287. package/src/kernel/runtime.test.ts +542 -0
  288. package/src/kernel/runtime.ts +307 -0
  289. package/src/kernel/sql-sink.ts +154 -0
  290. package/src/logger/format.ts +259 -0
  291. package/src/logger/index.ts +427 -0
  292. package/src/logger/rotate.ts +265 -0
  293. package/src/message/segment.test.ts +335 -0
  294. package/src/message/segment.ts +569 -0
  295. package/src/message/split.ts +97 -0
  296. package/src/message/target.ts +48 -0
  297. package/src/pipeline/cooldown.ts +136 -0
  298. package/src/pipeline/dispatch.ts +419 -0
  299. package/src/pipeline/event.ts +735 -0
  300. package/src/pipeline/middleware.test.ts +426 -0
  301. package/src/pipeline/middleware.ts +199 -0
  302. package/src/pipeline/prompt.ts +212 -0
  303. package/src/pipeline/router.test.ts +432 -0
  304. package/src/pipeline/router.ts +575 -0
  305. package/src/platform/detect.ts +178 -0
  306. package/src/platform/paths.ts +147 -0
  307. package/src/platform/system.test.ts +102 -0
  308. package/src/platform/system.ts +289 -0
  309. package/src/plugin/context.test.ts +459 -0
  310. package/src/plugin/context.ts +757 -0
  311. package/src/plugin/define.test.ts +120 -0
  312. package/src/plugin/define.ts +142 -0
  313. package/src/plugin/discover.test.ts +253 -0
  314. package/src/plugin/discover.ts +353 -0
  315. package/src/plugin/events.test.ts +162 -0
  316. package/src/plugin/events.ts +266 -0
  317. package/src/plugin/hooks.ts +373 -0
  318. package/src/plugin/host.test.ts +696 -0
  319. package/src/plugin/host.ts +738 -0
  320. package/src/plugin/market.test.ts +781 -0
  321. package/src/plugin/market.ts +870 -0
  322. package/src/plugin/services.test.ts +135 -0
  323. package/src/plugin/services.ts +261 -0
  324. package/src/plugin/tar.test.ts +158 -0
  325. package/src/plugin/tar.ts +309 -0
  326. package/src/render/registry.test.ts +127 -0
  327. package/src/render/registry.ts +439 -0
  328. package/src/scheduler/index.ts +357 -0
  329. package/src/server/api.test.ts +802 -0
  330. package/src/server/api.ts +869 -0
  331. package/src/server/auth.ts +201 -0
  332. package/src/server/browse.test.ts +193 -0
  333. package/src/server/browse.ts +240 -0
  334. package/src/server/files.ts +97 -0
  335. package/src/server/index.test.ts +566 -0
  336. package/src/server/index.ts +1386 -0
  337. package/src/server/table.ts +351 -0
  338. package/src/store/index.ts +156 -0
  339. package/src/store/json.ts +198 -0
  340. package/src/store/kv.test.ts +307 -0
  341. package/src/store/kv.ts +256 -0
  342. package/src/store/level.ts +167 -0
  343. package/src/store/memory.ts +109 -0
  344. package/src/store/sql.test.ts +204 -0
  345. package/src/store/sql.ts +324 -0
  346. package/src/testing/fake.ts +297 -0
  347. package/src/testing/index.ts +15 -0
  348. package/src/testing/mock-adapter.ts +588 -0
  349. package/src/util/deep.ts +272 -0
  350. package/src/util/defer.ts +211 -0
  351. package/src/util/dispose.ts +169 -0
  352. package/src/util/duration.ts +82 -0
  353. package/src/util/fs.ts +279 -0
  354. package/src/util/id.ts +94 -0
  355. package/src/util/lru.ts +185 -0
  356. package/src/util/queue.ts +167 -0
  357. package/src/util/text.ts +189 -0
@@ -0,0 +1,869 @@
1
+ /**
2
+ * 模块职责:面板 API —— 把内核各子系统的能力翻译成一组 HTTP / WebSocket 端点
3
+ * 依赖方向:依赖 config / logger / plugin / adapter / render / pipeline 的**具体实现**,
4
+ * 以及本目录的 ManagedServer 类型;不认识前端,也不被任何子系统反向依赖
5
+ * 生命周期:`registerApi()` 由 `App.start()` 之前的装配步骤调用一次,返回的 Disposer
6
+ * 交给 `App.own()`;端点本身随服务器一起活到停机
7
+ * 注意事项:本模块只产出描述,注册由调用方做(`createApiRoutes` 给出 `ApiSurface`,
8
+ * `registerApi` 才挂上去)—— 直接 import `kernel/app.ts` 会形成 app → api → app 的环。
9
+ *
10
+ * 只读模式(`server.readonly`)是面板策略而非服务器策略:插件注册的路由一律照常放行,
11
+ * 适配器的 webhook 不该因为面板设成只读而收不到消息。故判断在 `requireWritable()`。
12
+ *
13
+ * 日志走 WebSocket 而非 SSE:令牌只走请求头,而 `EventSource` 设不了请求头,
14
+ * 退到 Cookie 会引回 CSRF 面、退到查询串会把令牌写进访问日志。
15
+ *
16
+ * 历史日志与实时日志分两条路。合成一条时「握手瞬间同步发出的那一帧」在部分客户端会丢,
17
+ * 历史缺一段而无人察觉。
18
+ */
19
+ import type {
20
+ AccountState,
21
+ AdapterRegistryView,
22
+ BotRegistryView,
23
+ CommandInfo,
24
+ Disposer,
25
+ HttpMethod,
26
+ LogLevel,
27
+ Logger,
28
+ MiddlewareInfo,
29
+ PlatformInfo,
30
+ ResourceUsage,
31
+ RouteHandler,
32
+ RouteOptions,
33
+ RouteRequest,
34
+ RuntimePaths,
35
+ SchemaDescriptor,
36
+ TaskInfo,
37
+ WebSocketHandler
38
+ } from "@yunzai-ng/types"
39
+ import type { CoreConfigHandle } from "../config/core-config.js"
40
+ import { SchemaError } from "../config/schema.js"
41
+ import type { ConfigStore } from "../config/store.js"
42
+ import type { LoggerHub } from "../logger/index.js"
43
+ import type { LogRecord } from "../logger/format.js"
44
+ import type { AccountManager } from "../adapter/accounts.js"
45
+ import type { LoginManager } from "../adapter/login.js"
46
+ import type { RenderRegistry } from "../render/registry.js"
47
+ import type { EventDispatcher } from "../pipeline/dispatch.js"
48
+ import type { PluginHost } from "../plugin/host.js"
49
+ import type { PluginMarket } from "../plugin/market.js"
50
+ import type { SystemInfo } from "../platform/system.js"
51
+ import type { ServerSink } from "../plugin/hooks.js"
52
+ import { browseDirectory } from "./browse.js"
53
+ import type { ManagedServer } from "./index.js"
54
+
55
+ /** 面板 API 的挂载前缀 */
56
+ export const API_SCOPE = "/api"
57
+
58
+ /** `GET logs` 默认返回的条数 */
59
+ const LOG_TAIL_DEFAULT = 200
60
+
61
+ /** `GET logs` 单次最多返回的条数 */
62
+ const LOG_TAIL_MAX = 2000
63
+
64
+ /** 配置写入的来源标记,日志里据此区分"面板改的"与"用户手改文件" */
65
+ const WRITE_SOURCE = "webui"
66
+
67
+ /** 合法的日志级别,用于校验查询参数 */
68
+ const LOG_LEVELS: readonly string[] = ["trace", "debug", "info", "warn", "error", "fatal", "silent"]
69
+
70
+ /** 一条面板 HTTP 端点的描述 */
71
+ export interface ApiRoute {
72
+ /** HTTP 方法 */
73
+ readonly method: HttpMethod
74
+ /** 相对 `API_SCOPE` 的路径,如 `config/:name` */
75
+ readonly path: string
76
+ /** 处理函数 */
77
+ readonly handler: RouteHandler
78
+ /** 注册选项;缺省即"要令牌、1MB 体上限" */
79
+ readonly options?: RouteOptions
80
+ }
81
+
82
+ /** 一条面板 WebSocket 端点的描述 */
83
+ export interface ApiWebSocket {
84
+ /** 相对 `API_SCOPE` 的路径 */
85
+ readonly path: string
86
+ /** 每个连接调用一次 */
87
+ readonly handler: WebSocketHandler
88
+ }
89
+
90
+ /** 面板 API 的完整端点清单 */
91
+ export interface ApiSurface {
92
+ /** HTTP 端点 */
93
+ readonly routes: readonly ApiRoute[]
94
+ /** WebSocket 端点 */
95
+ readonly websockets: readonly ApiWebSocket[]
96
+ }
97
+
98
+ /**
99
+ * 命令、定时任务与中间件清单
100
+ *
101
+ * 与 `kernel/app.ts` 的 `KernelSubsystems.registries` 同形。这里重新声明一份而不是
102
+ * import 那个类型,是为了不让 api 反向依赖 kernel(见文件头第 1 条)。
103
+ */
104
+ export interface ApiRegistries {
105
+ /**
106
+ * 列出全部命令
107
+ * @returns 命令描述数组
108
+ */
109
+ commands(): CommandInfo[]
110
+
111
+ /**
112
+ * 列出全部定时任务
113
+ * @returns 任务描述数组
114
+ */
115
+ tasks(): TaskInfo[]
116
+
117
+ /**
118
+ * 列出全部中间件,顺序即实际的执行顺序
119
+ * @returns 中间件描述数组
120
+ */
121
+ middlewares(): MiddlewareInfo[]
122
+ }
123
+
124
+ /** 面板 API 需要的全部依赖 */
125
+ export interface ApiDeps {
126
+ /** 内核版本 */
127
+ readonly version: string
128
+ /** 目录布局 */
129
+ readonly paths: RuntimePaths
130
+ /** 运行环境 */
131
+ readonly platform: PlatformInfo
132
+ /** 面板自己的日志器 */
133
+ readonly logger: Logger
134
+ /** 内核配置句柄(读 `server.readonly` 与各项设置) */
135
+ readonly config: CoreConfigHandle
136
+ /** 配置仓库(面板要能改任意插件的配置) */
137
+ readonly configStore: ConfigStore
138
+ /** 日志枢纽 */
139
+ readonly loggerHub: LoggerHub
140
+ /** 插件宿主 */
141
+ readonly plugins: PluginHost
142
+ /**
143
+ * 插件市场
144
+ *
145
+ * 可选:市场依赖网络与插件目录写权限,嵌入式使用场景可以不提供,
146
+ * 此时相关端点一律返回 501,而不是让面板拿到一个空列表误以为市场是空的。
147
+ */
148
+ readonly market?: PluginMarket
149
+ /** 命令与任务清单 */
150
+ readonly registries: ApiRegistries
151
+ /** 适配器注册表(此处只读视图即可满足需要:注册由插件负责) */
152
+ readonly adapters: AdapterRegistryView
153
+ /** 账号管理器 */
154
+ readonly accounts: AccountManager
155
+ /** 登录会话管理器 */
156
+ readonly logins: LoginManager
157
+ /** 在线 Bot 注册表 */
158
+ readonly bots: BotRegistryView
159
+ /** 渲染器注册表 */
160
+ readonly renderers: RenderRegistry
161
+ /** 事件分发器(读积压计数) */
162
+ readonly dispatcher: EventDispatcher
163
+ /** 共享服务器(读监听信息与路由表) */
164
+ readonly server: ManagedServer
165
+
166
+ /**
167
+ * 取应用状态
168
+ *
169
+ * 是取值函数而不是字段:状态会变,而端点清单只建一次。
170
+ * @returns `created` / `starting` / `running` / `stopping` / `stopped`
171
+ */
172
+ status(): string
173
+
174
+ /**
175
+ * 取启动时间戳(毫秒)
176
+ * @returns 时间戳
177
+ */
178
+ startedAt(): number
179
+
180
+ /**
181
+ * 采样资源占用
182
+ * @returns 内存与 CPU 快照
183
+ */
184
+ usage(): ResourceUsage
185
+
186
+ /**
187
+ * 采样磁盘与显卡
188
+ *
189
+ * 与 `usage()` 分开的理由是**耗时不同量级**:`usage()` 只读进程自己的计数器,
190
+ * 同步且微秒级;本项要量文件系统、还可能 spawn 一个 nvidia-smi,故是异步的。
191
+ * 合成一个的话,`GET /api/overview` 就得跟着变成「等磁盘」的端点 ——
192
+ * 而它是面板每 5 秒必拉的那一个。
193
+ * @returns 磁盘与显卡快照
194
+ */
195
+ system(): Promise<SystemInfo>
196
+ }
197
+
198
+ /**
199
+ * 面板能改的配置句柄
200
+ *
201
+ * `ConfigStore.get()` 返回的是 `ConfigFile<any>`(仓库里存着各插件各自的类型,
202
+ * 取出来时已经无从得知)。这里声明一个只含面板真正会用到的成员的窄接口,
203
+ * 好处是本文件内部再也不出现 `any`:类型检查在这道边界上收敛一次,
204
+ * 后面所有调用点都是有类型的。
205
+ */
206
+ interface WritableConfig {
207
+ /** 配置名 */
208
+ readonly name: string
209
+ /** 展示标题 */
210
+ readonly title: string
211
+ /** 文件绝对路径 */
212
+ readonly file: string
213
+ /** 表单描述 */
214
+ readonly schema: SchemaDescriptor
215
+
216
+ /**
217
+ * 读当前值
218
+ * @returns 只读快照
219
+ */
220
+ get(): unknown
221
+
222
+ /**
223
+ * 局部合并写入
224
+ * @param patch 要改的字段
225
+ * @param source 变更来源
226
+ * @returns 写入后的快照
227
+ */
228
+ patch(patch: Record<string, unknown>, source: typeof WRITE_SOURCE): Promise<unknown>
229
+
230
+ /**
231
+ * 整体替换
232
+ * @param value 新值
233
+ * @param source 变更来源
234
+ * @returns 写入后的快照
235
+ */
236
+ replace(value: unknown, source: typeof WRITE_SOURCE): Promise<unknown>
237
+
238
+ /**
239
+ * 恢复默认值
240
+ * @param source 变更来源
241
+ * @returns 写入后的快照
242
+ */
243
+ reset(source: typeof WRITE_SOURCE): Promise<unknown>
244
+ }
245
+
246
+ /** 带状态码的错误,`ManagedServer` 的错误处理器按 `statusCode` 回状态 */
247
+ interface ApiError extends Error {
248
+ /** HTTP 状态码 */
249
+ statusCode: number
250
+ }
251
+
252
+ /**
253
+ * 造一个带状态码的错误
254
+ * @param status HTTP 状态码
255
+ * @param message 中文说明,会原样回给面板
256
+ * @returns 错误对象
257
+ */
258
+ function fail(status: number, message: string): ApiError {
259
+ const err = new Error(message) as ApiError
260
+ err.statusCode = status
261
+ return err
262
+ }
263
+
264
+ /**
265
+ * 取错误的可读信息
266
+ * @param err 任意抛出物
267
+ * @returns 文本
268
+ */
269
+ function messageOf(err: unknown): string {
270
+ return err instanceof Error ? err.message : String(err)
271
+ }
272
+
273
+ /**
274
+ * 把请求体当作对象取出
275
+ * @param body 已解析的请求体
276
+ * @returns 键值对
277
+ * @throws 请求体不是 JSON 对象时抛 400
278
+ */
279
+ function objectOf(body: unknown): Record<string, unknown> {
280
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
281
+ throw fail(400, "请求体必须是一个 JSON 对象")
282
+ }
283
+ return body as Record<string, unknown>
284
+ }
285
+
286
+ /**
287
+ * 从对象里取必填字符串
288
+ * @param obj 请求体
289
+ * @param key 字段名
290
+ * @returns 非空字符串
291
+ * @throws 缺失或类型不对时抛 400
292
+ */
293
+ function requireString(obj: Record<string, unknown>, key: string): string {
294
+ const value = obj[key]
295
+ if (typeof value !== "string" || value === "") throw fail(400, `字段 ${key} 必填,且必须是非空字符串`)
296
+ return value
297
+ }
298
+
299
+ /**
300
+ * 从对象里取可选字符串
301
+ * @param obj 请求体
302
+ * @param key 字段名
303
+ * @returns 字符串;缺失时 undefined
304
+ * @throws 存在但类型不对时抛 400
305
+ */
306
+ function optionalString(obj: Record<string, unknown>, key: string): string | undefined {
307
+ const value = obj[key]
308
+ if (value === undefined || value === null) return undefined
309
+ if (typeof value !== "string") throw fail(400, `字段 ${key} 必须是字符串`)
310
+ return value
311
+ }
312
+
313
+ /**
314
+ * 从对象里取可选布尔
315
+ * @param obj 请求体
316
+ * @param key 字段名
317
+ * @returns 布尔;缺失时 undefined
318
+ * @throws 存在但类型不对时抛 400
319
+ */
320
+ function optionalBoolean(obj: Record<string, unknown>, key: string): boolean | undefined {
321
+ const value = obj[key]
322
+ if (value === undefined || value === null) return undefined
323
+ if (typeof value !== "boolean") throw fail(400, `字段 ${key} 必须是布尔值`)
324
+ return value
325
+ }
326
+
327
+ /**
328
+ * 取查询串里的单个值
329
+ *
330
+ * 查询串允许同名重复(`?scope=a&scope=b`),此时 Fastify 给出数组。面板不需要
331
+ * 多值语义,取第一个即可 —— 报错反而会让"手动拼 URL 调试"变得麻烦。
332
+ * @param query 查询串对象
333
+ * @param key 参数名
334
+ * @returns 字符串;缺失时 undefined
335
+ */
336
+ function queryOf(query: RouteRequest["query"], key: string): string | undefined {
337
+ const value = query[key]
338
+ if (Array.isArray(value)) return value[0]
339
+ return value
340
+ }
341
+
342
+ /**
343
+ * 解析查询串里的正整数
344
+ * @param query 查询串对象
345
+ * @param key 参数名
346
+ * @param fallback 缺省值
347
+ * @param max 上限
348
+ * @returns 落在 `[1, max]` 内的整数
349
+ */
350
+ function countOf(query: RouteRequest["query"], key: string, fallback: number, max: number): number {
351
+ const raw = queryOf(query, key)
352
+ if (raw === undefined || raw === "") return fallback
353
+ const n = Number.parseInt(raw, 10)
354
+ if (!Number.isFinite(n) || n < 1) return fallback
355
+ return Math.min(n, max)
356
+ }
357
+
358
+ /**
359
+ * 解析查询串里的日志级别
360
+ * @param query 查询串对象
361
+ * @returns 级别;未指定或非法时 undefined
362
+ */
363
+ function levelOf(query: RouteRequest["query"]): LogLevel | undefined {
364
+ const raw = queryOf(query, "level")
365
+ if (raw === undefined || !LOG_LEVELS.includes(raw)) return undefined
366
+ return raw as LogLevel
367
+ }
368
+
369
+ /**
370
+ * 造出面板 API 的全部端点描述
371
+ *
372
+ * 只造描述,不注册 —— 注册由 `registerApi()` 或调用方自己做,理由见文件头第 1 条。
373
+ * 想单测某个端点时,直接从返回值里挑出来调它的 `handler` 即可,不需要起服务器。
374
+ * @param deps 依赖
375
+ * @returns 端点清单
376
+ */
377
+ export function createApiRoutes(deps: ApiDeps): ApiSurface {
378
+ const routes: ApiRoute[] = []
379
+ const websockets: ApiWebSocket[] = []
380
+
381
+ /** 只读模式下拦住写操作,见文件头第 2 条 */
382
+ const requireWritable = (): void => {
383
+ if (deps.config.get().server.readonly) {
384
+ throw fail(403, "面板处于只读模式(配置项 server.readonly 为 true),不能执行写操作")
385
+ }
386
+ }
387
+
388
+ /**
389
+ * 登记一条端点,并统一将 SchemaError 转换为 400
390
+ *
391
+ * SchemaError 中携带逐字段的 issues,直接抛出只会保留一条拼接后的消息文本,
392
+ * 前端即无法将错误标注至对应的表单项 —— 因此此处改为**返回**一个 400 信封。
393
+ */
394
+ const add = (method: HttpMethod, path: string, handler: RouteHandler, options?: RouteOptions): void => {
395
+ routes.push({
396
+ method,
397
+ path,
398
+ options,
399
+ handler: async req => {
400
+ try {
401
+ return await handler(req)
402
+ } catch (err) {
403
+ if (err instanceof SchemaError) {
404
+ return { status: 400, body: { error: err.message, issues: err.issues } }
405
+ }
406
+ throw err
407
+ }
408
+ }
409
+ })
410
+ }
411
+
412
+ /** 按名取配置句柄,取不到就是 404 */
413
+ const configOf = (name: string | undefined): WritableConfig => {
414
+ if (name === undefined || name === "") throw fail(400, "缺少配置名")
415
+ const file: WritableConfig | undefined = deps.configStore.get(name)
416
+ if (file === undefined) throw fail(404, `没有名为 ${name} 的配置。已声明的配置见 GET ${API_SCOPE}/config`)
417
+ return file
418
+ }
419
+
420
+ /** 按 id 取账号,取不到就是 404 */
421
+ const accountOf = (id: string | undefined): AccountState => {
422
+ if (id === undefined || id === "") throw fail(400, "缺少账号 id")
423
+ const state = deps.accounts.get(id)
424
+ if (state === undefined) throw fail(404, `没有 id 为 ${id} 的账号`)
425
+ return state
426
+ }
427
+
428
+ /* ────────────────────────────── 概览 ────────────────────────────── */
429
+
430
+ add("GET", "overview", () => {
431
+ const plugins = deps.plugins.list()
432
+ const accounts = deps.accounts.list()
433
+ return {
434
+ version: deps.version,
435
+ status: deps.status(),
436
+ startedAt: deps.startedAt(),
437
+ uptime: Date.now() - deps.startedAt(),
438
+ platform: deps.platform,
439
+ paths: deps.paths,
440
+ usage: deps.usage(),
441
+ server: {
442
+ ...deps.server.info,
443
+ readonly: deps.config.get().server.readonly,
444
+ connections: deps.server.connections
445
+ },
446
+ counts: {
447
+ plugins: plugins.length,
448
+ pluginsFailed: plugins.filter(p => p.status === "error").length,
449
+ commands: deps.registries.commands().length,
450
+ tasks: deps.registries.tasks().length,
451
+ adapters: deps.adapters.list().length,
452
+ accounts: accounts.length,
453
+ online: accounts.filter(a => a.status === "online").length,
454
+ renderers: deps.renderers.size,
455
+ logins: deps.logins.running
456
+ },
457
+ pipeline: { handled: deps.dispatcher.handled, queued: deps.dispatcher.queued }
458
+ }
459
+ })
460
+
461
+ /*
462
+ * 磁盘与显卡
463
+ *
464
+ * **单独一个端点,不并入 `GET /api/overview`。** 两条理由:
465
+ *
466
+ * 1) 概览是每 5 秒必拉的那一个,而本项要量文件系统、还可能 spawn 一个
467
+ * nvidia-smi。并进去就是让最热的端点等最慢的采样。
468
+ * 2) 概览页之外的地方(日后的插件页、排障页)可能只要磁盘不要那一大坨计数。
469
+ *
470
+ * **CPU 与内存刻意不在这里**,它们已在概览里 —— 同一事实供两份,两处的采样时刻
471
+ * 还不一样,面板上就会出现「CPU 卡片与 CPU 环不是一个数」。
472
+ *
473
+ * 不要令牌之外的额外权限:它报的是容量与型号,不含任何路径或文件名,
474
+ * 与 `GET fs` 那种「能看见目录树」不是一类东西,故只读模式下照常可用。
475
+ */
476
+ add("GET", "system", () => deps.system())
477
+
478
+ /* ────────────────────────────── 配置 ────────────────────────────── */
479
+
480
+ add("GET", "config", () => deps.configStore.list())
481
+
482
+ // 刻意**不脱敏**:读这个端点本身就需要令牌,而面板必须能显示与轮换令牌、
483
+ // 显示适配器的连接密钥。返回掩码会让"改一个字段就得重填全部密码"成为常态,
484
+ // 那才是真正会逼用户把密钥写到别处的设计
485
+ add("GET", "config/:name", req => {
486
+ const file = configOf(req.params.name)
487
+ return { name: file.name, title: file.title, file: file.file, schema: file.schema, value: file.get() }
488
+ })
489
+
490
+ add("PATCH", "config/:name", async req => {
491
+ requireWritable()
492
+ const file = configOf(req.params.name)
493
+ return { value: await file.patch(objectOf(req.body), WRITE_SOURCE) }
494
+ })
495
+
496
+ add("PUT", "config/:name", async req => {
497
+ requireWritable()
498
+ const file = configOf(req.params.name)
499
+ return { value: await file.replace(objectOf(req.body), WRITE_SOURCE) }
500
+ })
501
+
502
+ add("POST", "config/:name/reset", async req => {
503
+ requireWritable()
504
+ const file = configOf(req.params.name)
505
+ deps.logger.warn(`面板重置了配置 ${file.name}`)
506
+ return { value: await file.reset(WRITE_SOURCE) }
507
+ })
508
+
509
+ /* ────────────────────────────── 插件 ────────────────────────────── */
510
+
511
+ add("GET", "plugins", () => deps.plugins.list())
512
+
513
+ add("GET", "plugins/:name", req => {
514
+ const name = req.params.name ?? ""
515
+ const state = deps.plugins.get(name)
516
+ if (state === undefined) throw fail(404, `没有名为 ${name} 的插件`)
517
+ return state
518
+ })
519
+
520
+ add("POST", "plugins/:name/reload", async req => {
521
+ requireWritable()
522
+ const name = req.params.name ?? ""
523
+ const ok = await deps.plugins.reload(name)
524
+ if (!ok) throw fail(400, `重载 ${name} 失败,详情见日志`)
525
+ return { name, reloaded: true }
526
+ })
527
+
528
+ add("POST", "plugins/:name/unload", async req => {
529
+ requireWritable()
530
+ const name = req.params.name ?? ""
531
+ const ok = await deps.plugins.unload(name)
532
+ if (!ok) throw fail(404, `插件 ${name} 当前并未加载`)
533
+ return { name, unloaded: true }
534
+ })
535
+
536
+ /* ────────────────────────── 插件市场 ────────────────────────── */
537
+
538
+ /**
539
+ * 取市场实例
540
+ * @returns 市场实例
541
+ * @throws 当前部署未提供市场时以 501 结束请求
542
+ */
543
+ const market = (): PluginMarket => {
544
+ if (deps.market === undefined) throw fail(501, "当前部署未启用插件市场")
545
+ return deps.market
546
+ }
547
+
548
+ /**
549
+ * 安装或更新一个市场插件,并按需加载
550
+ *
551
+ * 更新前先卸载:旧版本的模块若留在内存中,其注册的命令仍会响应,而磁盘上已是
552
+ * 新版本代码,两者不一致。失败一律折成 400 —— 名称、版本、网络与归档内容的问题
553
+ * 都由请求方的输入或环境决定,不是服务端故障。
554
+ * @param name 插件名
555
+ * @param load 安装后是否立即加载
556
+ * @param replace 目标已存在时是否覆盖
557
+ * @returns 安装结果,附本次加载成功的插件名
558
+ * @throws 安装失败时以 400 结束请求
559
+ */
560
+ const installFromMarket = async (
561
+ name: string,
562
+ load: boolean,
563
+ replace: boolean
564
+ ): Promise<Record<string, unknown>> => {
565
+ const instance = market()
566
+ let unloaded = false
567
+ try {
568
+ if (replace && deps.plugins.get(name) !== undefined) unloaded = await deps.plugins.unload(name)
569
+ const result = replace ? await instance.update(name) : await instance.install(name)
570
+ const loaded = load ? [...(await deps.plugins.loadAll()).loaded] : []
571
+ return { ...result, unloaded, loaded }
572
+ } catch (err) {
573
+ throw fail(400, err instanceof Error ? err.message : String(err))
574
+ }
575
+ }
576
+
577
+ // 刷新索引不需要写权限:该动作只更新索引缓存,不改动配置、账号与已装插件。
578
+ // 只读模式的用途是防止误操作改动部署状态,浏览可安装的插件不属于此列
579
+ add("GET", "market", req => market().list(queryOf(req.query, "refresh") === "1"))
580
+
581
+ add("POST", "market/refresh", () => market().list(true))
582
+
583
+ add("POST", "market/install", async req => {
584
+ requireWritable()
585
+ const body = objectOf(req.body)
586
+ const name = requireString(body, "name")
587
+ return installFromMarket(name, optionalBoolean(body, "load") ?? true, false)
588
+ })
589
+
590
+ add("POST", "market/:name/update", async req => {
591
+ requireWritable()
592
+ const name = req.params.name ?? ""
593
+ const body = req.body === undefined ? {} : objectOf(req.body)
594
+ return installFromMarket(name, optionalBoolean(body, "load") ?? true, true)
595
+ })
596
+
597
+ add("DELETE", "market/:name", async req => {
598
+ requireWritable()
599
+ const name = req.params.name ?? ""
600
+ // 先卸载再删除目录:若目录已删除而插件仍在内存中运行,其命令仍会响应,
601
+ // 而重启后则不再存在 —— 此类不一致比一次失败的卸载更难排查
602
+ const unloaded = deps.plugins.get(name) === undefined ? false : await deps.plugins.unload(name)
603
+ let removed: boolean
604
+ try {
605
+ removed = await market().remove(name)
606
+ } catch (err) {
607
+ throw fail(400, err instanceof Error ? err.message : String(err))
608
+ }
609
+ if (!removed) throw fail(404, `插件目录 ${name} 不存在`)
610
+ return { name, unloaded, removed }
611
+ })
612
+
613
+ add("GET", "commands", () => deps.registries.commands())
614
+ add("GET", "tasks", () => deps.registries.tasks())
615
+
616
+ /*
617
+ * 中间件清单
618
+ *
619
+ * 与 `commands` / `tasks` 同一形制,都是内省:面板的插件卡片一直显示着中间件的
620
+ * **条数**,而点进去看不到是哪几条 —— 排查「消息被谁拦下了」时,条数没有用。
621
+ *
622
+ * 顺序即实际的执行顺序,不是注册顺序,理由见 `MiddlewarePipeline.list()`。
623
+ */
624
+ add("GET", "middlewares", () => deps.registries.middlewares())
625
+
626
+ add("GET", "renderers", () => deps.renderers.list())
627
+
628
+ /* ────────────────────── 适配器与账号 ────────────────────── */
629
+
630
+ add("GET", "adapters", () => deps.adapters.list())
631
+
632
+ // 账号配置里含连接密钥,同 GET config/:name 一样刻意不脱敏
633
+ add("GET", "accounts", () => deps.accounts.list())
634
+
635
+ add("GET", "accounts/:id", req => accountOf(req.params.id))
636
+
637
+ add("POST", "accounts", async req => {
638
+ requireWritable()
639
+ const body = objectOf(req.body)
640
+ const adapterId = requireString(body, "adapterId")
641
+ const config = body.config === undefined ? {} : objectOf(body.config)
642
+ const label = optionalString(body, "label")
643
+ const enabled = optionalBoolean(body, "enabled") ?? true
644
+ // create() 的失败几乎都是用户输入问题(适配器没装、配置字段填错),
645
+ // 而 validateAccount 的错误信息按契约就是给用户看的 —— 一律 400
646
+ try {
647
+ return { status: 201, body: await deps.accounts.create(adapterId, config, label, enabled) }
648
+ } catch (err) {
649
+ if (err instanceof SchemaError) throw err
650
+ throw fail(400, messageOf(err))
651
+ }
652
+ })
653
+
654
+ add("PATCH", "accounts/:id", async req => {
655
+ requireWritable()
656
+ const state = accountOf(req.params.id)
657
+ const body = objectOf(req.body)
658
+ const patch: { config?: unknown; label?: string; enabled?: boolean } = {}
659
+ if (body.config !== undefined) patch.config = objectOf(body.config)
660
+ const label = optionalString(body, "label")
661
+ if (label !== undefined) patch.label = label
662
+ const enabled = optionalBoolean(body, "enabled")
663
+ if (enabled !== undefined) patch.enabled = enabled
664
+ try {
665
+ return await deps.accounts.update(state.record.id, patch)
666
+ } catch (err) {
667
+ if (err instanceof SchemaError) throw err
668
+ throw fail(400, messageOf(err))
669
+ }
670
+ })
671
+
672
+ add("DELETE", "accounts/:id", async req => {
673
+ requireWritable()
674
+ const state = accountOf(req.params.id)
675
+ await deps.accounts.remove(state.record.id)
676
+ return undefined
677
+ })
678
+
679
+ // 连接类动作全部返回**动作之后的状态**而不是空体:面板据此立刻刷新那一行,
680
+ // 少一次往返,也不会出现"点了连接但列表还显示离线"的错觉
681
+ add("POST", "accounts/:id/connect", async req => {
682
+ requireWritable()
683
+ const state = accountOf(req.params.id)
684
+ await deps.accounts.connect(state.record.id)
685
+ return deps.accounts.get(state.record.id)
686
+ })
687
+
688
+ add("POST", "accounts/:id/disconnect", async req => {
689
+ requireWritable()
690
+ const state = accountOf(req.params.id)
691
+ await deps.accounts.disconnect(state.record.id, "面板手动断开")
692
+ return deps.accounts.get(state.record.id)
693
+ })
694
+
695
+ add("POST", "accounts/:id/reconnect", async req => {
696
+ requireWritable()
697
+ const state = accountOf(req.params.id)
698
+ await deps.accounts.reconnect(state.record.id)
699
+ return deps.accounts.get(state.record.id)
700
+ })
701
+
702
+ /* ────────────────────────── 交互式登录 ────────────────────────── */
703
+
704
+ add("GET", "logins", () => deps.logins.list())
705
+
706
+ add("GET", "logins/:id", req => {
707
+ const id = req.params.id ?? ""
708
+ const snapshot = deps.logins.snapshot(id)
709
+ if (snapshot === undefined) throw fail(404, `登录会话 ${id} 不存在或已结束并清理`)
710
+ return snapshot
711
+ })
712
+
713
+ // start() 是同步返回的:登录流程在后台跑,面板拿到初始快照后轮询或等 WS 推送。
714
+ // 这里刻意不做成"等到登录完成才回响应" —— 扫码登录能耗上几分钟,
715
+ // 任何一层反向代理都会先把这条请求掐断
716
+ add("POST", "logins", req => {
717
+ requireWritable()
718
+ const body = objectOf(req.body)
719
+ const adapterId = requireString(body, "adapterId")
720
+ const mode = requireString(body, "mode")
721
+ const label = optionalString(body, "label")
722
+ try {
723
+ return { status: 201, body: deps.logins.start(adapterId, mode, label) }
724
+ } catch (err) {
725
+ throw fail(400, messageOf(err))
726
+ }
727
+ })
728
+
729
+ add("POST", "logins/:id/answer", req => {
730
+ requireWritable()
731
+ const id = req.params.id ?? ""
732
+ const body = objectOf(req.body)
733
+ const seq = body.seq
734
+ if (typeof seq !== "number" || !Number.isInteger(seq)) throw fail(400, "字段 seq 必填,且必须是整数")
735
+ // 答复被拒有三种原因(会话没了、当前没在提问、序号过期),共同的正确反应
736
+ // 都是"重新读一次快照",所以不区分状态码,只把当前快照一并回去
737
+ const accepted = deps.logins.answer(id, seq, body.value)
738
+ return { accepted, session: deps.logins.snapshot(id) }
739
+ })
740
+
741
+ add("DELETE", "logins/:id", req => {
742
+ requireWritable()
743
+ const id = req.params.id ?? ""
744
+ return { cancelled: deps.logins.cancel(id), session: deps.logins.snapshot(id) }
745
+ })
746
+
747
+ /* ────────────────────────────── 日志 ────────────────────────────── */
748
+
749
+ add("GET", "logs", req => {
750
+ const level = levelOf(req.query)
751
+ const scope = queryOf(req.query, "scope")
752
+ const keyword = queryOf(req.query, "keyword")
753
+ const query: { limit: number; level?: LogLevel; scope?: string; keyword?: string } = {
754
+ limit: countOf(req.query, "limit", LOG_TAIL_DEFAULT, LOG_TAIL_MAX)
755
+ }
756
+ if (level !== undefined) query.level = level
757
+ if (scope !== undefined && scope !== "") query.scope = scope
758
+ if (keyword !== undefined && keyword !== "") query.keyword = keyword
759
+ return { file: deps.loggerHub.file, level: deps.loggerHub.level, records: deps.loggerHub.tail(query) }
760
+ })
761
+
762
+ // 实时日志:只推此后新产生的记录,历史请走 GET logs(见文件头第 4 条)。
763
+ // 过滤条件放在握手的查询串里而不是靠客户端发一帧配置过来 —— 后者存在
764
+ // "配置帧到达之前已经推了一批不该推的记录"的窗口期
765
+ websockets.push({
766
+ path: "logs",
767
+ handler: (conn, req) => {
768
+ const level = levelOf(req.query)
769
+ const scope = queryOf(req.query, "scope")
770
+ const keyword = queryOf(req.query, "keyword")
771
+ const threshold = level === undefined ? undefined : LOG_LEVELS.indexOf(level)
772
+
773
+ /** 判断一条记录是否要推给这个连接 */
774
+ const matches = (rec: LogRecord): boolean => {
775
+ if (threshold !== undefined && LOG_LEVELS.indexOf(rec.level) < threshold) return false
776
+ if (scope !== undefined && scope !== "" && rec.scope !== scope) return false
777
+ if (keyword !== undefined && keyword !== "" && !rec.msg.includes(keyword)) return false
778
+ return true
779
+ }
780
+
781
+ const off = deps.loggerHub.subscribe(rec => {
782
+ if (!conn.open) return
783
+ if (!matches(rec)) return
784
+ conn.send(JSON.stringify(rec))
785
+ })
786
+ // 必须摘:LoggerHub 的订阅者表是进程级的,连接断了还留着的话,
787
+ // 面板每刷新一次就永久多一个订阅者,且每条日志都会往一个死 socket 上写
788
+ conn.onClose(() => off())
789
+ }
790
+ })
791
+
792
+ /* ───────────────────────── 服务器自身 ───────────────────────── */
793
+
794
+ // 路由表是排障用的:插件说"我注册了 /plugin/foo/bar 但访问 404"时,
795
+ // 第一件要确认的事就是它到底有没有出现在这张表里
796
+ add("GET", "server", () => ({
797
+ ...deps.server.info,
798
+ readonly: deps.config.get().server.readonly,
799
+ connections: deps.server.connections,
800
+ routes: deps.server.listRoutes(),
801
+ websockets: deps.server.listWebsockets(),
802
+ static: deps.server.listStatic()
803
+ }))
804
+
805
+ /* ────────────────────────── 目录浏览 ────────────────────────── */
806
+
807
+ /*
808
+ * 只读列目录:`file` / `dir` 配置项的候选来源
809
+ *
810
+ * 三条边界写在这里,因为它们都是**面板策略**,与文件系统本身无关
811
+ * (文件系统那一侧的四条限制见 browse.ts 的文件头):
812
+ *
813
+ * 1) **要令牌。** 与其余 `/api/*` 一致 —— `add()` 不传 options 即为「要令牌」。
814
+ * 未设令牌时服务器只放行本机(见 auth.ts),故该端点不会凭空对外开放。
815
+ * 2) **只读模式下禁用。** 表面上矛盾:一个只读接口为何在只读模式下不可用?
816
+ * 因为它的用途只有「给配置项挑一个路径」,而只读模式下配置根本改不了 ——
817
+ * 此时它提供的唯一东西就是「让访问者看见这台机器的目录树」,那是纯粹的扩权。
818
+ * 宁可在用不到它的场合关掉。这一条是 2026-08-25 定夺的第 4 项。
819
+ * 3) **起点由前端给,不由内核猜。** 「从哪个目录开始浏览」取决于字段当前填的是什么,
820
+ * 只有前端知道。不带 `path` 时 Windows 列盘符、其余系统列 `/`。
821
+ */
822
+ add("GET", "fs", async req => {
823
+ if (deps.config.get().server.readonly) {
824
+ throw fail(403, "面板处于只读模式(配置项 server.readonly 为 true),目录浏览已停用")
825
+ }
826
+ const result = await browseDirectory(queryOf(req.query, "path"))
827
+ if (!result.ok) throw fail(result.status, result.message)
828
+ return result.listing
829
+ })
830
+
831
+ return { routes, websockets }
832
+ }
833
+
834
+ /**
835
+ * 将面板 API 挂载至服务器
836
+ *
837
+ * 接收 `ServerSink` 而非 `ManagedServer`:注册仅使用接缝中的三个方法,
838
+ * 收窄接口使该函数可在测试中对接一个测试替身服务器。
839
+ * @param server 服务器接缝
840
+ * @param surface 端点清单
841
+ * @returns 摘除全部端点的句柄;由 `App.stop()` 调用
842
+ */
843
+ export function installApiRoutes(server: ServerSink, surface: ApiSurface): Disposer {
844
+ const offs: Disposer[] = []
845
+ for (const route of surface.routes) {
846
+ offs.push(server.route(API_SCOPE, route.method, route.path, route.handler, route.options))
847
+ }
848
+ for (const ws of surface.websockets) {
849
+ offs.push(server.websocket(API_SCOPE, ws.path, ws.handler))
850
+ }
851
+ return () => {
852
+ // 逆序摘,且摘完清空:Disposer 本身是幂等的,但重复调用会白跑一遍查表
853
+ for (const off of offs.reverse()) off()
854
+ offs.length = 0
855
+ }
856
+ }
857
+
858
+ /**
859
+ * 构造并挂载面板 API
860
+ *
861
+ * `createApiRoutes` 与 `installApiRoutes` 的便捷组合,由 `kernel/app.ts` 使用。
862
+ * @param server 服务器接缝
863
+ * @param deps 依赖
864
+ * @returns 摘除全部端点的句柄
865
+ */
866
+ export function registerApi(server: ServerSink, deps: ApiDeps): Disposer {
867
+ return installApiRoutes(server, createApiRoutes(deps))
868
+ }
869
+