cortico 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 (311) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -0
  3. package/package.json +43 -0
  4. package/src/boot.ts +46 -0
  5. package/src/bot.ts +1342 -0
  6. package/src/config-file.ts +41 -0
  7. package/src/core/README.md +92 -0
  8. package/src/core/billing.ts +26 -0
  9. package/src/core/blobs.ts +106 -0
  10. package/src/core/bus.ts +311 -0
  11. package/src/core/config-schema.ts +201 -0
  12. package/src/core/config.ts +262 -0
  13. package/src/core/core.ts +699 -0
  14. package/src/core/cost.ts +244 -0
  15. package/src/core/event-store.ts +304 -0
  16. package/src/core/fork.ts +113 -0
  17. package/src/core/generation.ts +118 -0
  18. package/src/core/instance-lock.ts +215 -0
  19. package/src/core/ipc-logger.ts +105 -0
  20. package/src/core/language.ts +62 -0
  21. package/src/core/log-context.ts +38 -0
  22. package/src/core/loop.ts +1858 -0
  23. package/src/core/markers.ts +48 -0
  24. package/src/core/prefix.ts +121 -0
  25. package/src/core/run.ts +95 -0
  26. package/src/core/secrets.ts +18 -0
  27. package/src/core/session.ts +56 -0
  28. package/src/core/sessions.ts +261 -0
  29. package/src/core/state.ts +104 -0
  30. package/src/core/template.ts +72 -0
  31. package/src/core/timers.ts +129 -0
  32. package/src/core/tool-log.ts +135 -0
  33. package/src/core/transcript.ts +92 -0
  34. package/src/core/truncate.ts +121 -0
  35. package/src/core/types.ts +1140 -0
  36. package/src/core/usage-log.ts +79 -0
  37. package/src/core/util.ts +386 -0
  38. package/src/deploy-listing.ts +160 -0
  39. package/src/deploy.ts +162 -0
  40. package/src/extensions/README.md +72 -0
  41. package/src/extensions/dry-mount.ts +390 -0
  42. package/src/extensions/manifest.ts +153 -0
  43. package/src/extensions/runtime.ts +42 -0
  44. package/src/extensions.ts +519 -0
  45. package/src/launcher.ts +285 -0
  46. package/src/paths.ts +155 -0
  47. package/src/protocol/open-responses/LICENSE +201 -0
  48. package/src/protocol/open-responses/README.md +14 -0
  49. package/src/protocol/open-responses/context-helpers.ts +27 -0
  50. package/src/protocol/open-responses/context-log.ts +59 -0
  51. package/src/protocol/open-responses/context.ts +89 -0
  52. package/src/protocol/open-responses/generated.ts +115 -0
  53. package/src/protocol/open-responses/index.ts +33 -0
  54. package/src/protocol/open-responses/openapi.json +4230 -0
  55. package/src/protocol/open-responses/stream.ts +146 -0
  56. package/src/protocol/open-responses/tokens.ts +31 -0
  57. package/src/providers/README.md +86 -0
  58. package/src/providers/base.ts +111 -0
  59. package/src/providers/configuration.ts +97 -0
  60. package/src/providers/console/config.ts +21 -0
  61. package/src/providers/console/settings.ts +443 -0
  62. package/src/providers/console/strings.ts +74 -0
  63. package/src/providers/console/types.ts +11 -0
  64. package/src/providers/llamacpp/archive.ts +148 -0
  65. package/src/providers/llamacpp/catalog.ts +247 -0
  66. package/src/providers/llamacpp/config.ts +36 -0
  67. package/src/providers/llamacpp/console/client.ts +6 -0
  68. package/src/providers/llamacpp/console/models-panel.ts +120 -0
  69. package/src/providers/llamacpp/console/runtime-panel.ts +173 -0
  70. package/src/providers/llamacpp/console/server.ts +153 -0
  71. package/src/providers/llamacpp/index.ts +95 -0
  72. package/src/providers/llamacpp/native.ts +51 -0
  73. package/src/providers/llamacpp/options.ts +117 -0
  74. package/src/providers/llamacpp/runtime-store.ts +139 -0
  75. package/src/providers/llamacpp/runtime.ts +214 -0
  76. package/src/providers/llamacpp/server.ts +286 -0
  77. package/src/providers/llamacpp/strings.ts +230 -0
  78. package/src/providers/openai-responses-compat/config.ts +20 -0
  79. package/src/providers/openai-responses-compat/index.ts +89 -0
  80. package/src/providers/openai-responses-compat/native.ts +127 -0
  81. package/src/providers/openai-responses-compat/strings.ts +19 -0
  82. package/src/providers/pricebook.ts +75 -0
  83. package/src/providers/registry.ts +137 -0
  84. package/src/providers/strings.ts +71 -0
  85. package/src/providers/transport/chat.ts +156 -0
  86. package/src/providers/transport/errors.ts +48 -0
  87. package/src/providers/transport/history.ts +68 -0
  88. package/src/providers/transport/native-input.ts +83 -0
  89. package/src/providers/transport/native-types.ts +13 -0
  90. package/src/providers/transport/response-assembly.ts +191 -0
  91. package/src/providers/transport/response-http.ts +196 -0
  92. package/src/providers/transport/response-meters.ts +27 -0
  93. package/src/providers/transport/responses-input.ts +56 -0
  94. package/src/web/README.md +84 -0
  95. package/src/web/client/console-pages/builtins/llm-settings/panel.ts +625 -0
  96. package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +168 -0
  97. package/src/web/client/console-pages/builtins/llm-settings/strings.ts +213 -0
  98. package/src/web/client/console-pages/builtins.ts +10 -0
  99. package/src/web/client/console-pages/context.ts +135 -0
  100. package/src/web/client/console-pages/host.ts +633 -0
  101. package/src/web/client/console-pages/loader.ts +132 -0
  102. package/src/web/client/console-pages/strings.ts +90 -0
  103. package/src/web/client/console-pages/tools/strings.ts +49 -0
  104. package/src/web/client/console-pages/tools/view.ts +215 -0
  105. package/src/web/client/core/api.ts +200 -0
  106. package/src/web/client/core/language.ts +56 -0
  107. package/src/web/client/core/lifecycle.ts +132 -0
  108. package/src/web/client/core/router.ts +199 -0
  109. package/src/web/client/core/stream.ts +157 -0
  110. package/src/web/client/core/websocket.ts +82 -0
  111. package/src/web/client/features/appearance/index.ts +333 -0
  112. package/src/web/client/features/appearance/strings.ts +87 -0
  113. package/src/web/client/features/config/index.ts +11 -0
  114. package/src/web/client/features/config/strings.ts +41 -0
  115. package/src/web/client/features/config/view.ts +428 -0
  116. package/src/web/client/features/core/events.ts +197 -0
  117. package/src/web/client/features/core/index.ts +254 -0
  118. package/src/web/client/features/core/run.ts +56 -0
  119. package/src/web/client/features/core/runlog.ts +214 -0
  120. package/src/web/client/features/core/sessions.ts +53 -0
  121. package/src/web/client/features/core/strings.ts +157 -0
  122. package/src/web/client/features/extensions/index.ts +422 -0
  123. package/src/web/client/features/extensions/strings.ts +164 -0
  124. package/src/web/client/features/feature.ts +93 -0
  125. package/src/web/client/features/live/context.ts +280 -0
  126. package/src/web/client/features/live/fork.ts +156 -0
  127. package/src/web/client/features/live/index.ts +392 -0
  128. package/src/web/client/features/live/onboarding.ts +113 -0
  129. package/src/web/client/features/live/protocol.ts +122 -0
  130. package/src/web/client/features/live/sessions.ts +71 -0
  131. package/src/web/client/features/live/status.ts +68 -0
  132. package/src/web/client/features/live/strings.ts +252 -0
  133. package/src/web/client/features/live/timeline.ts +590 -0
  134. package/src/web/client/features/prompts/editor.ts +292 -0
  135. package/src/web/client/features/prompts/index.ts +346 -0
  136. package/src/web/client/features/prompts/strings.ts +136 -0
  137. package/src/web/client/features/prompts/view.ts +256 -0
  138. package/src/web/client/features/providers/index.ts +100 -0
  139. package/src/web/client/features/providers/strings.ts +23 -0
  140. package/src/web/client/features/settings/general.ts +28 -0
  141. package/src/web/client/features/settings/index.ts +82 -0
  142. package/src/web/client/features/settings/strings.ts +33 -0
  143. package/src/web/client/features/storage/index.ts +1 -0
  144. package/src/web/client/features/storage/strings.ts +45 -0
  145. package/src/web/client/features/storage/view.ts +154 -0
  146. package/src/web/client/features/usage/chart.ts +486 -0
  147. package/src/web/client/features/usage/index.ts +407 -0
  148. package/src/web/client/features/usage/labels.ts +46 -0
  149. package/src/web/client/features/usage/range.ts +36 -0
  150. package/src/web/client/features/usage/state.ts +37 -0
  151. package/src/web/client/features/usage/strings.ts +191 -0
  152. package/src/web/client/features/usage/tooltip.ts +86 -0
  153. package/src/web/client/features/usage/types.ts +96 -0
  154. package/src/web/client/features/worlds/index.ts +424 -0
  155. package/src/web/client/features/worlds/strings.ts +121 -0
  156. package/src/web/client/main.ts +273 -0
  157. package/src/web/client/shell/avatar.ts +233 -0
  158. package/src/web/client/shell/index.ts +549 -0
  159. package/src/web/client/shell/strings.ts +89 -0
  160. package/src/web/client/theme/handoff.ts +43 -0
  161. package/src/web/client/theme/palette.ts +109 -0
  162. package/src/web/client/theme/registry.ts +130 -0
  163. package/src/web/client/theme/storage.ts +72 -0
  164. package/src/web/client/theme/strings.ts +123 -0
  165. package/src/web/client/theme/studio.ts +366 -0
  166. package/src/web/client/ui/actions.ts +109 -0
  167. package/src/web/client/ui/data.ts +185 -0
  168. package/src/web/client/ui/dom.ts +23 -0
  169. package/src/web/client/ui/fields.ts +191 -0
  170. package/src/web/client/ui/format.ts +75 -0
  171. package/src/web/client/ui/icons.ts +183 -0
  172. package/src/web/client/ui/images.ts +91 -0
  173. package/src/web/client/ui/index.ts +89 -0
  174. package/src/web/client/ui/lamp.ts +125 -0
  175. package/src/web/client/ui/log.ts +98 -0
  176. package/src/web/client/ui/overlay.ts +184 -0
  177. package/src/web/client/ui/page.ts +8 -0
  178. package/src/web/client/ui/prompt-input.tsx +311 -0
  179. package/src/web/client/ui/sheet.ts +131 -0
  180. package/src/web/client/ui/strings.ts +71 -0
  181. package/src/web/console-pages.ts +367 -0
  182. package/src/web/files.ts +107 -0
  183. package/src/web/path-picker.ts +296 -0
  184. package/src/web/public/index.html +17 -0
  185. package/src/web/public/styles.css +1715 -0
  186. package/src/web/server.ts +1883 -0
  187. package/src/web/shared/client-panel.ts +603 -0
  188. package/src/web/shared/console-protocol.ts +560 -0
  189. package/src/web/shared/css.d.ts +1 -0
  190. package/src/web/shared/path-picker.ts +23 -0
  191. package/src/web/shared/theme.ts +256 -0
  192. package/src/web/theme-store.ts +37 -0
  193. package/src/world.ts +449 -0
  194. package/src/worlds/bilibili/ENV_PROMPT.md +5 -0
  195. package/src/worlds/bilibili/README.md +176 -0
  196. package/src/worlds/bilibili/audience-admission.ts +995 -0
  197. package/src/worlds/bilibili/client.ts +528 -0
  198. package/src/worlds/bilibili/coalescing-buffer.ts +125 -0
  199. package/src/worlds/bilibili/config.ts +185 -0
  200. package/src/worlds/bilibili/console/client.ts +202 -0
  201. package/src/worlds/bilibili/definition.ts +35 -0
  202. package/src/worlds/bilibili/gift-frame.ts +131 -0
  203. package/src/worlds/bilibili/normalize.ts +452 -0
  204. package/src/worlds/bilibili/overlay/announcement.ts +76 -0
  205. package/src/worlds/bilibili/overlay/assets.ts +82 -0
  206. package/src/worlds/bilibili/overlay/model.ts +494 -0
  207. package/src/worlds/bilibili/overlay/project.ts +202 -0
  208. package/src/worlds/bilibili/overlay/server.ts +286 -0
  209. package/src/worlds/bilibili/overlay/types.ts +217 -0
  210. package/src/worlds/bilibili/overlay/web/app.js +690 -0
  211. package/src/worlds/bilibili/overlay/web/editor.css +297 -0
  212. package/src/worlds/bilibili/overlay/web/editor.html +102 -0
  213. package/src/worlds/bilibili/overlay/web/editor.js +2116 -0
  214. package/src/worlds/bilibili/overlay/web/overlay.html +13 -0
  215. package/src/worlds/bilibili/overlay/web/styles.css +258 -0
  216. package/src/worlds/bilibili/protobuf.ts +123 -0
  217. package/src/worlds/bilibili/wire.ts +94 -0
  218. package/src/worlds/bilibili/world.ts +1389 -0
  219. package/src/worlds/console-fixture/ENV_PROMPT.md +1 -0
  220. package/src/worlds/console-fixture/console/client.ts +72 -0
  221. package/src/worlds/console-fixture/world.ts +73 -0
  222. package/src/worlds/index.ts +15 -0
  223. package/src/worlds/minecraft/ENV_PROMPT.md +32 -0
  224. package/src/worlds/minecraft/ENV_PROMPT_CAMERA.md +2 -0
  225. package/src/worlds/minecraft/LICENSE-mineflayer-pathfinder.txt +21 -0
  226. package/src/worlds/minecraft/README.md +917 -0
  227. package/src/worlds/minecraft/blueprint-plan.ts +915 -0
  228. package/src/worlds/minecraft/blueprint-registry.ts +566 -0
  229. package/src/worlds/minecraft/blueprint-repair.ts +236 -0
  230. package/src/worlds/minecraft/blueprint-resource.ts +272 -0
  231. package/src/worlds/minecraft/blueprint.ts +492 -0
  232. package/src/worlds/minecraft/body-lease.ts +340 -0
  233. package/src/worlds/minecraft/bridge.ts +749 -0
  234. package/src/worlds/minecraft/check.ts +651 -0
  235. package/src/worlds/minecraft/chests.ts +292 -0
  236. package/src/worlds/minecraft/client-launch.ts +272 -0
  237. package/src/worlds/minecraft/client-options.ts +85 -0
  238. package/src/worlds/minecraft/client-skins.ts +164 -0
  239. package/src/worlds/minecraft/client-window.ps1 +91 -0
  240. package/src/worlds/minecraft/client.ts +423 -0
  241. package/src/worlds/minecraft/combat-context.ts +109 -0
  242. package/src/worlds/minecraft/combat.ts +1552 -0
  243. package/src/worlds/minecraft/config.ts +499 -0
  244. package/src/worlds/minecraft/console/access.ts +218 -0
  245. package/src/worlds/minecraft/console/client.ts +244 -0
  246. package/src/worlds/minecraft/console/log.ts +110 -0
  247. package/src/worlds/minecraft/console/mount.ts +420 -0
  248. package/src/worlds/minecraft/console/skin.ts +257 -0
  249. package/src/worlds/minecraft/console/style.css +154 -0
  250. package/src/worlds/minecraft/console/world.ts +449 -0
  251. package/src/worlds/minecraft/deaths.ts +135 -0
  252. package/src/worlds/minecraft/definition.ts +18 -0
  253. package/src/worlds/minecraft/engine-child.ts +274 -0
  254. package/src/worlds/minecraft/engine-ipc.ts +98 -0
  255. package/src/worlds/minecraft/entity-facts.ts +132 -0
  256. package/src/worlds/minecraft/escape.ts +407 -0
  257. package/src/worlds/minecraft/executor.ts +14494 -0
  258. package/src/worlds/minecraft/explored.ts +156 -0
  259. package/src/worlds/minecraft/geometry.ts +205 -0
  260. package/src/worlds/minecraft/goal-plan.ts +647 -0
  261. package/src/worlds/minecraft/item-break.ts +126 -0
  262. package/src/worlds/minecraft/item-facts.ts +146 -0
  263. package/src/worlds/minecraft/item-pick.ts +67 -0
  264. package/src/worlds/minecraft/level-dat.ts +176 -0
  265. package/src/worlds/minecraft/log.ts +132 -0
  266. package/src/worlds/minecraft/mineflayer-fixes.ts +1180 -0
  267. package/src/worlds/minecraft/names.ts +377 -0
  268. package/src/worlds/minecraft/pathfinder-lib.d.ts +13 -0
  269. package/src/worlds/minecraft/pathfinder-perf.ts +796 -0
  270. package/src/worlds/minecraft/piglin.ts +49 -0
  271. package/src/worlds/minecraft/policy.ts +547 -0
  272. package/src/worlds/minecraft/precheck.ts +655 -0
  273. package/src/worlds/minecraft/proxy.ts +445 -0
  274. package/src/worlds/minecraft/ranged.ts +635 -0
  275. package/src/worlds/minecraft/readouts.ts +102 -0
  276. package/src/worlds/minecraft/round.ts +26 -0
  277. package/src/worlds/minecraft/search-observation.ts +92 -0
  278. package/src/worlds/minecraft/server-config.ts +467 -0
  279. package/src/worlds/minecraft/server.ts +658 -0
  280. package/src/worlds/minecraft/show.ts +84 -0
  281. package/src/worlds/minecraft/skills.ts +1763 -0
  282. package/src/worlds/minecraft/terrain.ts +1735 -0
  283. package/src/worlds/minecraft/window.ts +96 -0
  284. package/src/worlds/minecraft/works.ts +179 -0
  285. package/src/worlds/minecraft/world.ts +6438 -0
  286. package/src/worlds/qq/ENV_PROMPT.md +5 -0
  287. package/src/worlds/qq/config.ts +108 -0
  288. package/src/worlds/qq/console/client.ts +126 -0
  289. package/src/worlds/qq/console/events.ts +155 -0
  290. package/src/worlds/qq/console/gate.ts +232 -0
  291. package/src/worlds/qq/console/roster.ts +203 -0
  292. package/src/worlds/qq/console/style.css +65 -0
  293. package/src/worlds/qq/conversation.ts +28 -0
  294. package/src/worlds/qq/definition.ts +62 -0
  295. package/src/worlds/qq/driver.ts +375 -0
  296. package/src/worlds/qq/history-tools.ts +209 -0
  297. package/src/worlds/qq/normalize.ts +323 -0
  298. package/src/worlds/qq/qface-map.ts +282 -0
  299. package/src/worlds/qq/vision-prompt.ts +27 -0
  300. package/src/worlds/qq/vision.ts +642 -0
  301. package/src/worlds/qq/vlm.ts +142 -0
  302. package/src/worlds/qq/world.ts +1503 -0
  303. package/src/worlds/terminal/ENV_PROMPT.md +11 -0
  304. package/src/worlds/terminal/config.ts +9 -0
  305. package/src/worlds/terminal/definition.ts +14 -0
  306. package/src/worlds/terminal/world.ts +700 -0
  307. package/src/worlds/websearch/ENV_PROMPT.md +1 -0
  308. package/src/worlds/websearch/brave-client.ts +261 -0
  309. package/src/worlds/websearch/config.ts +75 -0
  310. package/src/worlds/websearch/definition.ts +20 -0
  311. package/src/worlds/websearch/world.ts +171 -0
