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,700 @@
1
+ /**
2
+ * 终端对话 World TerminalWorld。
3
+ *
4
+ * 定位:和QQ客户端同层级的外部平台。
5
+ * 职责边界:Worldowns聊天协议(JSON行),不监听端口——连接由外面交进来。
6
+ *
7
+ * **唯一入口**:`stream(panel, socket)` —— 通用控制台页流式通道
8
+ * (`ConsolePageContribution.stream`)。对话在框架眼里只是"一个带流的普通
9
+ * 控制台页",框架不认识"聊天",也不为它留具名槽位。
10
+ *
11
+ * 连接宿主的形状压在 `ChatPeer` 这层薄适配上(文件末尾的 `streamPeer`),协议解析、
12
+ * 名单、广播、历史回放都在这一份实现里。
13
+ *
14
+ * 命名统一为「终端」;`Web` 之名留给 CorticoWeb 延伸应用。
15
+ *
16
+ * 事件词表:
17
+ * terminal.message 操作员消息, origin:'internal' + trigger:'flush'(跳过合批安静窗口)
18
+ * (进出终端不投递事件:presence 噪音多次被判定不值得打扰bot)
19
+ * terminal.self 自己发出的消息回录, deliver:false
20
+ * terminal.invite 操作员按下终端页那颗按钮, origin:'internal' + trigger:'flush'
21
+ *
22
+ * 控制台通道的两条纪律:
23
+ * - 投递按 `internal`:这条通道装的不是"外面发生了什么",是操作员直接对她说话。
24
+ * loop 把 internal 项并进 user 消息,角色上高于落在工具回执区的外部事件。
25
+ * 代价是 internal 项不参与重启补投(loop 只补 external),掉线期间的后台话不补看。
26
+ * - 配了口令就每条消息自带 `[控制台|PIN:******]` 标记,同一串数字经环境提示词模板
27
+ * 进前缀供她比对。冒充控制台口吻的弹幕因此当场露馅。
28
+ *
29
+ * 语言分两层。给操作员看的(控制台声明、流上的系统提示、面板调用面的错误)按发起
30
+ * 请求的界面语言取:`console(language)` 每次请求重算,每条流按握手时的语言记在
31
+ * `ChatClient.language` 上,同一场对话里两个人可以各看各的语言。给模型看的(口令标记
32
+ * `[console|PIN:******]`、回录正文、附件的文本形态、`terminal_send` 的回执、环境提示词
33
+ * 模板 `ENV_PROMPT.md`)固定英文,不随任何设置变。
34
+ *
35
+ * 聊天协议(JSON行):
36
+ * 客户端→ {type:'hello', name} 报名字(必须先于msg)
37
+ * {type:'msg', text, images?} 发消息;images 为 [{mime, base64, name?}],
38
+ * text 与 images 至少一样非空
39
+ * {type:'greet', label} 按下终端页那颗按钮;label 是按钮上当时显示的字
40
+ * 服务端→ {type:'msg', from, text, ts, images?} 广播(用户消息回显+bot消息);
41
+ * images 为 [{ref, mime, name?}](ref 是附件句柄)
42
+ * {type:'sys', text} 系统提示
43
+ *
44
+ * 图片进媒体库(core data/media/),事件只带引用;正文末尾标注张数,模型不接受
45
+ * 图像时标注里同时写明她看不到,不让她对着一句"附图"猜内容。
46
+ */
47
+ import { readFileSync } from 'node:fs';
48
+ import { fileURLToPath } from 'node:url';
49
+ import type { BlobInput, EventEnvelope, World, WorldHost, WorldConsoleDecl, ToolDef } from '../../core/types.ts';
50
+ import type { ConfigGroup } from '../../core/config-schema.ts';
51
+ import type { ConsoleStream } from '../../web/shared/console-protocol.ts';
52
+ import { nowIso, shortTime } from '../../core/util.ts';
53
+ import { pick, type Language } from '../../core/language.ts';
54
+ import { TERMINAL_DEFAULTS, type TerminalConfigSection } from './config.ts';
55
+
56
+ /** 环境提示词模板。给模型的文本,固定英文。 */
57
+ const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
58
+
59
+ /** 本 World 唯一的面板局部 id(一页内唯一即可,不带 World 名前缀)。 */
60
+ const PANEL_CHAT = 'chat';
61
+
62
+ /** 口令的形状:六位数字,别的一律当没配置。 */
63
+ const PIN_SHAPE = /^\d{6}$/;
64
+
65
+ /**
66
+ * 模型可见的文本,固定英文:口令标记、回录正文、附件的文本形态、`terminal_send` 的回执。
67
+ * 口令标记写在正文最前面,与普通行(`[HH:MM] 名字: …`)不同形,不易混淆。
68
+ *
69
+ * `invite` 引号里的按钮标签是例外:它逐字取自按下时界面上显示的字,随对方的界面语言走。
70
+ * 被引用的标签是这件事的事实内容,和口令标记里的数字同类。
71
+ */
72
+ const MODEL_TEXT = {
73
+ pinMark: (pin: string) => `[console|PIN:${pin}]`,
74
+ selfLine: (time: string, text: string) => `[${time}] you: ${text}`,
75
+ inviteLine: (time: string, name: string, label: string, spokenBefore: boolean) =>
76
+ `[${time}] ${name} pressed the "${label}" button on the terminal.`
77
+ + (spokenBefore ? '' : ' No speech is recorded in the retained terminal history.'),
78
+ imageFallback: (from: string, i: number, total: number) => `image ${i}/${total} from ${from}`,
79
+ deliveredTo: (delivered: number, names: string[]) =>
80
+ `Sent to the chat channel on the console's "Terminal" page; ${delivered} connection${delivered === 1 ? '' : 's'} online right now`
81
+ + (names.length ? ` (${names.join(', ')})` : ''),
82
+ deliveredToNobody:
83
+ 'Sent to the chat channel on the console\'s "Terminal" page; no connection is open right now, so nobody sees this at the moment; '
84
+ + 'it is stored and will show up in the history for whoever connects next',
85
+ sinceLastSend: (minutes: number, since: number) =>
86
+ `The previous one went out ${minutes} minute${minutes === 1 ? '' : 's'} ago; since then `
87
+ + (since === 0 ? 'nobody has said anything on the terminal' : `${since} message${since === 1 ? '' : 's'} from people arrived on the terminal`),
88
+ joinFacts: (facts: string[]) => `${facts.join('; ')}.`,
89
+ };
90
+
91
+ /**
92
+ * 给操作员看的文案,两种语言各一张表;`en: typeof zh` 由 tsc 保证键集与签名一致。
93
+ * 中文逐字保留现状(测试断言它们)。
94
+ */
95
+ const zh = {
96
+ // ── 配置组 ─────────────────────────────────────────────────────────
97
+ configTitle: '终端 · 控制台口令',
98
+ configDescription:
99
+ '口令是控制台指示的凭据:World 给每条终端消息自动加上 [console|PIN:……] 标记,'
100
+ + '同一串数字进她的系统前缀供比对。对得上的照办,自称控制台却对不上的当普通外部输入。'
101
+ + '操作员不用手打口令,也不该在别处提起它。',
102
+ pinTitle: '控制台口令(六位数字)',
103
+ pinDescription:
104
+ '空 = 不启用:消息不带标记,前缀里也没有可比对的口令。不是六位数字的值一律当未配置'
105
+ + '(控制台徽标会说「格式不对」)。',
106
+ // ── 控制台露出:灯、徽标、面板、模板 ────────────────────────────────
107
+ lampLabel: '对话通道',
108
+ lampOnline: (n: number) => `${n} 人在线`,
109
+ lampNobody: '无人在线',
110
+ badgeOnline: '在线',
111
+ badgeOnlineValue: (n: number) => `${n} 人`,
112
+ badgePin: '口令',
113
+ pinEnabled: '已启用',
114
+ pinMalformed: '格式不对',
115
+ pinUnset: '未设置',
116
+ moduleLabel: '终端对话',
117
+ promptDocTitle: '终端 · 环境提示词',
118
+ promptDocDescription: '终端对话环境的常驻事实。',
119
+ pinVarDescription: '本场的控制台口令(worlds.terminal.pin);没配置或形状不对时展开成模板里的缺省文案。',
120
+ // ── 流上的系统提示与关闭理由 ───────────────────────────────────────
121
+ greeting: '已连接。请发送 {type:"hello", name:"你的名字"} 报上名字。',
122
+ botOffline: 'bot下线',
123
+ left: (name: string) => `${name} 离开了对话`,
124
+ joined: (name: string) => `${name} 进入了对话`,
125
+ notJson: '消息不是合法JSON,已忽略',
126
+ malformed: '消息格式不对,已忽略',
127
+ emptyName: '名字不能为空',
128
+ hello: (name: string) => `你好,${name}。`,
129
+ helloFirst: '请先发送 hello 设置名字',
130
+ imagesRejected: (reason: string) => `图片未发送: ${reason}`,
131
+ botNotConnected: 'bot尚未连接,消息未送达',
132
+ modelBlind: (model: string) => `当前模型 ${model} 不接收图像,她只看到每张图的文字说明`,
133
+ unknownType: (type: string) => `未知消息类型: ${type}`,
134
+ // ── 图片解析的拒收理由 ─────────────────────────────────────────────
135
+ imagesNotArray: 'images 必须是数组',
136
+ tooManyImages: (max: number) => `一条消息最多 ${max} 张图`,
137
+ unsupportedImage: (mime: string) => `不支持的图片格式: ${mime}`,
138
+ imageNoBase64: '图片缺少 base64 内容',
139
+ imageEmpty: '图片内容为空',
140
+ imageTooLarge: (mb: number) => `单张图片超过 ${mb}MB`,
141
+ // ── 流式通道名不对时的错误(措辞会带给对端) ────────────────────────
142
+ unknownPanel: (panel: string) => `未知通道: ${panel}`,
143
+ };
144
+
145
+ const en: typeof zh = {
146
+ configTitle: 'Terminal · Console PIN',
147
+ configDescription:
148
+ 'The PIN is the credential behind console instructions: the module stamps every terminal message with a '
149
+ + '[console|PIN:……] marker, and the same digits go into her system prefix for comparison. A match is obeyed; '
150
+ + 'anything claiming to be the console without a match is ordinary external input. '
151
+ + 'Operators never type the PIN by hand and should not mention it elsewhere.',
152
+ pinTitle: 'Console PIN (six digits)',
153
+ pinDescription:
154
+ 'Empty = disabled: messages carry no marker and the prefix has no PIN to compare against. '
155
+ + 'Any value that is not six digits counts as unset (the console badge says "malformed").',
156
+ lampLabel: 'Chat channel',
157
+ lampOnline: (n) => `${n} online`,
158
+ lampNobody: 'Nobody online',
159
+ badgeOnline: 'Online',
160
+ badgeOnlineValue: (n) => (n === 1 ? '1 person' : `${n} people`),
161
+ badgePin: 'PIN',
162
+ pinEnabled: 'Enabled',
163
+ pinMalformed: 'Malformed',
164
+ pinUnset: 'Not set',
165
+ moduleLabel: 'Terminal chat',
166
+ promptDocTitle: 'Terminal · Environment prompt',
167
+ promptDocDescription: 'Standing facts about the terminal chat environment.',
168
+ pinVarDescription: 'This session\'s console PIN (worlds.terminal.pin); expands to the template\'s default text when unset or malformed.',
169
+ greeting: 'Connected. Send {type:"hello", name:"your name"} to introduce yourself.',
170
+ botOffline: 'bot offline',
171
+ left: (name) => `${name} left the chat`,
172
+ joined: (name) => `${name} joined the chat`,
173
+ notJson: 'Message is not valid JSON; ignored',
174
+ malformed: 'Malformed message; ignored',
175
+ emptyName: 'Name must not be empty',
176
+ hello: (name) => `Hello, ${name}.`,
177
+ helloFirst: 'Send hello to set a name first',
178
+ imagesRejected: (reason) => `Images not sent: ${reason}`,
179
+ botNotConnected: 'The bot is not connected yet; message not delivered',
180
+ modelBlind: (model) => `The current model ${model} does not accept images; she only sees each image's text description`,
181
+ unknownType: (type) => `Unknown message type: ${type}`,
182
+ imagesNotArray: 'images must be an array',
183
+ tooManyImages: (max) => `At most ${max} images per message`,
184
+ unsupportedImage: (mime) => `Unsupported image format: ${mime}`,
185
+ imageNoBase64: 'Image is missing its base64 content',
186
+ imageEmpty: 'Image content is empty',
187
+ imageTooLarge: (mb) => `A single image exceeds ${mb}MB`,
188
+ unknownPanel: (panel) => `Unknown channel: ${panel}`,
189
+ };
190
+
191
+ type TerminalText = typeof zh;
192
+ const text = (language: Language): TerminalText => pick(language, { zh, en });
193
+
194
+ /** 口令配置组,文案按界面语言给;id / owner / 键与取值规则两种语言完全一致。 */
195
+ export function terminalConfigGroup(language: Language): ConfigGroup {
196
+ const t = text(language);
197
+ return {
198
+ id: 'world:terminal',
199
+ owner: 'world:terminal',
200
+ schema: {
201
+ type: 'object',
202
+ title: t.configTitle,
203
+ description: t.configDescription,
204
+ properties: {
205
+ 'worlds.terminal.pin': {
206
+ type: 'string',
207
+ title: t.pinTitle,
208
+ 'x-hot': true,
209
+ description: t.pinDescription,
210
+ },
211
+ },
212
+ },
213
+ };
214
+ }
215
+
216
+ /**
217
+ * 一条对话连接的宿主适配。
218
+ *
219
+ * 存在的理由只有一个:让协议逻辑不知道自己跑在 `ws.WebSocket` 上还是跑在
220
+ * `ConsoleStream` 上。两个实现都在本文件底部,各自五行。
221
+ */
222
+ interface ChatPeer {
223
+ /** 还能收发吗。已关/正在关一律 false。 */
224
+ readonly open: boolean;
225
+ /** 推一帧原始文本。可能抛(调用方按"发不出去即掉线"处理)。 */
226
+ send(raw: string): void;
227
+ /** 正常关闭,`reason` 尽量带给对端。 */
228
+ close(reason: string): void;
229
+ /** 出错时立刻掐断,不走挥手。 */
230
+ drop(): void;
231
+ }
232
+
233
+ interface ChatClient {
234
+ peer: ChatPeer;
235
+ /** hello 完成前为 null,完成后为客户端声明的名称。 */
236
+ name: string | null;
237
+ /** 这条连接握手时的界面语言;流上给它的系统提示按此取。 */
238
+ language: Language;
239
+ }
240
+
241
+
242
+
243
+ interface TerminalWorldOptions {
244
+ /** 渲染[HH:MM]用的时区,默认Asia/Shanghai */
245
+ timezone?: string;
246
+ /** 出方消息的 from 字段;取部署配置的 displayName */
247
+ botName?: string;
248
+ /** 配置节;不给 = 按默认值(口令未配置)。 */
249
+ cfg?: TerminalConfigSection;
250
+ }
251
+
252
+ const NAME_MAX = 32;
253
+ const TEXT_MAX = 4000;
254
+
255
+ /** 一条消息最多带几张图,单张解码后的字节上限。与面板侧的上限一致。 */
256
+ const IMAGES_MAX = 8;
257
+ const IMAGE_MAX_BYTES = 8 * 1024 * 1024;
258
+ /** 接收的图片格式:媒体库认得扩展名、主流多模态模型都吃的那几种。 */
259
+ const IMAGE_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
260
+
261
+ /** 回显/回放帧里的一张图:ref 是附件句柄,客户端凭它经 `blob` 调用取字节。 */
262
+ interface WireImage {
263
+ ref: string;
264
+ mime: string;
265
+ name?: string;
266
+ }
267
+
268
+ /** 一条事件落库后的图片附件 → 回显/回放帧里的 images。 */
269
+ function wireImagesOf(e: EventEnvelope): WireImage[] {
270
+ return (e.blobs ?? [])
271
+ .filter((b) => b.mime.startsWith('image/'))
272
+ .map((b) => ({ ref: b.handle, mime: b.mime, ...(b.name ? { name: b.name } : {}) }));
273
+ }
274
+
275
+ /** `{type:'msg'}` 里 images 的解析结果:要么整批合法,要么一句拒收理由。 */
276
+ type ParsedImages =
277
+ | { ok: true; images: Array<{ bytes: Buffer; mime: string; name?: string }> }
278
+ | { ok: false; reason: string };
279
+
280
+ function parseImages(raw: unknown, t: TerminalText): ParsedImages {
281
+ if (raw === undefined) return { ok: true, images: [] };
282
+ if (!Array.isArray(raw)) return { ok: false, reason: t.imagesNotArray };
283
+ if (raw.length > IMAGES_MAX) return { ok: false, reason: t.tooManyImages(IMAGES_MAX) };
284
+ const images: Array<{ bytes: Buffer; mime: string; name?: string }> = [];
285
+ for (const item of raw) {
286
+ const img = (item ?? {}) as { mime?: unknown; base64?: unknown; name?: unknown };
287
+ if (typeof img.mime !== 'string' || !IMAGE_MIMES.has(img.mime)) {
288
+ return { ok: false, reason: t.unsupportedImage(String(img.mime)) };
289
+ }
290
+ if (typeof img.base64 !== 'string' || !img.base64) return { ok: false, reason: t.imageNoBase64 };
291
+ const bytes = Buffer.from(img.base64, 'base64');
292
+ if (bytes.length === 0) return { ok: false, reason: t.imageEmpty };
293
+ if (bytes.length > IMAGE_MAX_BYTES) {
294
+ return { ok: false, reason: t.imageTooLarge(IMAGE_MAX_BYTES / 1024 / 1024) };
295
+ }
296
+ const name = typeof img.name === 'string' && img.name.trim() ? img.name.trim().slice(0, 120) : undefined;
297
+ images.push({ bytes, mime: img.mime, ...(name ? { name } : {}) });
298
+ }
299
+ return { ok: true, images };
300
+ }
301
+
302
+ export class TerminalWorld implements World {
303
+ readonly id = 'terminal';
304
+ private host: WorldHost | null = null;
305
+ private readonly clients = new Set<ChatClient>();
306
+ private readonly timezone: string;
307
+ private readonly botName: string;
308
+ private readonly cfg: TerminalConfigSection;
309
+ /** 上一条 terminal_send 的时刻;null = 本场还没发过 */
310
+ private lastSendAt: number | null = null;
311
+ /** 本场收到的操作员消息条数,以及上一条 terminal_send 发出时的读数 */
312
+ private operatorMessages = 0;
313
+ private operatorMessagesAtLastSend = 0;
314
+
315
+ constructor(opts: TerminalWorldOptions = {}) {
316
+ this.timezone = opts.timezone ?? 'Asia/Shanghai';
317
+ this.botName = opts.botName ?? 'bot';
318
+ this.cfg = opts.cfg ?? { ...TERMINAL_DEFAULTS };
319
+ }
320
+
321
+ /** 生效中的口令;形状不对当没配置(模板的缺省文案会接管那一行)。 */
322
+ private activePin(): string {
323
+ const pin = this.cfg.pin.trim();
324
+ return PIN_SHAPE.test(pin) ? pin : '';
325
+ }
326
+
327
+ /** 文本归 ENV_PROMPT.md,这里只报口令这一个值。 */
328
+ envPromptVars(): Record<string, string> {
329
+ return { 'terminal.pin': this.activePin() };
330
+ }
331
+
332
+ /** 控制台露出:在线人数与口令状态 + 一个通往对话页的面板。文案按这次请求的界面语言。 */
333
+ console(language: Language = 'zh'): WorldConsoleDecl {
334
+ const t = text(language);
335
+ const online = this.onlineCount();
336
+ const pin = this.activePin();
337
+ // 形状不对时静默当未配置会让人以为口令已经生效,所以这里把两种"没生效"分开说。
338
+ const pinState = pin ? t.pinEnabled : this.cfg.pin.trim() ? t.pinMalformed : t.pinUnset;
339
+ return {
340
+ label: t.moduleLabel,
341
+ // 一条链路:这个渠道不连外部任何东西,挂上了就是通的。没人在线不是故障——
342
+ // 灯报的是"能不能说上话",不是"此刻有没有人在说"。
343
+ lamps: [{
344
+ label: t.lampLabel,
345
+ state: 'online',
346
+ hint: online > 0 ? t.lampOnline(online) : t.lampNobody,
347
+ }],
348
+ badges: [
349
+ { label: t.badgeOnline, value: t.badgeOnlineValue(online), tone: online > 0 ? 'on' : 'off' },
350
+ // 口令本身不进徽标:控制台页面会被投屏、被截图。
351
+ { label: t.badgePin, value: pinState, tone: pin ? 'on' : 'off' },
352
+ ],
353
+ // 流式面:终端页经 /ws/providers/world%3Aterminal/panels/chat 接进来。这一页没有自己的面板,
354
+ // 对话只在终端页;这里只剩配置、模板、徽标与灯。
355
+ stream: (panel, socket) => this.stream(panel, socket, language),
356
+ promptDocs: [
357
+ {
358
+ key: 'worlds.terminal.envPrompt',
359
+ title: t.promptDocTitle,
360
+ description: t.promptDocDescription,
361
+ // bot 侧的覆盖文件(worlds/terminal/ENV_PROMPT.md)整份优先。
362
+ path: ENV_PROMPT_FILE,
363
+ role: 'envPrompt',
364
+ vars: [{
365
+ name: 'terminal.pin',
366
+ description: t.pinVarDescription,
367
+ }],
368
+ },
369
+ ],
370
+ config: [terminalConfigGroup(language)],
371
+ };
372
+ }
373
+
374
+ async start(host: WorldHost): Promise<void> {
375
+ this.host = host;
376
+ }
377
+
378
+ async stop(): Promise<void> {
379
+ // 关闭连接前清除 host,禁止 close 回调继续投递 presence 事件。
380
+ this.host = null;
381
+ for (const c of [...this.clients]) {
382
+ try { c.peer.close(text(c.language).botOffline); } catch { /* ignore */ }
383
+ }
384
+ this.clients.clear();
385
+ }
386
+
387
+ /**
388
+ * terminal_send 回执报告目标、当前连接者、距上次发送的时间及其后是否收到终端发言。没有已读回执,不能判断消息是否被阅读;终端 World 不推断其他 World 的直播状态,也不附加建议。
389
+ */
390
+ private deliveryFacts(delivered: number): string {
391
+ const t = MODEL_TEXT;
392
+ const facts: string[] = [];
393
+ const names = [...this.clients]
394
+ .filter((c) => c.peer.open && c.name !== null)
395
+ .map((c) => c.name as string);
396
+ facts.push(delivered > 0 ? t.deliveredTo(delivered, names) : t.deliveredToNobody);
397
+ if (this.lastSendAt !== null) {
398
+ const minutes = Math.round((Date.now() - this.lastSendAt) / 60_000);
399
+ const since = this.operatorMessages - this.operatorMessagesAtLastSend;
400
+ facts.push(t.sinceLastSend(minutes, since));
401
+ }
402
+ return t.joinFacts(facts);
403
+ }
404
+
405
+ /** 当前仍打开的客户端数(面板status可用) */
406
+ onlineCount(): number {
407
+ let n = 0;
408
+ for (const c of this.clients) if (c.peer.open) n++;
409
+ return n;
410
+ }
411
+
412
+ /**
413
+ * 通用流式通道的入口。控制台把 `/ws/providers/worlds:terminal/panels/chat` 的连接
414
+ * 包成 `ConsoleStream` 交进来,World 从这里接管整条连接的生命周期。
415
+ *
416
+ * 扇出语义由协议定死:**一条连接一次调用**,框架不广播也不去重——所以本 World
417
+ * 自己持一个 `clients` 集合,同时在场的人在同一个对话里,互相看得见。
418
+ *
419
+ * 未知面板抛错:服务端会只关这一条连接并把措辞带给对端(1011)。
420
+ * `language` 是握手那一刻浏览器的界面语言,这条流上的系统提示都按它给。
421
+ */
422
+ stream(panel: string, socket: ConsoleStream, language: Language = 'zh'): void {
423
+ if (panel !== PANEL_CHAT) throw new Error(text(language).unknownPanel(panel));
424
+ const client = this.attach(streamPeer(socket), language);
425
+ socket.onMessage((text) => this.onFrame(client, text));
426
+ socket.onClose(() => this.detach(client));
427
+ this.greet(client);
428
+ }
429
+
430
+ tools(): ToolDef[] {
431
+ return [
432
+ {
433
+ // 固定叫 terminal_send,与前缀文档里的「终端」同名
434
+ name: 'terminal_send',
435
+ description: 'Send text to everyone connected to the terminal.',
436
+ tags: ['speak'],
437
+ parameters: {
438
+ type: 'object',
439
+ properties: {
440
+ text: { type: 'string', description: 'Text to send.' },
441
+ },
442
+ required: ['text'],
443
+ },
444
+ handler: async (args) => {
445
+ const text = typeof args.text === 'string' ? args.text.trim() : '';
446
+ if (!text) return '[send failed] text must not be empty';
447
+ if (!this.host) return '[send failed] terminal module not started';
448
+ const delivered = this.broadcast({
449
+ type: 'msg',
450
+ from: this.botName,
451
+ text,
452
+ ts: nowIso(this.timezone),
453
+ });
454
+ // 自己的消息回录:只落库不投递(不会把自己吵醒)
455
+ this.host.pushEvent(
456
+ {
457
+ type: 'terminal.self',
458
+ ts: nowIso(this.timezone),
459
+ source: this.id,
460
+ text: MODEL_TEXT.selfLine(shortTime(this.timezone), text),
461
+ meta: { from: this.botName, body: text },
462
+ },
463
+ { deliver: false },
464
+ ).catch((e) => this.host?.log.warn('自录事件落库失败', { err: String(e) }));
465
+ const receipt = `[sent] ${this.deliveryFacts(delivered)}`;
466
+ this.lastSendAt = Date.now();
467
+ this.operatorMessagesAtLastSend = this.operatorMessages;
468
+ return receipt;
469
+ },
470
+ },
471
+ ];
472
+ }
473
+
474
+ // ── 连接生命周期(两条入口共用) ───────────────────────────────────────
475
+
476
+ /** 登记一条新连接。回调的挂接归各自入口,因为那是宿主 API 唯一不同的地方。 */
477
+ private attach(peer: ChatPeer, language: Language): ChatClient {
478
+ const client: ChatClient = { peer, name: null, language };
479
+ this.clients.add(client);
480
+ return client;
481
+ }
482
+
483
+ /** 开场白在全部回调挂载后发送。 */
484
+ private greet(client: ChatClient): void {
485
+ this.sendJson(client.peer, { type: 'sys', text: text(client.language).greeting });
486
+ }
487
+
488
+ /** 连接结束:出名单、给还在场的人一句提示。进出不打扰bot。 */
489
+ private detach(client: ChatClient): void {
490
+ this.clients.delete(client);
491
+ if (client.name === null || !this.host) return;
492
+ const name = client.name;
493
+ this.broadcastSys((t) => t.left(name), null);
494
+ }
495
+
496
+ /** 给在场的每条流一句系统提示,各按自己的语言;`except` 那条不发。 */
497
+ private broadcastSys(line: (t: TerminalText) => string, except: ChatClient | null): void {
498
+ for (const c of [...this.clients]) {
499
+ if (c === except || !c.peer.open) continue;
500
+ this.sendJson(c.peer, { type: 'sys', text: line(text(c.language)) });
501
+ }
502
+ }
503
+
504
+ // ── 协议(两条入口共用的唯一一份) ───────────────────────────────────
505
+
506
+ /**
507
+ * 收到一帧原始文本。**这是协议解析的唯一入口**——`/ws/chat` 的 `RawData` 与
508
+ * 流式通道的 `string` 在各自入口就已经归一成文本,到这里两条路完全同一份代码。
509
+ */
510
+ private onFrame(client: ChatClient, raw: string): void {
511
+ const t = text(client.language);
512
+ let parsed: unknown;
513
+ try {
514
+ parsed = JSON.parse(raw);
515
+ } catch {
516
+ this.sendJson(client.peer, { type: 'sys', text: t.notJson });
517
+ return;
518
+ }
519
+ if (parsed === null || typeof parsed !== 'object') {
520
+ this.sendJson(client.peer, { type: 'sys', text: t.malformed });
521
+ return;
522
+ }
523
+ const msg = parsed as Record<string, unknown>;
524
+
525
+ if (msg.type === 'hello') {
526
+ const name = typeof msg.name === 'string' ? msg.name.trim().slice(0, NAME_MAX) : '';
527
+ if (!name) {
528
+ this.sendJson(client.peer, { type: 'sys', text: t.emptyName });
529
+ return;
530
+ }
531
+ const firstHello = client.name === null;
532
+ client.name = name;
533
+ this.sendJson(client.peer, { type: 'sys', text: t.hello(name) });
534
+ if (firstHello) this.replayHistory(client);
535
+ // 给其他在场客户端一条提示;进出不打扰bot
536
+ if (firstHello) this.broadcastSys((tt) => tt.joined(name), client);
537
+ return;
538
+ }
539
+
540
+ if (msg.type === 'msg') {
541
+ if (client.name === null) {
542
+ this.sendJson(client.peer, { type: 'sys', text: t.helloFirst });
543
+ return;
544
+ }
545
+ const text = typeof msg.text === 'string' ? msg.text.trim().slice(0, TEXT_MAX) : '';
546
+ const parsed = parseImages(msg.images, t);
547
+ if (!parsed.ok) {
548
+ this.sendJson(client.peer, { type: 'sys', text: t.imagesRejected(parsed.reason) });
549
+ return;
550
+ }
551
+ if (!text && parsed.images.length === 0) return; // 空消息静默忽略
552
+ if (!this.host) {
553
+ this.sendJson(client.peer, { type: 'sys', text: t.botNotConnected });
554
+ return;
555
+ }
556
+ // 图片随事件落库:core 分配句柄并把每张的文本形态接在正文后;回显与回放只带句柄。
557
+ const visible = parsed.images.length > 0 && this.host.modelFacts.accepts(parsed.images[0].mime);
558
+ const total = parsed.images.length;
559
+ const from = client.name;
560
+ const blobs: BlobInput[] = parsed.images.map((img, i) => ({
561
+ bytes: img.bytes,
562
+ mime: img.mime,
563
+ ...(img.name ? { name: img.name } : {}),
564
+ fallbackText: MODEL_TEXT.imageFallback(from, i + 1, total),
565
+ }));
566
+ // 操作员消息:落库 + internal 投递(进 user 区)+ flush(跳过合批安静窗口)。
567
+ // 口令标记由 World 加,操作员不打;meta.from/body 供 hello 时历史回放还原,图片从落库的 blobs 取。
568
+ const pin = this.activePin();
569
+ const line = `[${shortTime(this.timezone)}] ${client.name}: ${text}`;
570
+ // terminal_send 回执要报"上一条发出之后终端上有没有人说过话",按条数记比
571
+ // 按时间戳比更准:事件库的 ts 只到秒,同秒的消息分不出先后。
572
+ this.operatorMessages += 1;
573
+ const host = this.host;
574
+ host.pushEvent(
575
+ {
576
+ type: 'terminal.message',
577
+ ts: nowIso(this.timezone),
578
+ source: this.id,
579
+ origin: 'internal',
580
+ text: pin ? `${MODEL_TEXT.pinMark(pin)} ${line}` : line,
581
+ senderKey: client.name,
582
+ meta: { from: client.name, body: text },
583
+ ...(blobs.length ? { blobs } : {}),
584
+ },
585
+ { trigger: 'flush' },
586
+ ).then((envelope) => {
587
+ // 回显广播给所有客户端(含发送者本人——前端以回显为准渲染);句柄在落库后才有。
588
+ const wireImages = wireImagesOf(envelope);
589
+ this.broadcast({
590
+ type: 'msg', from: client.name, text, ts: envelope.ts,
591
+ ...(wireImages.length ? { images: wireImages } : {}),
592
+ });
593
+ }).catch((e) => host.log.warn('消息事件投递失败', { err: String(e) }));
594
+ if (total > 0 && !visible) {
595
+ this.sendJson(client.peer, { type: 'sys', text: t.modelBlind(host.modelFacts.model()) });
596
+ }
597
+ return;
598
+ }
599
+
600
+ if (msg.type === 'greet') {
601
+ if (client.name === null) {
602
+ this.sendJson(client.peer, { type: 'sys', text: t.helloFirst });
603
+ return;
604
+ }
605
+ if (!this.host) {
606
+ this.sendJson(client.peer, { type: 'sys', text: t.botNotConnected });
607
+ return;
608
+ }
609
+ // 事件引用单行标签;没有标签时不产生事件。
610
+ const label = typeof msg.label === 'string' ? msg.label.replace(/\s+/g, ' ').trim() : '';
611
+ if (!label) return;
612
+ const host = this.host;
613
+ host.pushEvent(
614
+ {
615
+ type: 'terminal.invite',
616
+ ts: nowIso(this.timezone),
617
+ source: this.id,
618
+ origin: 'internal',
619
+ text: MODEL_TEXT.inviteLine(shortTime(this.timezone), client.name, label, this.spokenBefore()),
620
+ senderKey: client.name,
621
+ meta: { from: client.name, label },
622
+ },
623
+ { trigger: 'flush' },
624
+ ).catch((e) => host.log.warn('按钮事件投递失败', { err: String(e) }));
625
+ return;
626
+ }
627
+
628
+ this.sendJson(client.peer, { type: 'sys', text: t.unknownType(String(msg.type)) });
629
+ }
630
+
631
+ /** 已保存的终端事件中是否有发言。 */
632
+ private spokenBefore(): boolean {
633
+ if (!this.host) return false;
634
+ return this.host.store
635
+ .range({ source: this.id })
636
+ .some((e) => e.type === 'terminal.message' || e.type === 'terminal.self');
637
+ }
638
+
639
+ /** hello 后回放最近的对话历史;发言人与正文读本 World 落库时写下的 meta.from/body。 */
640
+ private replayHistory(client: ChatClient): void {
641
+ if (!this.host) return;
642
+ const events = this.host.store
643
+ .range({ source: this.id, limit: 60 })
644
+ .filter((e) => e.type === 'terminal.message' || e.type === 'terminal.self')
645
+ .slice(-30);
646
+ for (const e of events) {
647
+ const { from, body } = e.meta as { from: string; body: string };
648
+ const images = wireImagesOf(e);
649
+ this.sendJson(client.peer, {
650
+ type: 'msg', from, text: body, ts: e.ts, history: true,
651
+ ...(images.length ? { images } : {}),
652
+ });
653
+ }
654
+ }
655
+
656
+ /** 返回实际送达的客户端数,并移除已关闭的连接。 */
657
+ private broadcast(payload: unknown): number {
658
+ const raw = JSON.stringify(payload);
659
+ let n = 0;
660
+ for (const c of [...this.clients]) {
661
+ if (c.peer.open) {
662
+ try {
663
+ c.peer.send(raw);
664
+ n++;
665
+ } catch {
666
+ this.clients.delete(c);
667
+ c.peer.drop();
668
+ }
669
+ } else {
670
+ // close 回调迟到前先移除已关闭的连接。
671
+ this.clients.delete(c);
672
+ }
673
+ }
674
+ return n;
675
+ }
676
+
677
+ private sendJson(peer: ChatPeer, payload: unknown): void {
678
+ if (!peer.open) return;
679
+ try { peer.send(JSON.stringify(payload)); } catch { /* ignore */ }
680
+ }
681
+ }
682
+
683
+ // ---------------------------------------------------------------------------
684
+ // 宿主适配:把流式连接对象包成 ChatPeer
685
+ // ---------------------------------------------------------------------------
686
+
687
+ /**
688
+ * 通用流式通道。
689
+ *
690
+ * `ConsoleStream` 的 `send` 已经保证"已关时静默丢弃、不抛",`close` 也不抛,
691
+ * 所以这层几乎是恒等映射;`drop` 没有对应物(协议里没有 terminate),退成 close。
692
+ */
693
+ function streamPeer(socket: ConsoleStream): ChatPeer {
694
+ return {
695
+ get open(): boolean { return socket.open; },
696
+ send(raw: string): void { socket.send(raw); },
697
+ close(reason: string): void { socket.close(reason); },
698
+ drop(): void { try { socket.close('connection error'); } catch { /* ignore */ } },
699
+ };
700
+ }