@@ -0,0 +1,1883 @@
1
+ import type { ContextRecord } from '../protocol/open-responses/context.ts';
2
+ import type { EnvPromptOrigin } from '../core/prefix.ts';
3
+ /**
4
+ * WebApp 是独立于 agent 的控制台服务。Express、HTTP 与
5
+ * WebSocket 共用端口;聊天协议由终端对话 World 实现。
6
+ *
7
+ * 依赖通过本文件的窄接口注入,WebApp 不导入 core。
8
+ */
9
+ import { createServer, type Server } from 'node:http';
10
+ import { createHash } from 'node:crypto';
11
+ import type { AddressInfo } from 'node:net';
12
+ import { createReadStream, existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
13
+ import { open as openFile } from 'node:fs/promises';
14
+ import { basename, extname, isAbsolute, join } from 'node:path';
15
+ import { pipeline } from 'node:stream/promises';
16
+ import { fileURLToPath } from 'node:url';
17
+ import express, { type Request, type Response } from 'express';
18
+ import { WebSocketServer, type WebSocket } from 'ws';
19
+ import type {
20
+ EventEnvelope, EventRangeQuery, EventStoreReader, Logger,
21
+ ConfigGroup, ConfigValues, WorldConsoleDecl,
22
+ LogRecord, OwnedStoragePart, StoragePart, ToolSchema,
23
+ } from '../core/types.ts';
24
+ import type { SessionStats } from '../core/sessions.ts';
25
+ import type { UsageAggregate, UsageBucketOption } from '../core/cost.ts';
26
+ import { estimateMessagesTokens } from '../core/util.ts';
27
+ import { coerceGroupValues } from '../core/config-schema.ts';
28
+ import { isLanguage, pick, systemLanguage, type Language } from '../core/language.ts';
29
+
30
+ /** 服务端直接回给操作者的几句话:运行控制回执与关机账的总结行,按请求的界面语言。API 协议错误不在此列。 */
31
+ const SERVER_TEXT = {
32
+ zh: {
33
+ paused: '已暂停:事件照常落库排队,不投递唤醒',
34
+ resumed: '已继续:积压事件一次性投递',
35
+ exitSupervised: '进程即将退出,启动器随即重新拉起',
36
+ exitUnsupervised: '进程即将退出;没有检测到启动器循环,需要手动重新启动',
37
+ shutdownSkipped: (n: number, labels: string[]) => `本地关机完成,但有 ${n} 步没走完:${labels.join('、')}`,
38
+ shutdownComplete: (n: number) => `本地关机完成(${n} 步全部走完)`,
39
+ externalUnverified: (items: string[]) => `;[P0] ${items.join(';')}`,
40
+ externalItem: (label: string, status: string, detail: string, manualAction: string) =>
41
+ `${label}=${status}(${detail})。人工动作:${manualAction}`,
42
+ externalVerified: ';外部状态检查均已验证结束',
43
+ },
44
+ en: {
45
+ paused: 'Paused: events are still stored and queued, no wake is delivered',
46
+ resumed: 'Resumed: the backlog is delivered in one batch',
47
+ exitSupervised: 'The process is about to exit; the launcher will start it again',
48
+ exitUnsupervised: 'The process is about to exit; no launcher loop was detected, so it must be started again by hand',
49
+ shutdownSkipped: (n: number, labels: string[]) => `Local shutdown finished, but ${n} step(s) did not complete: ${labels.join(', ')}`,
50
+ shutdownComplete: (n: number) => `Local shutdown finished (all ${n} steps completed)`,
51
+ externalUnverified: (items: string[]) => `; [P0] ${items.join('; ')}`,
52
+ externalItem: (label: string, status: string, detail: string, manualAction: string) =>
53
+ `${label}=${status} (${detail}). Manual action: ${manualAction}`,
54
+ externalVerified: '; every external state check verified ended',
55
+ },
56
+ };
57
+ import { logPredicate, readRunsIndex, readTailRecordsWhere } from './files.ts';
58
+ import { ConsoleAssets, ConsolePageRegistry, type ConsolePageSource } from './console-pages.ts';
59
+ import { THEME_FILE, readDeploymentTheme, writeDeploymentTheme } from './theme-store.ts';
60
+ import { THEME_SCRIPT_ID, type InjectedTheme, type StoredTheme } from './shared/theme.ts';
61
+ import { EXTENSION_ASSET_PREFIX, extensionAssetSegment, type ExtensionConsoleAsset } from '../extensions/manifest.ts';
62
+ import {
63
+ CONSOLE_LAMPS_ROUTE, CONSOLE_LANGUAGE_HEADER, CONSOLE_LANGUAGE_QUERY, CONSOLE_PROTOCOL_VERSION,
64
+ PROVIDERS_LAMP_ID,
65
+ isBinaryResult, isFileResult,
66
+ type ConsoleFileResult, type ConsoleLamp, type ConsoleStream,
67
+ } from './shared/console-protocol.ts';
68
+ import {
69
+ PATH_PICKER_ROUTE,
70
+ type PathPicker,
71
+ } from './shared/path-picker.ts';
72
+ import {
73
+ nativePathPicker,
74
+ parsePathPickerOptions,
75
+ PathPickerRequestError,
76
+ PathPickerUnavailableError,
77
+ validatePickedPath,
78
+ } from './path-picker.ts';
79
+
80
+
81
+
82
+
83
+
84
+ /**
85
+ * 调试通道依赖(窄接口,不import core):session/事件/运行日志的实时观察接缝。
86
+ * on*系列在WebApp构造时各注册一次;WebApp内部维护调试客户端集合做广播。
87
+ */
88
+ export interface WebAppDebugDeps {
89
+ /** WebApp 不修改返回的 session 消息数组。 */
90
+ sessionMessages(): readonly ContextRecord[];
91
+ /** 当前的合成开头;为空时返回空数组,不写入 session 记录。省略时时间线不标注开头。 */
92
+ sessionHead?(): ContextRecord[];
93
+ onSessionAppend(cb: (msg: ContextRecord, index: number) => void): void;
94
+ onSessionReset(cb: (messages: ContextRecord[]) => void): void;
95
+ onEvent(cb: (e: EventEnvelope) => void): void;
96
+ onRunlog(cb: (entry: LogRecord) => void): void;
97
+ /** 当前 run 最近落盘的运行日志(hello 快照用) */
98
+ recentLog?(limit: number): LogRecord[];
99
+ /** 当前 run id;/api/log 缺省读它的 log.jsonl */
100
+ runId?(): string;
101
+ /** 主循环当前工具表schema(run()前为空数组) */
102
+ toolSchemas(): Array<{ name: string; description: string; parameters: Record<string, unknown> }>;
103
+ }
104
+
105
+ /** session观察注册表的窄接口(core/sessions.ts的SessionTracker天然满足) */
106
+ export interface WebAppSessionsDeps {
107
+ list(): SessionStats[];
108
+ messages(id: string): readonly ContextRecord[] | null;
109
+ onChange(cb: () => void): void;
110
+ }
111
+
112
+ export type { OwnedStoragePart, StoragePart };
113
+
114
+ export interface WorldInfo {
115
+ /** Worldid(装配层给的那个,如 terminal) */
116
+ id: string;
117
+ /** 已挂载到 core 并在当前进程运行。 */
118
+ status: 'active';
119
+ /** 人类可读名(来自装配层的 World 目录;目录没列的 World 缺省用 id) */
120
+ label?: string;
121
+ /** Persona定义的渠道;false/缺省 = 部署侧选配的外挂 */
122
+ declared?: boolean;
123
+ /**
124
+ * 当前对 agent 可见。false = 三要素已撤下(事件不再唤醒 agent),但 World**照常运行**:
125
+ * 连接不断、它持有的页面照常工作。
126
+ */
127
+ visible?: boolean;
128
+ /** 当前系统前缀和工具声明尚未按新的可见性重载。 */
129
+ prefixDrifted?: boolean;
130
+ /** 人工撰写的环境提示词描述 */
131
+ envPrompt: string;
132
+ /** 相对persona/的工作区目录(如 worlds/<Worldid>) */
133
+ workspace: string;
134
+ /** World 工具名清单 */
135
+ tools: string[];
136
+ /**
137
+ * World 声明的控制台表面:状态灯、徽标、专用面板、链接和可调配置组。
138
+ * 控制台路由不依赖 World id。
139
+ */
140
+ lamps?: WorldConsoleDecl['lamps'];
141
+ badges?: WorldConsoleDecl['badges'];
142
+ links?: WorldConsoleDecl['links'];
143
+ }
144
+
145
+ /**
146
+ * 本地已有实现但当前未激活的 World。激活写回 config.json 的 `worlds.<id>.enabled`
147
+ * 并立即挂载,不重启进程。
148
+ */
149
+ export interface InactiveWorldInfo {
150
+ id: string;
151
+ status: 'inactive';
152
+ label: string;
153
+ /** 是否由Persona声明;false 表示部署侧选配 World。 */
154
+ declared: boolean;
155
+ }
156
+
157
+ /** Persona已声明、但当前部署未提供实现的 World。仅出现在控制台目录中。 */
158
+ export interface UnavailableWorldInfo {
159
+ id: string;
160
+ status: 'missing';
161
+ label: string;
162
+ declared: boolean;
163
+ /** 为什么装不上,原样显示 */
164
+ reason: string;
165
+ }
166
+
167
+ export type ConsoleWorldInfo = WorldInfo | InactiveWorldInfo | UnavailableWorldInfo;
168
+
169
+ /**
170
+ * 一个装在 `extensions/` 下的包。`state` 是启动时的加载结果对照此刻磁盘:
171
+ * `pending-restart` = 启动后装的或换了版本,`removed` = 启动后卸了但本进程里还在跑,
172
+ * `idle` = bot 包装了但这份部署没引用它。
173
+ */
174
+ export interface ExtensionInfo {
175
+ name: string;
176
+ /** extensions/package.json 里的版本范围或 `link:` 路径 */
177
+ spec: string;
178
+ version: string | null;
179
+ description?: string;
180
+ /** manifest 里的类别(`world` / `provider` / `bot`);解析不出 manifest 时缺席 */
181
+ kind?: 'world' | 'provider' | 'bot';
182
+ /** 扩展声明的契约版本 */
183
+ api?: number;
184
+ /** 包声明了浏览器端产物 */
185
+ consoleClient: boolean;
186
+ /** 浏览器端产物:没声明 / 在发 / 声明了但文件不在 */
187
+ console?: 'none' | 'served' | 'missing';
188
+ loaded: boolean;
189
+ reason?: string;
190
+ worldId?: string;
191
+ label?: string;
192
+ state: 'loaded' | 'failed' | 'pending-restart' | 'removed' | 'idle';
193
+ }
194
+
195
+ export interface ExtensionSearchHit {
196
+ name: string;
197
+ version: string;
198
+ description: string;
199
+ date?: string;
200
+ publisher?: string;
201
+ /** 月下载量 */
202
+ downloads: number;
203
+ links: { npm?: string; repository?: string; homepage?: string };
204
+ installed: boolean;
205
+ /** 按哪一类关键字搜到的(`cortico-world` → world,`cortico-provider` → provider,`cortico-bot` → bot) */
206
+ kind?: 'world' | 'provider' | 'bot';
207
+ }
208
+
209
+ /** 装 npm 上的包(可带版本或 dist-tag),或本机一个含 package.json 的目录。 */
210
+ export type ExtensionInstallTarget = { name: string; version?: string } | { path: string };
211
+
212
+ /** 扩展面:清单、搜索、装卸。装卸只改磁盘,加载要重启进程。 */
213
+ export interface WebAppExtensionDeps {
214
+ list(): { dir: string; extensions: ExtensionInfo[] };
215
+ /** 不给 kind = world(`cortico-world`)。 */
216
+ search(query: string, kind?: 'world' | 'provider' | 'bot'): Promise<ExtensionSearchHit[]>;
217
+ /**
218
+ * 已加载扩展的浏览器端产物。服务端据此把页 id 映到 `/assets/extensions/<包>/<版本>/<文件>`
219
+ * 并只发这几个文件;缺席 = 没有扩展带面板。
220
+ */
221
+ consoleAssets?(): readonly ExtensionConsoleAsset[];
222
+ /** 返回一句结果描述(含 pnpm 输出尾部) */
223
+ install(target: ExtensionInstallTarget): Promise<string>;
224
+ uninstall(name: string): Promise<string>;
225
+ }
226
+
227
+ /** 规范关机 / 重启的账:本地步骤与外部状态分开记。 */
228
+ export interface WebAppShutdownReport {
229
+ localComplete?: boolean;
230
+ complete: boolean;
231
+ steps: Array<{ label: string; ok: boolean; elapsedMs: number; detail?: string }>;
232
+ externalChecks?: Array<{
233
+ key: string;
234
+ label: string;
235
+ status: 'verified-ended' | 'still-live' | 'unknown';
236
+ detail: string;
237
+ manualAction: string;
238
+ }>;
239
+ }
240
+
241
+ /** World 对 agent 的可见性(热开关;不影响 World 自身运行) */
242
+ export interface WebAppWorldVisibilityDeps {
243
+ state(): { visibility: Record<string, boolean>; driftedWorlds: string[] };
244
+ /** 返回一句结果描述,按 `language` */
245
+ set(id: string, visible: boolean, language: Language): string;
246
+ }
247
+
248
+ /**
249
+ * World 激活 / 停用 / 重启,全部热生效:装配层写回 `worlds.<id>.enabled`、按定义
250
+ * 重建实例并挂进/撤出 core。未知 id 或前置检查失败抛错,信息原样给操作者。
251
+ */
252
+ export interface WebAppWorldActivationDeps {
253
+ /** 激活或停用;返回一句结果描述,按 `language`。 */
254
+ set(id: string, enabled: boolean, language: Language): Promise<string>;
255
+ /** 停下当前实例、按定义重建并重新启动;返回一句结果描述,按 `language`。 */
256
+ restart(id: string, language: Language): Promise<string>;
257
+ }
258
+
259
+ /**
260
+ * 面板中可直接修改、并参与 system 前缀组装的固定文本源。
261
+ * scope 只有两档:人格侧(装配层/Persona声明的)与 World 侧——core 核心
262
+ * 语义无关,不存在 core 归属的提示词模板。
263
+ */
264
+ /** 编辑器旁注里的一个占位符:声明 + **此刻的实际展开值**(后者比描述直观得多)。 */
265
+ export interface PromptVarView {
266
+ name: string;
267
+ description: string;
268
+ multiline?: boolean;
269
+ /** 此刻会填进去的东西;没人报值时缺席(编辑器标红:模板里用了但没人填) */
270
+ value?: string;
271
+ }
272
+
273
+ export interface PromptDocument {
274
+ key: string;
275
+ title: string;
276
+ scope: 'persona' | 'world';
277
+ description: string;
278
+ content: string;
279
+ revision: string;
280
+ /** `envPrompt`=某 World 进前缀那份;`prefix`=顶层装配表 */
281
+ role?: 'envPrompt' | 'prefix';
282
+ /** 当前模板来源:部署覆盖、bot 包覆盖或 World 默认。保存写入部署覆盖。 */
283
+ origin?: EnvPromptOrigin;
284
+ vars?: PromptVarView[];
285
+ }
286
+
287
+ /** 前缀的一段。`sourceKey` 指向可编辑模板;没有 = 现拼的,编辑器标只读。 */
288
+ export interface PrefixSegmentView {
289
+ title: string;
290
+ text: string;
291
+ sourceKey?: string;
292
+ }
293
+
294
+ export interface WebAppPromptDeps {
295
+ /** 标题与说明按 `language`;key、内容与 revision 不随语言变。 */
296
+ list(language: Language): PromptDocument[] | Promise<PromptDocument[]>;
297
+ /** 回执按 `language`。`baseRevision` 过期时抛 `PromptRevisionConflict`,控制台据此回 409。 */
298
+ write(key: string, content: string, baseRevision: string | undefined, language: Language): string;
299
+ /** 删除部署覆盖,回落到 bot 包覆盖或 World 默认。 */
300
+ reset?(key: string, language: Language): string;
301
+ /**
302
+ * 整条前缀的分段视图,**现拼**——不需要活 session。
303
+ *
304
+ * 这一点是有意的:编辑器要改的是**下一条 session 的前缀**,不是当前这条的历史
305
+ * 快照。所以这里现场组装一份"如果现在开一条 session,前缀会长这样"。
306
+ */
307
+ prefix?(): Promise<PrefixSegmentView[]>;
308
+ }
309
+
310
+ /** 工具归属:core 为流程原语,persona 为 Persona 工具,world 的 id/label 指向对应 World。 */
311
+ export type ToolOwner =
312
+ | { kind: 'core' }
313
+ | { kind: 'persona' }
314
+ | { kind: 'world'; id: string; label?: string };
315
+
316
+ export interface WebAppToolSchemasDeps {
317
+ list(): Array<ToolSchema & { owner: ToolOwner }>;
318
+ }
319
+
320
+ export interface WebAppSessionControlDeps {
321
+ /** 重读所有前缀源,只替换当前 session 的 system 消息。回执按 `language`。 */
322
+ reloadPrefix(language: Language): Promise<string>;
323
+ }
324
+
325
+ /** `WebAppPromptDeps.write` 在 `baseRevision` 过期时抛的错;控制台回 409 并标 conflict。 */
326
+ export class PromptRevisionConflict extends Error {
327
+ override readonly name = 'PromptRevisionConflict';
328
+ }
329
+
330
+ /**
331
+ * 控制台展示带版本历史的记忆介质时需要的三个形状。
332
+ *
333
+ * 控制台只约束历史记录的展示形状;git、快照或无历史由人格实现选择。
334
+ * TypeScript 结构化类型允许实现侧的既有类型直接满足这些接口。
335
+ */
336
+ export interface ConsoleHistoryEntry {
337
+ hash: string;
338
+ fullHash: string;
339
+ author: string;
340
+ email: string;
341
+ date: string;
342
+ message: string;
343
+ }
344
+
345
+ export interface ConsoleCheckpointEntry {
346
+ name: string;
347
+ message: string;
348
+ hash: string;
349
+ date: string;
350
+ }
351
+
352
+ export interface ConsoleMediumStatus {
353
+ available: boolean;
354
+ repo: boolean;
355
+ /** 工作区有未提交改动 */
356
+ dirty: boolean;
357
+ head: string | null;
358
+ lastCommit: ConsoleHistoryEntry | null;
359
+ tags: string[];
360
+ }
361
+
362
+ /** 存档点管理 */
363
+ export interface WebAppCheckpointDeps {
364
+ list(): ConsoleCheckpointEntry[];
365
+ /** 新建:提交当前 + 打标记,返回结果描述 */
366
+ create(name: string, note: string): string;
367
+ /** 删除标记 */
368
+ remove(name: string): string;
369
+ }
370
+
371
+ /** 分时段/范围的用量聚合(用量·成本页数据源) */
372
+ export interface WebAppUsageDeps {
373
+ status?(): { pending: number; error: string | null };
374
+ aggregate(opts: { from?: string; to?: string; bucket: UsageBucketOption; currency?: string; basis?: 'marginal' | 'equivalent' }): UsageAggregate;
375
+ }
376
+
377
+
378
+ /**
379
+ * 一组可调配置项的读写(所有者声明 schema,控制台通用渲染)。
380
+ * 声明来自 core / Persona / 各 World,装配层收集后交进来。
381
+ */
382
+ export interface WebAppConfigDeps {
383
+ /** 全部配置组(带 JSON Schema 与当前值),标题与说明按 `language`;id、键与值不随语言变 */
384
+ groups(language: Language): Array<{ group: ConfigGroup; values: ConfigValues }>;
385
+ /** 按组提交:只接受该组 schema 里声明过的键。改了就写回 config.json。回执按 `language`。 */
386
+ set(groupId: string, values: ConfigValues, language: Language): string;
387
+ /** `x-options` 下拉的活选项;缺席或 kind 不认识给空表 */
388
+ options?(kind: string, language: Language): Array<{ value: string; label: string }>;
389
+ }
390
+
391
+ /**
392
+ * 框架级控制台契约。bot 专用的控制面由控制台页(Console Page)提供。
393
+ */
394
+ export interface ConsoleSurface {
395
+ /** 事件流(只读) */
396
+ store: EventStoreReader;
397
+ /** persona/工作区绝对路径(工作区浏览) */
398
+ memoryDir: string;
399
+ /** data/目录绝对路径(runlog.jsonl / session-main.jsonl所在) */
400
+ dataDir: string;
401
+ /** bot 根目录。头像固定写入此目录的 avatar.png;未提供时不挂载头像写入面。 */
402
+ botDir?: string;
403
+ /**
404
+ * 控制台的默认语言:印在 `<html lang>` 上,也是没带语言的请求用的那种。缺省按进程读一次
405
+ * 系统语言。每个请求可以经 `CONSOLE_LANGUAGE_HEADER`(WebSocket 握手经
406
+ * `CONSOLE_LANGUAGE_QUERY`)带自己的语言,服务端给控制台的文案按它取。
407
+ */
408
+ language?: Language;
409
+ /**
410
+ * 部署的默认配色方案 id。`theme.json` 还没有记录时用它;认不出的 id 由控制台落到框架默认方案。
411
+ */
412
+ defaultScheme?: string;
413
+ /**
414
+ * 监听地址。缺省 `127.0.0.1`:控制台没有身份认证,默认不对局域网露面。
415
+ * 要放到反向代理后面或有意让别的机器访问,才显式换成 `0.0.0.0`。
416
+ */
417
+ host?: string;
418
+ /** 主循环状态快照(token/截断/梦状态…有什么给什么) */
419
+ getStatus(): Record<string, unknown>;
420
+ /** 调试通道(可选;不挂载时 /ws/debug 拒绝连接) */
421
+ debug?: WebAppDebugDeps;
422
+ /** session观察(可选;不挂载时 /api/sessions 空、/ws/sessions 拒绝) */
423
+ sessions?: WebAppSessionsDeps;
424
+ /** 可清除的存储部分清单(可选;不挂载时 /api/storage 空),每项带装配层盖的归属。标签与回执按 `language`;key 不随语言变。 */
425
+ storage?: (language: Language) => OwnedStoragePart[];
426
+ /** 用量聚合(可选;不挂载时 /api/usage 空) */
427
+ usage?: WebAppUsageDeps;
428
+ /** 按 schema 声明的可调配置项(可选;不挂载时 /api/config 503) */
429
+ config?: WebAppConfigDeps;
430
+ /** 本机路径选择器。测试与嵌入环境可替换;缺省使用当前平台的原生对话框。 */
431
+ pathPicker?: PathPicker;
432
+ /** 已挂载 World 清单(可选;不挂载时 /api/worlds 空)。async:envPrompt可能异步。显示名与理由按 `language` */
433
+ worlds?: (language: Language) => Promise<ConsoleWorldInfo[]> | ConsoleWorldInfo[];
434
+ /** World 对 agent 的可见性开关(可选;不挂载时 /api/worlds/visibility 503) */
435
+ worldVisibility?: WebAppWorldVisibilityDeps;
436
+ /** World 激活开关(可选;不挂载时 /api/worlds/activation 503) */
437
+ worldActivation?: WebAppWorldActivationDeps;
438
+ /** 固定提示词源文件编辑。 */
439
+ prompts?: WebAppPromptDeps;
440
+ /** 工具 schema 的只读完整结构。 */
441
+ toolSchemas?: WebAppToolSchemasDeps;
442
+ /** 当前 session 的非破坏性前缀重载。 */
443
+ sessionControl?: WebAppSessionControlDeps;
444
+ /** 开场引导的一次性标记;缺席时控制台不给引导。 */
445
+ onboarding?: {
446
+ /** 删除标记;已经删过时不报错。 */
447
+ dismiss(): void;
448
+ };
449
+
450
+ /** 运行控制缺席时 /api/run/* 返回 503;暂停期间事件仍落库排队。关机顺序与进程退出由装配层决定。 */
451
+ run?: {
452
+ pause(): void;
453
+ resume(): void;
454
+ isPaused(): boolean;
455
+ /** 账里的步骤名与总结行按 `language`。 */
456
+ shutdown?(language: Language): Promise<WebAppShutdownReport>;
457
+ /**
458
+ * 规范关机,退出前落下重启标志;启动器循环读到标志后重新拉起。没有启动器循环
459
+ * (`supervised` 为 false)时它就是一次关机,页面上要说清楚。
460
+ */
461
+ restart?(language: Language): Promise<WebAppShutdownReport>;
462
+ supervised?: boolean;
463
+ };
464
+ /** 扩展装卸(可选;不挂载时 /api/extensions* 503)。 */
465
+ extensions?: WebAppExtensionDeps;
466
+ /**
467
+ * 控制台页的贡献来源(World 与 bot 各自的控制面)。装配层决定收什么;
468
+ * WebApp 只负责聚合、校验与转交,不认识任何具体的一页。
469
+ * 不挂载时 /api/console/manifest 返回一份只有 framework 能力的空 manifest。
470
+ */
471
+ consolePageSources?: () => ConsolePageSource[];
472
+ /** 所有 provider 模块的本地可用性汇总;缺席时省略该状态。 */
473
+ providersLamp?: (language: Language) => ConsoleLamp;
474
+ /**
475
+ * 浏览器端构建产物目录(含 asset-manifest.json)。缺省取仓库的 `dist/web`。
476
+ * 没构建过不是错误——控制台照常起,只是没有任何页扩展。
477
+ */
478
+ webDistDir?: string;
479
+ /**
480
+ * 控制台页流的心跳间隔(毫秒),缺省 30 秒。
481
+ * 反向代理的空闲超时比这短时要调小;测试里调到很小以便验证半开连接被清掉。
482
+ */
483
+ streamHeartbeatMs?: number;
484
+ log: Logger;
485
+ }
486
+
487
+ /**
488
+ * 服务端注入面。bot 专属的控制面**全部**走控制台页(`consolePageSources`),
489
+ * 框架不再有任何具名扩展槽位。
490
+ */
491
+ export type WebAppDeps = ConsoleSurface;
492
+
493
+
494
+ const FILE_MAX_BYTES = 1024 * 1024; // 1MB
495
+ const AVATAR_FILE = 'avatar.png';
496
+
497
+ /** 主题记录的正文上限:三十多个 token 两份调色板,自定义方案再多也到不了这个量级。 */
498
+ const THEME_MAX_BYTES = '256kb';
499
+ const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
500
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
501
+
502
+ function revisionOf(buf: Buffer | string): string {
503
+ return createHash('sha256').update(buf).digest('hex');
504
+ }
505
+
506
+ function intParam(v: unknown): number | undefined {
507
+ if (typeof v !== 'string' || v.trim() === '') return undefined;
508
+ const n = Number(v);
509
+ return Number.isFinite(n) ? Math.floor(n) : undefined;
510
+ }
511
+
512
+ function strParam(v: unknown): string | undefined {
513
+ return typeof v === 'string' && v !== '' ? v : undefined;
514
+ }
515
+
516
+ /** 展示时过滤 contextDelivery 为 archive-only 的记录,不修改存储。 */
517
+ function dropArchiveOnly(events: readonly EventEnvelope[]): EventEnvelope[] {
518
+ return events.filter((event) => event.contextDelivery !== 'archive-only');
519
+ }
520
+
521
+ function clamp(n: number, lo: number, hi: number): number {
522
+ return Math.min(hi, Math.max(lo, n));
523
+ }
524
+
525
+ /** GET 形式的面板调用把 args 编码成 query 里的 JSON 数组。 */
526
+ function parseQueryArgs(raw: unknown): { args: unknown[] } | { error: string } {
527
+ if (typeof raw !== 'string' || raw.trim() === '') return { args: [] };
528
+ let parsed: unknown;
529
+ try {
530
+ parsed = JSON.parse(raw);
531
+ } catch {
532
+ return { error: 'args 不是合法 JSON' };
533
+ }
534
+ return Array.isArray(parsed) ? { args: parsed } : { error: 'args 必须是 JSON 数组' };
535
+ }
536
+
537
+ const hasPort = (h: string): boolean => /:\d+$/.test(h);
538
+ const barePort = (h: string): string => h.replace(/:\d+$/, '');
539
+
540
+ /** 按 Host 校验 Origin;缺失 Origin 时放行,null、非法或主机不匹配时拒绝。 */
541
+ function isForeignOrigin(origin: unknown, hostHeader: unknown): boolean {
542
+ if (typeof origin !== 'string' || origin === '') return false;
543
+ if (origin === 'null') return true; // 沙箱 iframe / file:// 一类,不是本控制台
544
+ let originHost: string;
545
+ try {
546
+ originHost = new URL(origin).host;
547
+ } catch {
548
+ return true;
549
+ }
550
+ const host = typeof hostHeader === 'string' ? hostHeader : '';
551
+ if (!originHost || !host) return true;
552
+ if (originHost === host) return false;
553
+ // 任一侧省了端口(走默认端口时浏览器会省)→ 只比主机名
554
+ if ((!hasPort(originHost) || !hasPort(host)) && barePort(originHost) === barePort(host)) return false;
555
+ return true;
556
+ }
557
+
558
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
559
+ const WILDCARD_HOSTS = new Set(['0.0.0.0', '::', '[::]']);
560
+
561
+ /**
562
+ * Host 头必须是回环名或显式绑定的地址;绑定通配地址时不校验 Host。
563
+ */
564
+ function isAllowedHost(hostHeader: unknown, listenHost: string): boolean {
565
+ if (WILDCARD_HOSTS.has(listenHost)) return true;
566
+ if (typeof hostHeader !== 'string' || hostHeader === '') return false;
567
+ const name = barePort(hostHeader).toLowerCase();
568
+ return LOOPBACK_HOSTS.has(name) || name === listenHost.toLowerCase() || name === `[${listenHost.toLowerCase()}]`;
569
+ }
570
+
571
+ /**
572
+ * 控制台页流式通道的 WS 路径:`/ws/providers/<page>/panels/<panel>`
573
+ * (与 `panelStreamRoute` 同一形状;路径里的 `providers` 段是线协议形状,未随类型改名)。
574
+ * 两段都是 `encodeURIComponent` 过的——
575
+ * page id 必含冒号,在 URL 里是 `%3A`,所以这里必须解码回来再去注册表查。
576
+ */
577
+ const STREAM_PATH_RE = /^\/ws\/providers\/([^/]+)\/panels\/([^/]+)$/;
578
+
579
+ function parseStreamPath(pathname: string): { pageId: string; panelId: string } | null {
580
+ const m = STREAM_PATH_RE.exec(pathname);
581
+ if (!m) return null;
582
+ try {
583
+ return { pageId: decodeURIComponent(m[1]), panelId: decodeURIComponent(m[2]) };
584
+ } catch {
585
+ return null; // 坏的百分号编码:当成不认识的路径
586
+ }
587
+ }
588
+
589
+ /** WS 关闭时 reason 最多 123 字节(协议硬限),超了 ws 会直接抛。 */
590
+ function clipCloseReason(reason: string): string {
591
+ let out = reason;
592
+ while (Buffer.byteLength(out, 'utf8') > 123) out = out.slice(0, -1);
593
+ return out;
594
+ }
595
+
596
+ function frameText(data: unknown): string {
597
+ if (typeof data === 'string') return data;
598
+ if (Buffer.isBuffer(data)) return data.toString('utf8');
599
+ if (Array.isArray(data)) return Buffer.concat(data as Buffer[]).toString('utf8');
600
+ if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8');
601
+ return String(data);
602
+ }
603
+
604
+ /** 那一页还没注册 onMessage 时最多攒几帧(解析要 await,期间对端可能已经发帧)。 */
605
+ const STREAM_PREBUFFER = 64;
606
+
607
+ /** 心跳间隔。一拍没等到 pong 就判失联——最坏 2 拍发现半开连接。 */
608
+ const STREAM_HEARTBEAT_MS = 30_000;
609
+
610
+ /**
611
+ * 把一条真 ws 连接包成协议里的 `ConsoleStream` 交给那一页。
612
+ *
613
+ * 这层适配是**故意**的:协议里那个接口不认识 ws(它同时被浏览器端 import,
614
+ * 而跑在子进程里的贡献方也要能实现它),所以宿主细节全压在这个函数里。
615
+ * 三条约定在这里落实:
616
+ * - 连接已关时 `send` 静默丢弃,不抛(否则每次推送都要自己判活)
617
+ * - `onClose` 只触发一次,且对端关/出错/服务器 stop 三条路都到得了
618
+ * (那一页可能挂着定时器等这个回调来清理)
619
+ * - socket 出错时 `terminate()` 兜底,不留半死连接
620
+ */
621
+ function toConsoleStream(ws: WebSocket, log: Logger, heartbeatMs: number): ConsoleStream {
622
+ const messageCbs: Array<(text: string) => void> = [];
623
+ const closeCbs: Array<() => void> = [];
624
+ const pending: string[] = [];
625
+ let closed = false;
626
+ let dropped = 0;
627
+
628
+ const fireClose = (): void => {
629
+ if (closed) return;
630
+ closed = true;
631
+ for (const cb of [...closeCbs]) {
632
+ try { cb(); } catch (err) { log.error('provider 流 onClose 回调抛错', { error: String(err) }); }
633
+ }
634
+ closeCbs.length = 0;
635
+ };
636
+
637
+ ws.on('message', (data) => {
638
+ const text = frameText(data);
639
+ if (messageCbs.length === 0) {
640
+ if (pending.length >= STREAM_PREBUFFER) {
641
+ dropped += 1;
642
+ if (dropped === 1) log.warn('provider 流未注册 onMessage,超额帧已丢弃');
643
+ return;
644
+ }
645
+ pending.push(text);
646
+ return;
647
+ }
648
+ for (const cb of [...messageCbs]) {
649
+ try { cb(text); } catch (err) { log.error('provider 流 onMessage 回调抛错', { error: String(err) }); }
650
+ }
651
+ });
652
+ ws.on('close', fireClose);
653
+ ws.on('error', (err) => {
654
+ log.warn('provider 流连接出错', { error: String(err) });
655
+ try { ws.terminate(); } catch { /* ignore */ }
656
+ fireClose();
657
+ });
658
+
659
+ /** 上一轮 ping 未收到 pong 时终止连接,触发关闭清理。 */
660
+ let alive = true;
661
+ ws.on('pong', () => { alive = true; });
662
+ const beat = setInterval(() => {
663
+ if (!alive) {
664
+ log.warn('provider 流心跳失联,断开');
665
+ try { ws.terminate(); } catch { /* ignore */ }
666
+ return;
667
+ }
668
+ alive = false;
669
+ try { ws.ping(); } catch { /* 连接正在关,下一拍自会收场 */ }
670
+ }, heartbeatMs);
671
+ closeCbs.push(() => clearInterval(beat));
672
+
673
+ return {
674
+ get open(): boolean {
675
+ return !closed && ws.readyState === 1 /* OPEN */;
676
+ },
677
+ send(data: string): void {
678
+ if (closed || ws.readyState !== 1) return; // 已关:静默丢弃
679
+ try { ws.send(data); } catch (err) { log.warn('provider 流推送失败', { error: String(err) }); }
680
+ },
681
+ close(reason?: string): void {
682
+ try {
683
+ ws.close(1000, reason === undefined ? undefined : clipCloseReason(reason));
684
+ } catch {
685
+ try { ws.terminate(); } catch { /* ignore */ }
686
+ fireClose();
687
+ }
688
+ },
689
+ onMessage(cb: (text: string) => void): void {
690
+ messageCbs.push(cb);
691
+ if (messageCbs.length === 1 && pending.length) {
692
+ const buffered = pending.splice(0, pending.length);
693
+ for (const text of buffered) {
694
+ try { cb(text); } catch (err) { log.error('provider 流 onMessage 回调抛错', { error: String(err) }); }
695
+ }
696
+ }
697
+ },
698
+ onClose(cb: () => void): void {
699
+ // 注册晚于关闭时立刻补一次:那一页的清理不该因为竞态而丢
700
+ if (closed) { try { cb(); } catch { /* ignore */ } return; }
701
+ closeCbs.push(cb);
702
+ },
703
+ };
704
+ }
705
+
706
+
707
+ export class WebApp {
708
+ private readonly deps: WebAppDeps;
709
+ private readonly app: express.Express;
710
+ private readonly listenHost: string;
711
+ /** 控制台页聚合。没挂 consolePageSources 时它也在,只是永远收到空清单。 */
712
+ private readonly consolePages: ConsolePageRegistry;
713
+ private readonly assets: ConsoleAssets;
714
+ /** 已加载扩展的浏览器端产物。进程生命期内不变(加载新扩展要重启),构造时算一次。 */
715
+ private readonly extensionAssets: readonly ExtensionConsoleAsset[];
716
+ private readonly webDistDir: string;
717
+ /** 默认语言:印在 `<html lang>` 上,没带语言的请求也用它。构造时定死。 */
718
+ private readonly language: Language;
719
+ private server: Server | null = null;
720
+ private wss: WebSocketServer | null = null;
721
+ /** /ws/debug 已连接客户端(广播目标) */
722
+ private readonly debugClients = new Set<WebSocket>();
723
+ /** /ws/sessions 已连接客户端(session统计广播;chat页轻量订阅用) */
724
+ private readonly sessionClients = new Set<WebSocket>();
725
+
726
+ constructor(deps: WebAppDeps) {
727
+ this.deps = deps;
728
+ this.listenHost = deps.host ?? '127.0.0.1';
729
+ this.language = deps.language ?? systemLanguage();
730
+ this.webDistDir = deps.webDistDir ?? fileURLToPath(new URL('../../dist/web', import.meta.url));
731
+ this.extensionAssets = deps.extensions?.consoleAssets?.() ?? [];
732
+ this.assets = new ConsoleAssets(this.webDistDir, deps.log, this.extensionAssets);
733
+ this.consolePages = new ConsolePageRegistry({
734
+ sources: () => deps.consolePageSources?.() ?? [],
735
+ capabilities: () => this.frameworkCapabilities(),
736
+ assets: this.assets,
737
+ log: deps.log,
738
+ });
739
+ this.app = this.buildApp();
740
+ // 调试观察接缝:监听器只在构造时注册一次;之后所有帧广播给当前客户端集合
741
+ const dbg = deps.debug;
742
+ if (dbg) {
743
+ dbg.onSessionAppend((message, index) => {
744
+ this.debugBroadcast({ t: 'session.append', index, message });
745
+ // status顺带推一份(不定时轮询,append即代表状态变化)
746
+ this.debugBroadcast({ t: 'status', status: this.safeStatus() });
747
+ });
748
+ // reset 顺带带上合成开头现值:前缀重载/交接都走 reset,标注块跟着刷新
749
+ dbg.onSessionReset((messages) => this.debugBroadcast({
750
+ t: 'session.reset',
751
+ messages,
752
+ head: dbg.sessionHead?.() ?? [],
753
+ }));
754
+ dbg.onEvent((envelope) => this.debugBroadcast({ t: 'event', envelope }));
755
+ dbg.onRunlog((entry) => this.debugBroadcast({ t: 'runlog', entry }));
756
+ }
757
+ // session统计变化→全量列表推送(列表小,每次LLM调用一帧,频率低)。
758
+ // 同帧也走debug通道(chat调试台已连/ws/debug,免开第二条连接)。
759
+ deps.sessions?.onChange(() => {
760
+ this.sessionsBroadcast();
761
+ this.debugBroadcast({ t: 'sessions', sessions: this.safeSessionList() });
762
+ });
763
+ }
764
+
765
+ /**
766
+ * 框架级表面挂没挂。同时供 `/api/capabilities` 与 `/api/console/manifest` 使用,
767
+ * 两处必须是同一份事实。
768
+ *
769
+ * 这里**只有框架级表面**。各页自己的能力("有没有模型档位面板"这种)由
770
+ * manifest 里有没有对应的页回答,不在中央留一份知识。
771
+ */
772
+ /**
773
+ * 这个请求的界面语言:HTTP 看 `CONSOLE_LANGUAGE_HEADER`,WebSocket 握手看 URL 里的
774
+ * `CONSOLE_LANGUAGE_QUERY`;缺席或不认识 = 默认语言。
775
+ */
776
+ private languageOf(req: { headers: Record<string, unknown>; url?: string }): Language {
777
+ const header = req.headers[CONSOLE_LANGUAGE_HEADER];
778
+ if (isLanguage(header)) return header;
779
+ let fromQuery: string | null = null;
780
+ try {
781
+ fromQuery = new URL(req.url ?? '', 'http://localhost').searchParams.get(CONSOLE_LANGUAGE_QUERY);
782
+ } catch { /* 坏 URL:当没带 */ }
783
+ return isLanguage(fromQuery) ? fromQuery : this.language;
784
+ }
785
+
786
+ /** 部署的主题记录;文件读不成时记一条,按没有记录发给浏览器。 */
787
+ private readTheme(dir: string): StoredTheme | null {
788
+ const { state, error } = readDeploymentTheme(dir);
789
+ if (error) this.deps.log.warn(`${THEME_FILE} 读不成`, { error });
790
+ return state;
791
+ }
792
+
793
+ private frameworkCapabilities(): Record<string, boolean> {
794
+ return {
795
+ debug: !!this.deps.debug,
796
+ sessions: !!this.deps.sessions,
797
+ storage: (this.deps.storage?.(this.language) ?? []).length > 0,
798
+ usage: !!this.deps.usage,
799
+ config: !!this.deps.config,
800
+ worlds: !!this.deps.worlds,
801
+ worldVisibility: !!this.deps.worldVisibility,
802
+ worldActivation: !!this.deps.worldActivation,
803
+ prompts: !!this.deps.prompts,
804
+ toolSchemas: !!this.deps.toolSchemas,
805
+ sessionControl: !!this.deps.sessionControl,
806
+ run: !!this.deps.run,
807
+ shutdown: !!this.deps.run?.shutdown,
808
+ restart: !!this.deps.run?.restart,
809
+ supervised: this.deps.run?.supervised === true,
810
+ extensions: !!this.deps.extensions,
811
+ avatar: !!this.deps.botDir,
812
+ };
813
+ }
814
+
815
+ /** 关机和重启请求等待编排完成,并返回各步骤结果。 */
816
+ private async respondPowerAction(
817
+ res: Response,
818
+ language: Language,
819
+ action: ((language: Language) => Promise<WebAppShutdownReport>) | undefined,
820
+ unavailable: string,
821
+ logLine: string,
822
+ trailer: string,
823
+ ): Promise<void> {
824
+ if (!action) { res.status(503).json({ error: unavailable }); return; }
825
+ this.deps.log.warn(logLine);
826
+ try {
827
+ const report = await action(language);
828
+ const skipped = report.steps.filter((s) => !s.ok);
829
+ const localComplete = report.localComplete ?? skipped.length === 0;
830
+ const externalChecks = report.externalChecks ?? [];
831
+ const unverified = externalChecks.filter((check) => check.status !== 'verified-ended');
832
+ const t = pick(language, SERVER_TEXT);
833
+ const localResult = !localComplete
834
+ ? t.shutdownSkipped(skipped.length, skipped.map((s) => s.label))
835
+ : t.shutdownComplete(report.steps.length);
836
+ const externalResult = unverified.length > 0
837
+ ? t.externalUnverified(unverified.map((check) =>
838
+ t.externalItem(check.label, check.status, check.detail ?? '', check.manualAction ?? '')))
839
+ : externalChecks.length > 0
840
+ ? t.externalVerified
841
+ : '';
842
+ res.json({
843
+ ok: report.complete,
844
+ localComplete,
845
+ complete: report.complete,
846
+ steps: report.steps,
847
+ externalChecks,
848
+ result: `${localResult}${externalResult},${trailer}`,
849
+ });
850
+ } catch (err) {
851
+ this.deps.log.error('关机编排失败', { error: String(err) });
852
+ if (!res.headersSent) res.status(500).json({ error: String(err) });
853
+ }
854
+ }
855
+
856
+ /**
857
+ * 把那一页的返回值按约定送回:内存字节走 `$binary`,大文件走 `$file`
858
+ * 流式发送,其余按 JSON。语义不解释,形状归那一页。
859
+ */
860
+ private async sendInvokeResult(req: Request, res: Response, value: unknown): Promise<void> {
861
+ if (isBinaryResult(value)) {
862
+ const { mime, base64 } = value.$binary;
863
+ res.setHeader('Content-Type', typeof mime === 'string' ? mime : 'application/octet-stream');
864
+ res.send(Buffer.from(base64, 'base64'));
865
+ return;
866
+ }
867
+ if (isFileResult(value)) {
868
+ if (!isAbsolute(value.$file.path)) {
869
+ res.status(500).json({ error: 'provider 返回了非绝对文件路径' });
870
+ return;
871
+ }
872
+ await this.sendVerifiedFile(req, res, value.$file);
873
+ return;
874
+ }
875
+ if (
876
+ typeof value === 'object'
877
+ && value !== null
878
+ && Object.prototype.hasOwnProperty.call(value, '$file')
879
+ ) {
880
+ res.status(500).json({ error: 'provider 返回了无效文件描述' });
881
+ return;
882
+ }
883
+ res.json(value ?? null);
884
+ }
885
+
886
+ private async sendVerifiedFile(
887
+ req: Request,
888
+ res: Response,
889
+ file: ConsoleFileResult['$file'],
890
+ ): Promise<void> {
891
+ const handle = await openFile(file.path, 'r');
892
+ try {
893
+ const stat = await handle.stat();
894
+ if (!stat.isFile() || stat.size !== file.bytes) {
895
+ throw new Error('provider 文件大小与声明不匹配');
896
+ }
897
+
898
+ const hash = createHash('sha256');
899
+ const chunk = Buffer.allocUnsafe(Math.min(Math.max(file.bytes, 1), 64 * 1024));
900
+ let offset = 0;
901
+ while (offset < file.bytes) {
902
+ const { bytesRead } = await handle.read(
903
+ chunk,
904
+ 0,
905
+ Math.min(chunk.byteLength, file.bytes - offset),
906
+ offset,
907
+ );
908
+ if (bytesRead === 0) throw new Error('provider 文件在校验期间被截断');
909
+ hash.update(chunk.subarray(0, bytesRead));
910
+ offset += bytesRead;
911
+ }
912
+ if (hash.digest('hex') !== file.sha256) {
913
+ throw new Error('provider 文件 hash 与声明不匹配');
914
+ }
915
+
916
+ res.type(file.mime);
917
+ res.setHeader('Content-Length', String(file.bytes));
918
+ if (req.method === 'HEAD' || file.bytes === 0) {
919
+ res.end();
920
+ return;
921
+ }
922
+
923
+ // 显式 fd 让响应读取复用上面完成校验的同一次 open;路径不会被重新打开。
924
+ await pipeline(createReadStream(file.path, {
925
+ fd: handle.fd,
926
+ autoClose: false,
927
+ start: 0,
928
+ end: file.bytes - 1,
929
+ }), res);
930
+ } finally {
931
+ await handle.close();
932
+ }
933
+ }
934
+
935
+ private safeSessionList(): SessionStats[] {
936
+ try {
937
+ return this.deps.sessions?.list() ?? [];
938
+ } catch {
939
+ return [];
940
+ }
941
+ }
942
+
943
+ private sessionsBroadcast(): void {
944
+ const src = this.deps.sessions;
945
+ if (!src || this.sessionClients.size === 0) return;
946
+ let raw: string;
947
+ try {
948
+ raw = JSON.stringify({ t: 'sessions', sessions: src.list() });
949
+ } catch {
950
+ return;
951
+ }
952
+ for (const ws of [...this.sessionClients]) {
953
+ if (ws.readyState === 1 /* OPEN */) {
954
+ try { ws.send(raw); } catch { this.sessionClients.delete(ws); }
955
+ } else if (ws.readyState > 1) {
956
+ this.sessionClients.delete(ws);
957
+ }
958
+ }
959
+ }
960
+
961
+ /** /ws/sessions 新连接:发全量列表,之后变化实时推 */
962
+ private handleSessionsConnection(ws: WebSocket): void {
963
+ const src = this.deps.sessions;
964
+ if (!src) {
965
+ try { ws.send(JSON.stringify({ t: 'sys', text: 'session观察不可用' })); } catch { /* ignore */ }
966
+ ws.close(1013, 'sessions deps not mounted');
967
+ return;
968
+ }
969
+ this.sessionClients.add(ws);
970
+ ws.on('close', () => this.sessionClients.delete(ws));
971
+ ws.on('error', () => {
972
+ this.sessionClients.delete(ws);
973
+ try { ws.terminate(); } catch { /* ignore */ }
974
+ });
975
+ try {
976
+ ws.send(JSON.stringify({ t: 'sessions', sessions: src.list() }));
977
+ } catch (err) {
978
+ this.deps.log.warn('sessions hello发送失败', { error: String(err) });
979
+ }
980
+ }
981
+
982
+ /** 通道不可用时先发送错误帧,再关闭连接:no-surface 使用 1013,其余使用 1008。 */
983
+ private handleConsolePageStream(ws: WebSocket, pageId: string, panelId: string, language: Language): void {
984
+ // 先包再解析:解析要 await,期间对端可能已经发帧,适配器会替那一页攒着
985
+ const socket = toConsoleStream(ws, this.deps.log, this.deps.streamHeartbeatMs ?? STREAM_HEARTBEAT_MS);
986
+ void this.consolePages.resolveStream(pageId, panelId, language).then(
987
+ (out) => {
988
+ if (!out.ok) {
989
+ this.closeStreamWith(
990
+ ws,
991
+ out.failure.message,
992
+ out.failure.kind === 'no-surface' ? 1013 : 1008,
993
+ out.failure.kind,
994
+ );
995
+ return;
996
+ }
997
+ try {
998
+ out.open(socket);
999
+ } catch (err) {
1000
+ // 那一页的 stream() 抛错只关这一条连接:一个面板炸了不该带走控制台
1001
+ this.deps.log.error(`provider 流式面抛错 ${pageId}/${panelId}`, { error: String(err) });
1002
+ this.closeStreamWith(ws, `流式面出错: ${String(err)}`, 1011, 'stream error');
1003
+ }
1004
+ },
1005
+ (err) => {
1006
+ this.deps.log.error(`provider 流解析失败 ${pageId}/${panelId}`, { error: String(err) });
1007
+ this.closeStreamWith(ws, `流解析失败: ${String(err)}`, 1011, 'resolve error');
1008
+ },
1009
+ );
1010
+ }
1011
+
1012
+ /** 发一帧说明再关。reason 走 ASCII 短句(帧里才是给人看的中文,reason 有 123 字节上限)。 */
1013
+ private closeStreamWith(ws: WebSocket, text: string, code: number, reason: string): void {
1014
+ try { ws.send(JSON.stringify({ t: 'sys', text })); } catch { /* ignore */ }
1015
+ try { ws.close(code, reason); } catch { try { ws.terminate(); } catch { /* ignore */ } }
1016
+ }
1017
+
1018
+ private safeStatus(): Record<string, unknown> {
1019
+ try {
1020
+ return this.deps.getStatus() ?? {};
1021
+ } catch (err) {
1022
+ return { statusError: String(err) };
1023
+ }
1024
+ }
1025
+
1026
+ private debugBroadcast(payload: unknown): void {
1027
+ if (this.debugClients.size === 0) return;
1028
+ const raw = JSON.stringify(payload);
1029
+ for (const ws of [...this.debugClients]) {
1030
+ if (ws.readyState === 1 /* OPEN */) {
1031
+ try { ws.send(raw); } catch { this.debugClients.delete(ws); }
1032
+ } else if (ws.readyState > 1) {
1033
+ this.debugClients.delete(ws);
1034
+ }
1035
+ }
1036
+ }
1037
+
1038
+ /** /ws/debug 新连接:发hello全量快照,之后实时帧由构造时注册的监听器广播 */
1039
+ private handleDebugConnection(ws: WebSocket): void {
1040
+ const dbg = this.deps.debug;
1041
+ if (!dbg) {
1042
+ try { ws.send(JSON.stringify({ t: 'sys', text: '调试通道不可用' })); } catch { /* ignore */ }
1043
+ ws.close(1013, 'debug deps not mounted');
1044
+ return;
1045
+ }
1046
+ this.debugClients.add(ws);
1047
+ ws.on('close', () => this.debugClients.delete(ws));
1048
+ ws.on('error', () => {
1049
+ this.debugClients.delete(ws);
1050
+ try { ws.terminate(); } catch { /* ignore */ }
1051
+ });
1052
+ try {
1053
+ ws.send(JSON.stringify({
1054
+ t: 'hello',
1055
+ session: dbg.sessionMessages(),
1056
+ head: dbg.sessionHead?.() ?? [],
1057
+ toolSchemas: dbg.toolSchemas(),
1058
+ events: dropArchiveOnly(this.deps.store.range({ limit: 400 })).slice(-200),
1059
+ runlog: dbg.recentLog?.(200) ?? [],
1060
+ status: this.safeStatus(),
1061
+ sessions: this.safeSessionList(),
1062
+ }));
1063
+ } catch (err) {
1064
+ this.deps.log.warn('调试hello发送失败', { error: String(err) });
1065
+ }
1066
+ }
1067
+
1068
+ /**
1069
+ * 启动,返回实际监听端口。
1070
+ * 传 0 由 OS 分配;传固定端口时若已被占用则顺延到下一个空闲端口。
1071
+ */
1072
+ async start(port: number): Promise<number> {
1073
+ if (this.server) throw new Error('WebApp已在运行');
1074
+ const wss = new WebSocketServer({ noServer: true });
1075
+
1076
+ const attachUpgrade = (server: Server): void => {
1077
+ server.on('upgrade', (req, socket, head) => {
1078
+ let pathname = '';
1079
+ try {
1080
+ pathname = new URL(req.url ?? '', 'http://localhost').pathname;
1081
+ } catch { /* 保持空串 */ }
1082
+ // 框架自己的两条固定路径 + 通用控制台页流式通道(路径带 page/panel 两段)
1083
+ const stream = parseStreamPath(pathname);
1084
+ const framework = pathname === '/ws/debug' || pathname === '/ws/sessions';
1085
+ if (!framework && !stream) {
1086
+ socket.destroy();
1087
+ return;
1088
+ }
1089
+ if (!isAllowedHost(req.headers.host, this.listenHost)) {
1090
+ this.deps.log.warn('拒绝 Host 不在白名单的 WebSocket 连接', {
1091
+ path: pathname, host: String(req.headers.host),
1092
+ });
1093
+ socket.destroy();
1094
+ return;
1095
+ }
1096
+ // 跨站页面开的 WS 不受同源策略限制,只能在 upgrade 这一关自己拦(见 isForeignOrigin)
1097
+ if (isForeignOrigin(req.headers.origin, req.headers.host)) {
1098
+ this.deps.log.warn('拒绝跨站WebSocket连接', {
1099
+ path: pathname, origin: String(req.headers.origin),
1100
+ });
1101
+ socket.destroy();
1102
+ return;
1103
+ }
1104
+ wss.handleUpgrade(req, socket, head, (ws) => {
1105
+ if (stream) {
1106
+ this.handleConsolePageStream(ws, stream.pageId, stream.panelId, this.languageOf(req));
1107
+ return;
1108
+ }
1109
+ if (pathname === '/ws/debug') {
1110
+ this.handleDebugConnection(ws);
1111
+ return;
1112
+ }
1113
+ this.handleSessionsConnection(ws);
1114
+ });
1115
+ });
1116
+ };
1117
+
1118
+ const listenOnce = async (candidate: number): Promise<Server> => {
1119
+ const server = createServer(this.app);
1120
+ attachUpgrade(server);
1121
+ try {
1122
+ await new Promise<void>((res, rej) => {
1123
+ server.once('error', rej);
1124
+ server.listen(candidate, this.listenHost, () => {
1125
+ server.removeListener('error', rej);
1126
+ res();
1127
+ });
1128
+ });
1129
+ } catch (err) {
1130
+ server.close();
1131
+ throw err;
1132
+ }
1133
+ return server;
1134
+ };
1135
+
1136
+ // 固定端口被占时顺延;0 交给 OS 分配且只尝试一次。
1137
+ //
1138
+ // 固定端口最多尝试 5 个候选端口。
1139
+ const maxAttempts = port === 0 ? 1 : 5;
1140
+ let server: Server | null = null;
1141
+ let lastErr: unknown;
1142
+ for (let i = 0; i < maxAttempts; i++) {
1143
+ const candidate = port === 0 ? 0 : port + i;
1144
+ try {
1145
+ server = await listenOnce(candidate);
1146
+ if (i > 0) {
1147
+ // 端口顺延记录 warn,便于发现预期端口被其他实例占用。
1148
+ this.deps.log.warn(
1149
+ `端口 ${port} 被占用,改用 ${candidate}。`,
1150
+ );
1151
+ }
1152
+ break;
1153
+ } catch (err) {
1154
+ lastErr = err;
1155
+ const code = (err as NodeJS.ErrnoException)?.code;
1156
+ if (code !== 'EADDRINUSE' || port === 0) throw err;
1157
+ }
1158
+ }
1159
+ if (!server) {
1160
+ throw lastErr instanceof Error
1161
+ ? lastErr
1162
+ : new Error(`端口 ${port}–${port + maxAttempts - 1} 均不可用`);
1163
+ }
1164
+
1165
+ this.server = server;
1166
+ this.wss = wss;
1167
+ const actual = (server.address() as AddressInfo).port;
1168
+ this.deps.log.info(`web面板已启动 http://${this.listenHost}:${actual}/`);
1169
+ return actual;
1170
+ }
1171
+
1172
+ /** 实际绑定的地址(未启动=null)。缺省 `127.0.0.1`,即局域网上没有这个端口。 */
1173
+ get boundAddress(): string | null {
1174
+ const addr = this.server?.address();
1175
+ return addr && typeof addr === 'object' ? addr.address : null;
1176
+ }
1177
+
1178
+ async stop(): Promise<void> {
1179
+ const wss = this.wss;
1180
+ const server = this.server;
1181
+ this.wss = null;
1182
+ this.server = null;
1183
+ if (wss) {
1184
+ for (const client of wss.clients) {
1185
+ try { client.terminate(); } catch { /* ignore */ }
1186
+ }
1187
+ await new Promise<void>((res) => wss.close(() => res()));
1188
+ }
1189
+ this.debugClients.clear();
1190
+ this.sessionClients.clear();
1191
+ if (server) {
1192
+ await new Promise<void>((res) => {
1193
+ server.close(() => res());
1194
+ server.closeAllConnections();
1195
+ });
1196
+ }
1197
+ }
1198
+
1199
+
1200
+ private buildApp(): express.Express {
1201
+ const app = express();
1202
+ app.disable('x-powered-by');
1203
+
1204
+ app.use((req, res, next) => {
1205
+ if (isAllowedHost(req.headers.host, this.listenHost)) {
1206
+ next();
1207
+ return;
1208
+ }
1209
+ this.deps.log.warn('拒绝 Host 不在白名单的请求', { path: req.path, host: String(req.headers.host) });
1210
+ res.status(421).json({ error: 'Host 不被接受' });
1211
+ });
1212
+
1213
+ // 写操作的同源闸门:控制台的每个 POST 都是真副作用(改配置、删存储、回滚人格),
1214
+ // 别的站点的页面能凭浏览器自动发这些请求,拦这一下就断了。读接口不设闸——
1215
+ // 跨站读本来就拿不到响应体(没有一个 CORS 头放开过)。
1216
+ app.use((req, res, next) => {
1217
+ if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
1218
+ next();
1219
+ return;
1220
+ }
1221
+ if (!isForeignOrigin(req.headers.origin, req.headers.host)) {
1222
+ next();
1223
+ return;
1224
+ }
1225
+ this.deps.log.warn('拒绝跨站写请求', { path: req.path, origin: String(req.headers.origin) });
1226
+ res.status(403).json({ error: '跨站请求被拒绝' });
1227
+ });
1228
+
1229
+ const wrap = (h: (req: Request, res: Response) => void) => (req: Request, res: Response) => {
1230
+ try {
1231
+ h(req, res);
1232
+ } catch (err) {
1233
+ this.deps.log.error(`API错误 ${req.path}`, { error: String(err) });
1234
+ if (!res.headersSent) res.status(500).json({ error: String(err) });
1235
+ }
1236
+ };
1237
+ app.get('/api/status', wrap((_req, res) => {
1238
+ res.json({ ...this.safeStatus(), uptimeSec: Math.round(process.uptime()) });
1239
+ }));
1240
+
1241
+ app.get('/api/avatar', wrap((_req, res) => {
1242
+ const dir = this.deps.botDir;
1243
+ if (!dir) { res.status(404).end(); return; }
1244
+ const file = join(dir, AVATAR_FILE);
1245
+ if (!existsSync(file)) { res.status(404).end(); return; }
1246
+ res.setHeader('Cache-Control', 'no-store');
1247
+ res.type('png').send(readFileSync(file));
1248
+ }));
1249
+
1250
+ app.post('/api/avatar', express.raw({ type: 'image/png', limit: AVATAR_MAX_BYTES }), wrap((req, res) => {
1251
+ const dir = this.deps.botDir;
1252
+ if (!dir) { res.status(503).json({ error: '头像存储不可用' }); return; }
1253
+ const body = req.body;
1254
+ if (!Buffer.isBuffer(body) || body.length <= PNG_SIGNATURE.length || !body.subarray(0, 8).equals(PNG_SIGNATURE)) {
1255
+ res.status(400).json({ error: '头像必须是有效的 PNG 图片' });
1256
+ return;
1257
+ }
1258
+ const file = join(dir, AVATAR_FILE);
1259
+ const temporary = join(dir, `.${AVATAR_FILE}.${process.pid}.tmp`);
1260
+ writeFileSync(temporary, body);
1261
+ rmSync(file, { force: true });
1262
+ renameSync(temporary, file);
1263
+ this.deps.log.warn('bot 头像已更新', { file: AVATAR_FILE });
1264
+ res.json({ ok: true, file: AVATAR_FILE });
1265
+ }));
1266
+
1267
+ app.get('/api/theme', wrap((_req, res) => {
1268
+ const dir = this.deps.botDir;
1269
+ if (!dir) { res.status(503).json({ error: '主题记录不可用' }); return; }
1270
+ res.json({ defaultScheme: this.deps.defaultScheme ?? '', theme: this.readTheme(dir) });
1271
+ }));
1272
+
1273
+ app.post('/api/theme', express.json({ limit: THEME_MAX_BYTES }), wrap((req, res) => {
1274
+ const dir = this.deps.botDir;
1275
+ if (!dir) { res.status(503).json({ error: '主题记录不可用' }); return; }
1276
+ const theme = writeDeploymentTheme(dir, req.body);
1277
+ this.deps.log.info('控制台配色已更新', { scheme: theme.selectedId, mode: theme.mode });
1278
+ res.json({ ok: true, theme });
1279
+ }));
1280
+
1281
+ // 能力清单只声明挂载情况,前端据此省略未挂载面板。
1282
+ app.get('/api/capabilities', wrap((_req, res) => {
1283
+ res.json({ capabilities: this.frameworkCapabilities() });
1284
+ }));
1285
+
1286
+ // 事件流按 store.range 的 from/to 游标区间查询,默认最近 100 条,to 用于向前翻页。
1287
+ // 默认隐藏 archive-only,避免原始归档与投影重复展示;不修改原始归档,archive=1 可查看全部记录。
1288
+ app.get('/api/events', wrap((req, res) => {
1289
+ const q: EventRangeQuery = {};
1290
+ const from = intParam(req.query.from);
1291
+ if (from !== undefined) q.fromCursor = from;
1292
+ const to = intParam(req.query.to);
1293
+ if (to !== undefined) q.toCursor = to;
1294
+ const limit = clamp(intParam(req.query.limit) ?? 100, 1, 1000);
1295
+ const source = strParam(req.query.source);
1296
+ if (source) q.source = source;
1297
+ const withArchive = strParam(req.query.archive) === '1';
1298
+ if (withArchive) {
1299
+ q.limit = limit;
1300
+ res.json({ latest: this.deps.store.latestCursor(), events: this.deps.store.range(q) });
1301
+ return;
1302
+ }
1303
+ // 过滤会吃掉配额,所以多取一截再截尾——否则"最近 100 条"实际只剩一半。
1304
+ q.limit = clamp(limit * 2, 1, 2000);
1305
+ const events = dropArchiveOnly(this.deps.store.range(q)).slice(-limit);
1306
+ res.json({ latest: this.deps.store.latestCursor(), events });
1307
+ }));
1308
+
1309
+ // 运行日志:服务端按级别/区域/小类/轮次/关键词过滤,从文件尾部向前扫到够数为止。
1310
+ // run 缺省为当前 run;文件不存在→[]。
1311
+ app.get('/api/log', wrap((req, res) => {
1312
+ const limit = clamp(intParam(req.query.limit) ?? 200, 1, 2000);
1313
+ const runId = strParam(req.query.run) ?? this.deps.debug?.runId?.();
1314
+ if (!runId) { res.json([]); return; }
1315
+ const pred = logPredicate({
1316
+ level: strParam(req.query.level), area: strParam(req.query.area), event: strParam(req.query.event),
1317
+ grep: strParam(req.query.grep), since: strParam(req.query.since), round: intParam(req.query.round), call: strParam(req.query.call),
1318
+ });
1319
+ res.json(readTailRecordsWhere(join(this.deps.dataDir, 'runs', runId, 'log.jsonl'), limit, pred));
1320
+ }));
1321
+
1322
+ app.get('/api/runs', wrap((_req, res) => {
1323
+ res.json({ current: this.deps.debug?.runId?.() ?? null, runs: readRunsIndex(join(this.deps.dataDir, 'runs', 'index.jsonl')) });
1324
+ }));
1325
+
1326
+ app.get('/api/sessions', wrap((_req, res) => {
1327
+ const src = this.deps.sessions;
1328
+ res.json({ sessions: src ? src.list() : [] });
1329
+ }));
1330
+
1331
+ // 某个session的当前消息流(fork含继承的主session前缀,可能较大)
1332
+ app.get('/api/sessions/messages', wrap((req, res) => {
1333
+ const src = this.deps.sessions;
1334
+ if (!src) { res.status(503).json({ error: 'session观察不可用' }); return; }
1335
+ const id = strParam(req.query.id);
1336
+ if (!id) { res.status(400).json({ error: '缺少id参数' }); return; }
1337
+ const messages = src.messages(id);
1338
+ if (messages === null) { res.status(404).json({ error: `没有这个session: ${id}` }); return; }
1339
+ res.json({ id, messages, estTokens: estimateMessagesTokens(messages) });
1340
+ }));
1341
+
1342
+ app.get('/api/storage', wrap((req, res) => {
1343
+ const parts = (this.deps.storage?.(this.languageOf(req)) ?? []).map((p) => {
1344
+ let stat = '';
1345
+ try { stat = p.stat(); } catch (err) { stat = `统计失败: ${String(err)}`; }
1346
+ return {
1347
+ key: p.key, label: p.label, kind: p.kind, owner: p.owner,
1348
+ location: p.location, danger: !!p.danger, note: p.note, stat,
1349
+ };
1350
+ });
1351
+ res.json({ parts });
1352
+ }));
1353
+
1354
+ // 清除某个存储部分(运维动作;POST,key走query免body解析)
1355
+ app.post('/api/storage/clear', (req: Request, res: Response) => {
1356
+ void (async () => {
1357
+ const key = strParam(req.query.key);
1358
+ if (!key) { res.status(400).json({ error: '缺少key参数' }); return; }
1359
+ const part = (this.deps.storage?.(this.languageOf(req)) ?? []).find((p) => p.key === key);
1360
+ if (!part) { res.status(404).json({ error: `没有这个存储部分: ${key}` }); return; }
1361
+ try {
1362
+ const result = await part.clear();
1363
+ this.deps.log.warn(`存储部分已清除: ${key}`, { result });
1364
+ res.json({ ok: true, result });
1365
+ } catch (err) {
1366
+ this.deps.log.error(`存储清除失败: ${key}`, { error: String(err) });
1367
+ res.status(500).json({ error: String(err) });
1368
+ }
1369
+ })();
1370
+ });
1371
+
1372
+ // 一键清空:按order升序清除全部存储部分(session最后);逐项结果返回
1373
+ app.post('/api/storage/clear-all', (req: Request, res: Response) => {
1374
+ void (async () => {
1375
+ const parts = [...(this.deps.storage?.(this.languageOf(req)) ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
1376
+ if (!parts.length) { res.status(404).json({ error: '服务端未挂载存储清单' }); return; }
1377
+ const results: Array<{ key: string; ok: boolean; result: string }> = [];
1378
+ for (const part of parts) {
1379
+ try {
1380
+ results.push({ key: part.key, ok: true, result: String(await part.clear()) });
1381
+ } catch (err) {
1382
+ results.push({ key: part.key, ok: false, result: String(err) });
1383
+ }
1384
+ }
1385
+ this.deps.log.warn('存储已一键清空', { results });
1386
+ res.json({ ok: results.every((r) => r.ok), results });
1387
+ })();
1388
+ });
1389
+
1390
+ app.post('/api/onboarding/dismiss', wrap((_req, res) => {
1391
+ const src = this.deps.onboarding;
1392
+ if (!src) { res.status(503).json({ error: '开场引导标记不可用' }); return; }
1393
+ src.dismiss();
1394
+ res.json({ ok: true });
1395
+ }));
1396
+
1397
+ // 暂停/继续:暂停=事件照常落库排队但不投递唤醒;继续=积压一次性投递
1398
+ app.post('/api/run/pause', wrap((req, res) => {
1399
+ const run = this.deps.run;
1400
+ if (!run) { res.status(503).json({ error: '运行控制不可用' }); return; }
1401
+ run.pause();
1402
+ this.deps.log.warn('运行已暂停(人工操作)');
1403
+ res.json({ ok: true, paused: true, result: pick(this.languageOf(req), SERVER_TEXT).paused });
1404
+ }));
1405
+
1406
+ app.post('/api/run/resume', wrap((req, res) => {
1407
+ const run = this.deps.run;
1408
+ if (!run) { res.status(503).json({ error: '运行控制不可用' }); return; }
1409
+ run.resume();
1410
+ this.deps.log.warn('运行已继续(人工操作)');
1411
+ res.json({ ok: true, paused: false, result: pick(this.languageOf(req), SERVER_TEXT).resumed });
1412
+ }));
1413
+
1414
+ /** 将关机请求转交装配层,等待完成后返回各步骤结果。 */
1415
+ app.post('/api/run/shutdown', (req: Request, res: Response) => {
1416
+ void this.respondPowerAction(
1417
+ res, this.languageOf(req), this.deps.run?.shutdown, '关机控制不可用', '收到关机请求(人工操作)', '进程即将退出',
1418
+ );
1419
+ });
1420
+
1421
+ // 重启 = 落下重启标志 + 规范关机。有没有启动器循环把它拉起来,回执里说明。
1422
+ app.post('/api/run/restart', (req: Request, res: Response) => {
1423
+ const supervised = this.deps.run?.supervised === true;
1424
+ const language = this.languageOf(req);
1425
+ void this.respondPowerAction(
1426
+ res, language, this.deps.run?.restart, '重启控制不可用', '收到重启请求(人工操作)',
1427
+ supervised ? pick(language, SERVER_TEXT).exitSupervised : pick(language, SERVER_TEXT).exitUnsupervised,
1428
+ );
1429
+ });
1430
+
1431
+ // 已挂载 World 清单(前端"World"层的卡片数据源)
1432
+ app.get('/api/worlds', (req: Request, res: Response) => {
1433
+ void (async () => {
1434
+ try {
1435
+ const src = this.deps.worlds;
1436
+ const worlds = src ? await src(this.languageOf(req)) : [];
1437
+ res.json({ worlds });
1438
+ } catch (err) {
1439
+ this.deps.log.error('API错误 /api/worlds', { error: String(err) });
1440
+ if (!res.headersSent) res.status(500).json({ error: String(err) });
1441
+ }
1442
+ })();
1443
+ });
1444
+
1445
+ // 可见性开关仅撤下 agent 表面; World 继续运行,前缀段与工具在重载后更新。
1446
+ app.post('/api/worlds/visibility', express.json(), wrap((req, res) => {
1447
+ const src = this.deps.worldVisibility;
1448
+ if (!src) { res.status(503).json({ error: 'World 可见性开关不可用' }); return; }
1449
+ const body = (req.body ?? {}) as { id?: unknown; visible?: unknown };
1450
+ const id = typeof body.id === 'string' ? body.id : '';
1451
+ if (!id) { res.status(400).json({ error: '缺少 World id' }); return; }
1452
+ if (typeof body.visible !== 'boolean') { res.status(400).json({ error: 'visible 必须是布尔' }); return; }
1453
+ try {
1454
+ const result = src.set(id, body.visible, this.languageOf(req));
1455
+ res.json({ ok: true, result, ...src.state() });
1456
+ } catch (err) {
1457
+ res.status(400).json({ error: String(err) });
1458
+ }
1459
+ }));
1460
+
1461
+ // World 激活 / 停用:写回 config.json 的 worlds.<id>.enabled 并立即挂载或撤出,不重启进程。
1462
+ // 激活独立于可见性;未激活 World 不在当前 core 里。
1463
+ app.post('/api/worlds/activation', express.json(), wrap(async (req, res) => {
1464
+ const src = this.deps.worldActivation;
1465
+ if (!src) { res.status(503).json({ error: 'World 激活开关不可用' }); return; }
1466
+ const body = (req.body ?? {}) as { id?: unknown; enabled?: unknown };
1467
+ const id = typeof body.id === 'string' ? body.id : '';
1468
+ if (!id) { res.status(400).json({ error: '缺少 Worldid' }); return; }
1469
+ if (typeof body.enabled !== 'boolean') { res.status(400).json({ error: 'enabled 必须是布尔' }); return; }
1470
+ try {
1471
+ const result = await src.set(id, body.enabled, this.languageOf(req));
1472
+ this.deps.log.warn('World 激活状态已改', { id, enabled: body.enabled });
1473
+ res.json({ ok: true, result });
1474
+ } catch (err) {
1475
+ res.status(400).json({ error: String(err) });
1476
+ }
1477
+ }));
1478
+
1479
+ // World 重启:停下当前实例、按定义重建、重新启动。构造时读走的参数(端口、地址、路径)由此生效。
1480
+ app.post('/api/worlds/restart', express.json(), wrap(async (req, res) => {
1481
+ const src = this.deps.worldActivation;
1482
+ if (!src) { res.status(503).json({ error: 'World 重启不可用' }); return; }
1483
+ const body = (req.body ?? {}) as { id?: unknown };
1484
+ const id = typeof body.id === 'string' ? body.id : '';
1485
+ if (!id) { res.status(400).json({ error: '缺少 Worldid' }); return; }
1486
+ try {
1487
+ const result = await src.restart(id, this.languageOf(req));
1488
+ this.deps.log.warn('World 已重启', { id });
1489
+ res.json({ ok: true, result });
1490
+ } catch (err) {
1491
+ res.status(400).json({ error: String(err) });
1492
+ }
1493
+ }));
1494
+
1495
+ // 扩展:磁盘上的包对照启动时的加载结果。装卸只改磁盘,加载要重启进程。
1496
+ app.get('/api/extensions', wrap((_req, res) => {
1497
+ const src = this.deps.extensions;
1498
+ if (!src) { res.status(503).json({ error: '扩展管理不可用' }); return; }
1499
+ res.json(src.list());
1500
+ }));
1501
+
1502
+ app.get('/api/extensions/search', wrap(async (req, res) => {
1503
+ const src = this.deps.extensions;
1504
+ if (!src) { res.status(503).json({ error: '扩展管理不可用' }); return; }
1505
+ const kind = strParam(req.query.kind);
1506
+ if (kind !== undefined && kind !== 'world' && kind !== 'provider' && kind !== 'bot') {
1507
+ res.status(400).json({ error: `kind 只能是 world、provider 或 bot,现在是 ${kind}` });
1508
+ return;
1509
+ }
1510
+ try {
1511
+ res.json({ hits: await src.search(strParam(req.query.q) ?? '', kind) });
1512
+ } catch (err) {
1513
+ res.status(502).json({ error: `npm 搜索失败: ${String(err)}` });
1514
+ }
1515
+ }));
1516
+
1517
+ app.post('/api/extensions/install', express.json(), wrap(async (req, res) => {
1518
+ const src = this.deps.extensions;
1519
+ if (!src) { res.status(503).json({ error: '扩展管理不可用' }); return; }
1520
+ const body = (req.body ?? {}) as { name?: unknown; version?: unknown; path?: unknown };
1521
+ const target: ExtensionInstallTarget | null = typeof body.path === 'string' && body.path.trim()
1522
+ ? { path: body.path }
1523
+ : typeof body.name === 'string' && body.name.trim()
1524
+ ? { name: body.name, ...(typeof body.version === 'string' && body.version.trim() ? { version: body.version } : {}) }
1525
+ : null;
1526
+ if (!target) { res.status(400).json({ error: '缺少包名或路径' }); return; }
1527
+ try {
1528
+ const result = await src.install(target);
1529
+ this.deps.log.warn('扩展已安装(重启后加载)', { target });
1530
+ res.json({ ok: true, result, restartRequired: true });
1531
+ } catch (err) {
1532
+ res.status(400).json({ error: String(err instanceof Error ? err.message : err) });
1533
+ }
1534
+ }));
1535
+
1536
+ app.post('/api/extensions/uninstall', express.json(), wrap(async (req, res) => {
1537
+ const src = this.deps.extensions;
1538
+ if (!src) { res.status(503).json({ error: '扩展管理不可用' }); return; }
1539
+ const body = (req.body ?? {}) as { name?: unknown };
1540
+ const name = typeof body.name === 'string' ? body.name.trim() : '';
1541
+ if (!name) { res.status(400).json({ error: '缺少包名' }); return; }
1542
+ try {
1543
+ const result = await src.uninstall(name);
1544
+ this.deps.log.warn('扩展已卸载(重启后消失)', { name });
1545
+ res.json({ ok: true, result, restartRequired: true });
1546
+ } catch (err) {
1547
+ res.status(400).json({ error: String(err instanceof Error ? err.message : err) });
1548
+ }
1549
+ }));
1550
+
1551
+ app.get('/api/prompts', wrap(async (req, res) => {
1552
+ const src = this.deps.prompts;
1553
+ if (!src) { res.status(503).json({ error: '提示词模板编辑不可用' }); return; }
1554
+ res.json({ prompts: await src.list(this.languageOf(req)) });
1555
+ }));
1556
+
1557
+ /** 整条前缀的分段视图。现拼,不依赖活 session(见 WebAppPromptDeps.prefix)。 */
1558
+ app.get('/api/prompts/prefix', wrap(async (_req, res) => {
1559
+ const src = this.deps.prompts;
1560
+ if (!src?.prefix) { res.status(503).json({ error: '前缀预览不可用' }); return; }
1561
+ res.json({ segments: await src.prefix() });
1562
+ }));
1563
+
1564
+ app.post('/api/prompts', express.json({ limit: '2mb' }), (req: Request, res: Response) => {
1565
+ const src = this.deps.prompts;
1566
+ if (!src) { res.status(503).json({ error: '提示词模板编辑不可用' }); return; }
1567
+ const body = (req.body ?? {}) as Record<string, unknown>;
1568
+ const key = typeof body.key === 'string' ? body.key.trim() : '';
1569
+ if (!key) { res.status(400).json({ error: '缺少 key' }); return; }
1570
+ if (typeof body.content !== 'string') { res.status(400).json({ error: 'content 必须是字符串' }); return; }
1571
+ if (Buffer.byteLength(body.content, 'utf8') > FILE_MAX_BYTES) {
1572
+ res.status(413).json({ error: '提示词超过1MB,拒绝保存' });
1573
+ return;
1574
+ }
1575
+ try {
1576
+ const result = src.write(
1577
+ key,
1578
+ body.content,
1579
+ typeof body.baseRevision === 'string' ? body.baseRevision : undefined,
1580
+ this.languageOf(req),
1581
+ );
1582
+ this.deps.log.warn('固定提示词已编辑(人工)', { key });
1583
+ res.json({ ok: true, result, revision: revisionOf(body.content) });
1584
+ } catch (err) {
1585
+ const conflict = err instanceof PromptRevisionConflict;
1586
+ res.status(conflict ? 409 : 400).json({
1587
+ error: String(err),
1588
+ ...(conflict ? { conflict: true } : {}),
1589
+ });
1590
+ }
1591
+ });
1592
+
1593
+ app.post('/api/prompts/reset', express.json(), (req: Request, res: Response) => {
1594
+ const src = this.deps.prompts;
1595
+ if (!src?.reset) { res.status(503).json({ error: '提示词模板编辑不可用' }); return; }
1596
+ const body = (req.body ?? {}) as Record<string, unknown>;
1597
+ const key = typeof body.key === 'string' ? body.key.trim() : '';
1598
+ if (!key) { res.status(400).json({ error: '缺少 key' }); return; }
1599
+ try {
1600
+ const result = src.reset(key, this.languageOf(req));
1601
+ this.deps.log.warn('固定提示词部署覆盖已移除', { key });
1602
+ res.json({ ok: true, result });
1603
+ } catch (err) {
1604
+ res.status(400).json({ error: String(err) });
1605
+ }
1606
+ });
1607
+
1608
+ app.get('/api/tool-schemas', wrap((_req, res) => {
1609
+ const src = this.deps.toolSchemas;
1610
+ if (!src) { res.status(503).json({ error: '工具 schema 不可用' }); return; }
1611
+ res.json({ tools: src.list() });
1612
+ }));
1613
+
1614
+ app.post('/api/session/reload-prefix', (req: Request, res: Response) => {
1615
+ void (async () => {
1616
+ const src = this.deps.sessionControl;
1617
+ if (!src) { res.status(503).json({ error: 'session前缀重载不可用' }); return; }
1618
+ try {
1619
+ const result = await src.reloadPrefix(this.languageOf(req));
1620
+ this.deps.log.warn('当前session系统前缀已重载(人工)');
1621
+ res.json({ ok: true, result });
1622
+ } catch (err) {
1623
+ this.deps.log.error('重载当前session系统前缀失败', { error: String(err) });
1624
+ if (!res.headersSent) res.status(500).json({ error: String(err) });
1625
+ }
1626
+ })();
1627
+ });
1628
+
1629
+ // ---- Console Page API:World 与 bot 的控制面唯一通道。----
1630
+
1631
+ app.get('/api/console/manifest', (req: Request, res: Response) => {
1632
+ void this.consolePages.manifest(this.languageOf(req)).then(
1633
+ (manifest) => { if (!res.headersSent) res.json(manifest); },
1634
+ (err) => {
1635
+ this.deps.log.error('API错误 /api/console/manifest', { error: String(err) });
1636
+ if (!res.headersSent) {
1637
+ // manifest 是开页第一个请求;彻底失败也要给一份结构完整的空壳,
1638
+ // 否则前端只能把错误当界面显示。
1639
+ res.status(500).json({
1640
+ protocolVersion: CONSOLE_PROTOCOL_VERSION,
1641
+ providers: [],
1642
+ framework: { capabilities: {} },
1643
+ error: String(err),
1644
+ });
1645
+ }
1646
+ },
1647
+ );
1648
+ });
1649
+
1650
+ /**
1651
+ * 状态灯。manifest 的一个薄切片,给秒级轮询用——导航上的灯要跟得上
1652
+ * "引擎起来了没",而 manifest 那份带着全部面板与前缀源索引。
1653
+ *
1654
+ * 失败给 200 + 空表:一次取灯失败不该让导航变成一排问号,下一拍自然会补上。
1655
+ */
1656
+ app.get(CONSOLE_LAMPS_ROUTE, (req: Request, res: Response) => {
1657
+ const language = this.languageOf(req);
1658
+ const framework = this.deps.providersLamp
1659
+ ? { [PROVIDERS_LAMP_ID]: [this.deps.providersLamp(language)] }
1660
+ : {};
1661
+ void this.consolePages.lamps(language).then(
1662
+ (lamps) => { if (!res.headersSent) res.json({ lamps: { ...framework, ...lamps } }); },
1663
+ (err) => {
1664
+ this.deps.log.error(`API错误 ${CONSOLE_LAMPS_ROUTE}`, { error: String(err) });
1665
+ if (!res.headersSent) res.json({ lamps: {} });
1666
+ },
1667
+ );
1668
+ });
1669
+
1670
+ /**
1671
+ * Panel RPC。解析失败(没这一页 / 没声明这个面板 / 没有数据面)与
1672
+ * 那一页自己抛错是两回事:前者 404/503,后者 500。
1673
+ */
1674
+ const invokeConsolePagePanel = (req: Request, res: Response, args: unknown[]): void => {
1675
+ const page = String(req.params.provider ?? '');
1676
+ const panel = String(req.params.panel ?? '');
1677
+ const method = String(req.params.method ?? '');
1678
+ const transport = req.method === 'GET' || req.method === 'HEAD' ? 'get' : 'post';
1679
+ void this.consolePages.invoke(page, panel, method, args, transport, this.languageOf(req)).then(
1680
+ async (out) => {
1681
+ if (res.headersSent) return;
1682
+ if (!out.ok) {
1683
+ const status = out.failure.kind === 'no-surface'
1684
+ ? 503
1685
+ : out.failure.kind === 'method-not-allowed'
1686
+ ? 405
1687
+ : 404;
1688
+ res.status(status).json({ error: out.failure.message });
1689
+ return;
1690
+ }
1691
+ await this.sendInvokeResult(req, res, out.value);
1692
+ },
1693
+ ).catch((err) => {
1694
+ this.deps.log.error(`面板调用失败 ${page}/${panel}.${method}`, { error: String(err) });
1695
+ if (!res.headersSent) {
1696
+ res.status(500).json({ error: String(err) });
1697
+ } else if (!res.destroyed) {
1698
+ res.destroy(err instanceof Error ? err : new Error(String(err)));
1699
+ }
1700
+ });
1701
+ };
1702
+
1703
+ // GET:轮询读 + <audio src> 这类只能带 URL 的场合。args 经 query 传 JSON 数组。
1704
+ app.get('/api/console/providers/:provider/panels/:panel/:method', wrap((req, res) => {
1705
+ const args = parseQueryArgs(req.query.args);
1706
+ if ('error' in args) { res.status(400).json({ error: args.error }); return; }
1707
+ invokeConsolePagePanel(req, res, args.args);
1708
+ }));
1709
+
1710
+ // POST:带参调用。base64 音频/图片会经这里,limit 放宽。
1711
+ app.post(
1712
+ '/api/console/providers/:provider/panels/:panel/:method',
1713
+ express.json({ limit: '64mb' }),
1714
+ wrap((req, res) => {
1715
+ const body = (req.body ?? {}) as { args?: unknown };
1716
+ if (body.args !== undefined && !Array.isArray(body.args)) {
1717
+ res.status(400).json({ error: 'args 必须是数组' });
1718
+ return;
1719
+ }
1720
+ invokeConsolePagePanel(req, res, (body.args as unknown[] | undefined) ?? []);
1721
+ }),
1722
+ );
1723
+
1724
+ app.get('/api/usage', wrap((req, res) => {
1725
+ const src = this.deps.usage;
1726
+ if (!src) { res.json({ currency: 'USD', bucket: 'day', from: null, to: null, series: [], totals: null, byRole: [], byModel: [] }); return; }
1727
+ const BUCKETS = ['auto', 'minute', 'hour', 'day', 'week', 'month'] as const;
1728
+ const raw = strParam(req.query.bucket);
1729
+ const bucket: UsageBucketOption = (BUCKETS as readonly string[]).includes(raw ?? '') ? (raw as UsageBucketOption) : 'auto';
1730
+ const opts: Parameters<WebAppUsageDeps['aggregate']>[0] = { bucket };
1731
+ const currency = strParam(req.query.currency);
1732
+ if (currency) opts.currency = currency;
1733
+ const basis = strParam(req.query.basis);
1734
+ if (basis === 'marginal' || basis === 'equivalent') opts.basis = basis;
1735
+ const from = strParam(req.query.from);
1736
+ if (from) opts.from = from;
1737
+ const to = strParam(req.query.to);
1738
+ if (to) opts.to = to;
1739
+ res.json({ ...src.aggregate(opts), ledger: src.status?.() });
1740
+ }));
1741
+
1742
+ app.get('/api/config', wrap((req, res) => {
1743
+ const src = this.deps.config;
1744
+ if (!src) { res.status(503).json({ error: '配置项声明不可用' }); return; }
1745
+ res.json({ groups: src.groups(this.languageOf(req)) });
1746
+ }));
1747
+
1748
+ app.get('/api/config/options/:kind', wrap((req, res) => {
1749
+ const src = this.deps.config;
1750
+ if (!src) { res.status(503).json({ error: '配置项声明不可用' }); return; }
1751
+ const kind = typeof req.params.kind === 'string' ? req.params.kind : '';
1752
+ res.json({ options: src.options?.(kind, this.languageOf(req)) ?? [] });
1753
+ }));
1754
+
1755
+ app.post(PATH_PICKER_ROUTE, express.json({ limit: '16kb' }), (req: Request, res: Response) => {
1756
+ let options;
1757
+ try {
1758
+ options = parsePathPickerOptions(req.body);
1759
+ } catch (err) {
1760
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
1761
+ return;
1762
+ }
1763
+ const picker = this.deps.pathPicker ?? nativePathPicker;
1764
+ void picker.pick(options)
1765
+ .then((selected) => validatePickedPath(selected, options))
1766
+ .then((path) => res.json({ path }))
1767
+ .catch((err: unknown) => {
1768
+ const message = err instanceof Error ? err.message : String(err);
1769
+ if (err instanceof PathPickerRequestError) {
1770
+ res.status(400).json({ error: message });
1771
+ return;
1772
+ }
1773
+ if (err instanceof PathPickerUnavailableError) {
1774
+ res.status(503).json({ error: message });
1775
+ return;
1776
+ }
1777
+ this.deps.log.error('本机路径选择器失败', { error: message });
1778
+ res.status(500).json({ error: message });
1779
+ });
1780
+ });
1781
+
1782
+ app.post('/api/config', express.json(), (req: Request, res: Response) => {
1783
+ const src = this.deps.config;
1784
+ if (!src) { res.status(503).json({ error: '配置项声明不可用' }); return; }
1785
+ const body = (req.body ?? {}) as Record<string, unknown>;
1786
+ const groupId = typeof body.group === 'string' ? body.group : '';
1787
+ const language = this.languageOf(req);
1788
+ const entry = src.groups(language).find((g) => g.group.id === groupId);
1789
+ if (!entry) { res.status(400).json({ error: `未知配置组: ${groupId}` }); return; }
1790
+ const values = (body.values ?? {}) as Record<string, unknown>;
1791
+ // 校验完全按声明走:schema 里没声明的键一律忽略,控制台不能靠猜往配置里塞东西
1792
+ const parsed = coerceGroupValues(entry.group, values, language);
1793
+ if ('error' in parsed) { res.status(400).json({ error: parsed.error }); return; }
1794
+ try {
1795
+ const result = src.set(groupId, parsed.values, language);
1796
+ this.deps.log.warn('配置项已修改', { group: groupId });
1797
+ res.json({ ok: true, result, groups: src.groups(language) });
1798
+ } catch (err) {
1799
+ res.status(500).json({ error: String(err) });
1800
+ }
1801
+ });
1802
+
1803
+ /**
1804
+ * 扩展的浏览器端产物。**逐个文件发,不挂目录**:URL 里的三段只用来在产物表里
1805
+ * 定位一条已加载的记录,文件名必须与那条记录里的 basename 相等——路径不由 URL
1806
+ * 拼出来,所以 `..` 与别的文件名都只是查不到。要排在 `/assets` 静态之前。
1807
+ *
1808
+ * 版本在 URL 里,换版本即换 URL,于是可以按 immutable 缓存。
1809
+ */
1810
+ app.get(`${EXTENSION_ASSET_PREFIX}:pkg/:version/:file`, wrap((req, res) => {
1811
+ const { pkg, version, file } = req.params;
1812
+ const hit = this.extensionAssets.find(
1813
+ (a) => extensionAssetSegment(a.packageName) === pkg && extensionAssetSegment(a.version) === version,
1814
+ );
1815
+ const path = hit
1816
+ ? [hit.jsFile, hit.cssFile].find((f): f is string => !!f && extensionAssetSegment(basename(f)) === file)
1817
+ : undefined;
1818
+ if (!path) { res.status(404).end(); return; }
1819
+ res.type(extname(path) === '.css' ? 'css' : 'js');
1820
+ // 卸载扩展只改磁盘,本进程的产物表还留着那一条:文件没了就是 404,不是 500。
1821
+ res.sendFile(path, { maxAge: '1y', immutable: true }, (err) => {
1822
+ if (err && !res.headersSent) res.status(404).end();
1823
+ });
1824
+ }));
1825
+
1826
+ // 构建产物。只有这一条把 dist/web 露出去,且 URL 前缀与 asset-manifest 里
1827
+ // 写的 `/assets/` 一致;manifest 之外的 key 根本不会被任何响应引用。
1828
+ app.use('/assets', express.static(this.webDistDir, { fallthrough: true, index: false }));
1829
+
1830
+ const publicDir = fileURLToPath(new URL('./public', import.meta.url));
1831
+
1832
+ /**
1833
+ * 首页要注入内核入口。入口文件名带内容 hash(缓存需要),所以 index.html 里
1834
+ * 写不死它——由服务端查构建产物注入。
1835
+ */
1836
+ const serveIndex = (_req: Request, res: Response): void => {
1837
+ const file = join(publicDir, 'index.html');
1838
+ let html: string;
1839
+ try {
1840
+ html = readFileSync(file, 'utf8');
1841
+ } catch (err) {
1842
+ res.status(500).send(String(err));
1843
+ return;
1844
+ }
1845
+ /** 语言盖在 `<html lang>` 上:内核在 import 期就读它,框架页面能在模块顶层选串表。 */
1846
+ const lang = this.language === 'en' ? 'en' : 'zh-CN';
1847
+ html = html.replace('<html lang="zh-CN">', `<html lang="${lang}">`);
1848
+ /**
1849
+ * 部署默认方案与已保存的记录随首页发出,首次渲染前就读得到;没有记录时发 null,
1850
+ * 浏览器据此把本机旧记录交上来。转义 `<` 之后正文不可能提前闭合这个 script。
1851
+ */
1852
+ const injected: InjectedTheme = {
1853
+ defaultScheme: this.deps.defaultScheme ?? '',
1854
+ theme: this.deps.botDir ? this.readTheme(this.deps.botDir) : null,
1855
+ };
1856
+ const payload = JSON.stringify(injected).replaceAll('<', '\\u003c');
1857
+ html = html.replace(
1858
+ '</head>',
1859
+ `<script type="application/json" id="${THEME_SCRIPT_ID}">${payload}</script></head>`,
1860
+ );
1861
+ const core = this.assets.core();
1862
+ if (core) {
1863
+ /**
1864
+ * 认**最后一个** `</body>`。
1865
+ *
1866
+ * 用 `replace()` 会命中第一处,而页面注释里完全可能出现这个字面量
1867
+ * (这份 index.html 的注释就解释过入口是怎么注进来的)——那样 script
1868
+ * 标签会被注进注释里,页面一片空白而且看不出原因。踩过一次,记在这里。
1869
+ */
1870
+ const at = html.lastIndexOf('</body>');
1871
+ const tag = `<script type="module" src="${core}"></script>\n`;
1872
+ html = at >= 0 ? html.slice(0, at) + tag + html.slice(at) : html + tag;
1873
+ }
1874
+ res.type('html').send(html);
1875
+ };
1876
+ app.get('/', wrap(serveIndex));
1877
+ app.get('/index.html', wrap(serveIndex));
1878
+
1879
+ app.use(express.static(publicDir));
1880
+
1881
+ return app;
1882
+ }
1883
+ }