oh-my-agent-web 0.2.0 → 0.4.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 (250) hide show
  1. package/README.md +86 -447
  2. package/dist/server/approval/read-only.js +107 -0
  3. package/dist/server/approval/rules.js +85 -0
  4. package/dist/server/approval/scope.js +150 -0
  5. package/dist/server/brand.js +2 -0
  6. package/dist/server/cli-config.js +52 -0
  7. package/dist/server/cli-info.js +101 -0
  8. package/dist/server/cli.js +304 -0
  9. package/dist/server/engine/builtin.js +61 -0
  10. package/dist/server/engine/catalog.js +8 -0
  11. package/dist/server/engine/codex-approvals.js +45 -0
  12. package/dist/server/engine/codex-map.js +298 -0
  13. package/dist/server/engine/codex-sessions.js +159 -0
  14. package/dist/server/engine/codex-transport.js +49 -0
  15. package/dist/server/engine/codex.js +314 -0
  16. package/dist/server/engine/dsh-history.js +195 -0
  17. package/dist/server/engine/dsh-log.js +245 -0
  18. package/dist/server/engine/dsh-map.js +221 -0
  19. package/dist/server/engine/dsh-sessions.js +207 -0
  20. package/dist/server/engine/dsh-transport.js +37 -0
  21. package/dist/server/engine/dsh.js +284 -0
  22. package/dist/server/engine/fake.js +231 -0
  23. package/dist/server/engine/jsonrpc-stdio.js +241 -0
  24. package/dist/server/engine/model-registry-reload.js +115 -0
  25. package/dist/server/engine/pi-events.js +148 -0
  26. package/dist/server/engine/pi-rename.js +14 -0
  27. package/dist/server/engine/pi-sdk.js +343 -0
  28. package/dist/server/engine/pi-session-model.js +23 -0
  29. package/dist/server/engine/pi-thinking.js +34 -0
  30. package/dist/server/engine/pi-transcript.js +225 -0
  31. package/dist/server/engine/pi-version.js +11 -0
  32. package/dist/server/engine/port.js +1 -0
  33. package/dist/server/engine/registry.js +47 -0
  34. package/dist/server/engine/scratch.js +13 -0
  35. package/dist/server/engine/suggestion-guidance.js +9 -0
  36. package/dist/server/engine/switchable.js +22 -0
  37. package/dist/server/engine/wiring.js +107 -0
  38. package/dist/server/files/dir-routes.js +40 -0
  39. package/dist/server/files/file-search.js +63 -0
  40. package/dist/server/files/file-service.js +232 -0
  41. package/dist/server/files/files-routes.js +182 -0
  42. package/dist/server/files/git-info.js +186 -0
  43. package/dist/server/http/auth-routes.js +99 -0
  44. package/dist/server/http/auth.js +191 -0
  45. package/dist/server/http/aux-routes.js +24 -0
  46. package/dist/server/http/compress.js +75 -0
  47. package/dist/server/http/health-and-static.js +33 -0
  48. package/dist/server/http/live-routes.js +113 -0
  49. package/dist/server/http/login-page.js +200 -0
  50. package/dist/server/index.js +342 -520
  51. package/dist/server/markers/actions.js +262 -0
  52. package/dist/server/protocol-parse.js +201 -0
  53. package/dist/server/protocol-settings.js +1 -0
  54. package/dist/server/protocol.js +6 -0
  55. package/dist/server/providers/catalog-store.js +85 -0
  56. package/dist/server/providers/catalog-writes.js +197 -0
  57. package/dist/server/providers/catalog.js +77 -0
  58. package/dist/server/providers/enabled-models.js +118 -0
  59. package/dist/server/providers/import-models.js +69 -0
  60. package/dist/server/providers/import-routes.js +136 -0
  61. package/dist/server/providers/model-test.js +52 -0
  62. package/dist/server/providers/probe.js +64 -0
  63. package/dist/server/providers/provider-handlers.js +203 -0
  64. package/dist/server/providers/provider-store.js +327 -0
  65. package/dist/server/providers/request-headers.js +7 -0
  66. package/dist/server/providers/validate.js +241 -0
  67. package/dist/server/push/push-routes.js +114 -0
  68. package/dist/server/push/push.js +120 -0
  69. package/dist/server/session/approval-bridge.js +103 -0
  70. package/dist/server/session/attachment-resolve.js +21 -0
  71. package/dist/server/session/client-view.js +82 -0
  72. package/dist/server/session/conversation-create.js +65 -0
  73. package/dist/server/session/delete-session.js +84 -0
  74. package/dist/server/session/engine-switch.js +108 -0
  75. package/dist/server/session/lock.js +82 -0
  76. package/dist/server/session/model-controller.js +111 -0
  77. package/dist/server/session/notices.js +31 -0
  78. package/dist/server/session/pi-opener.js +123 -0
  79. package/dist/server/session/pi-refresh.js +51 -0
  80. package/dist/server/session/pi-sessions-provider.js +46 -0
  81. package/dist/server/session/pi-sessions.js +272 -0
  82. package/dist/server/session/refresh-actions.js +42 -0
  83. package/dist/server/session/rename-session.js +23 -0
  84. package/dist/server/session/runtime-services.js +28 -0
  85. package/dist/server/session/runtime-types.js +1 -0
  86. package/dist/server/session/runtime.js +358 -0
  87. package/dist/server/session/session-stats.js +88 -0
  88. package/dist/server/session/settings.js +367 -0
  89. package/dist/server/session/snapshot-for.js +7 -0
  90. package/dist/server/session/stats-refresher.js +50 -0
  91. package/dist/server/session/switch-conversation.js +86 -0
  92. package/dist/server/session/throttle.js +14 -0
  93. package/dist/server/session/turn-events.js +126 -0
  94. package/dist/server/session/turn-finish.js +79 -0
  95. package/dist/server/session/turn-markers.js +26 -0
  96. package/dist/server/session/turn-queue.js +111 -0
  97. package/dist/server/session/turn-send.js +88 -0
  98. package/dist/server/session/turn-watchdog.js +77 -0
  99. package/dist/server/sessions/session-routes.js +17 -0
  100. package/dist/server/skills/skill-routes.js +28 -0
  101. package/dist/server/skills/skill-store.js +134 -0
  102. package/dist/server/snapshot/fan-out.js +10 -0
  103. package/dist/server/snapshot/messages.js +75 -0
  104. package/dist/server/snapshot/sender.js +62 -0
  105. package/dist/server/snapshot/state.js +193 -0
  106. package/dist/server/terminal/terminal-routes.js +176 -0
  107. package/dist/server/terminal/terminal-session.js +140 -0
  108. package/dist/server/uploads/upload-routes.js +64 -0
  109. package/dist/server/uploads/upload-store.js +115 -0
  110. package/dist/server/util/data-dir.js +36 -0
  111. package/dist/server/util/env.js +140 -0
  112. package/dist/server/util/ids.js +28 -0
  113. package/dist/server/util/log-file.js +67 -0
  114. package/dist/server/util/log.js +52 -0
  115. package/dist/server/util/origin.js +75 -0
  116. package/dist/server/util/paths.js +60 -0
  117. package/dist/server/util/pricing-index.js +121 -0
  118. package/dist/server/util/pricing.js +214 -0
  119. package/dist/server/util/startup.js +34 -0
  120. package/dist/server/util/upstream-version.js +140 -0
  121. package/dist/server/util/usage.js +19 -0
  122. package/dist/server/util/version.js +50 -0
  123. package/dist/server/ws/handle-message.js +95 -0
  124. package/package.json +49 -63
  125. package/web/dist/apple-touch-icon-precomposed.png +0 -0
  126. package/web/dist/apple-touch-icon.png +0 -0
  127. package/web/dist/assets/abnfDiagram-WEYOIA3C-NLp09iEC.js +1 -0
  128. package/web/dist/assets/arc-DviL9o3c.js +1 -0
  129. package/web/dist/assets/architecture-WOLXFQ4H-fjsM_Sy3.js +1 -0
  130. package/web/dist/assets/architectureDiagram-ZHVYKT53-Cn3B4TE8.js +36 -0
  131. package/web/dist/assets/array-BifhSqXX.js +1 -0
  132. package/web/dist/assets/blockDiagram-RJAE7QA3-CIuvxWvi.js +140 -0
  133. package/web/dist/assets/c4Diagram-XDPPPE54-DvdKmEpF.js +38 -0
  134. package/web/dist/assets/channel-C3AQ6nAs.js +1 -0
  135. package/web/dist/assets/chunk-2E7WHVMD-HNNASGCS.js +88 -0
  136. package/web/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  137. package/web/dist/assets/chunk-4EMNKR2T-Cg-eRwBE.js +1 -0
  138. package/web/dist/assets/chunk-55XCU76K-BweiisZL.js +1 -0
  139. package/web/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  140. package/web/dist/assets/chunk-7S6XSTFO-BCzIiP-6.js +2 -0
  141. package/web/dist/assets/chunk-AQDTSKZB-Dl0NRFuU.js +72 -0
  142. package/web/dist/assets/chunk-DSNLSHJZ-zhHCjjN9.js +129 -0
  143. package/web/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  144. package/web/dist/assets/chunk-HAGFOTED-BDQqU2EH.js +62 -0
  145. package/web/dist/assets/chunk-HNE4Q36P-B-qRzMjX.js +1 -0
  146. package/web/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  147. package/web/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  148. package/web/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  149. package/web/dist/assets/chunk-NQOKRVIF-CNcvhMn_.js +213 -0
  150. package/web/dist/assets/chunk-UHW7PFBP-Dwr8LUzr.js +1 -0
  151. package/web/dist/assets/chunk-VQ5W62QI-O8sN6jJu.js +1 -0
  152. package/web/dist/assets/chunk-WQQL4CDK-B6-59Zg2.js +10 -0
  153. package/web/dist/assets/chunk-XXDRQBXY-DvSbeUlZ.js +1 -0
  154. package/web/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  155. package/web/dist/assets/chunk-YZO67HFG-BXpKYKsw.js +1 -0
  156. package/web/dist/assets/chunk-ZAUX2LTX-B1Z5VSkz.js +1 -0
  157. package/web/dist/assets/chunk-ZCHMQSOR-h_5mBLIA.js +2 -0
  158. package/web/dist/assets/classDiagram-v2-4MYWR3UA-Dr6JnaaV.js +217 -0
  159. package/web/dist/assets/cose-bilkent-JH36ORCC-36ShkR7R.js +1 -0
  160. package/web/dist/assets/cynefin-EF2NZ3EQ-Dg6bI7pC.js +1 -0
  161. package/web/dist/assets/cynefinDiagram-WF24UDRP-B0YHpBlR.js +62 -0
  162. package/web/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  163. package/web/dist/assets/dagre-NK4GIZVX-Cx3mn7YS.js +4 -0
  164. package/web/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  165. package/web/dist/assets/diagram-CBK3MWTC-l8aZG_Bm.js +24 -0
  166. package/web/dist/assets/diagram-I5XIIV2D-Bd5f14_h.js +24 -0
  167. package/web/dist/assets/diagram-ODIL2UCW-hBsxCYUj.js +30 -0
  168. package/web/dist/assets/diagram-PFBEJUXE-BgpIBUYl.js +3 -0
  169. package/web/dist/assets/diagram-RJA3I4VV-CDU1gUVD.js +41 -0
  170. package/web/dist/assets/diagram-TYUNFSNR-Iuxh7Tqf.js +200 -0
  171. package/web/dist/assets/dist-C86cKTuE.js +1 -0
  172. package/web/dist/assets/ebnfDiagram-V4P3TTPT-Cms0HFKh.js +1 -0
  173. package/web/dist/assets/elk-QONTBCTQ-B7yEy0PQ.js +27 -0
  174. package/web/dist/assets/erDiagram-KYQIGQAA-CL48j4aU.js +99 -0
  175. package/web/dist/assets/eventmodeling-K75KTNOO-CFvkaejh.js +1 -0
  176. package/web/dist/assets/flowDiagram-IUGLLWBU-6UyKXpqG.js +1 -0
  177. package/web/dist/assets/ganttDiagram-R7GPNTJY-vdMr-0ZV.js +292 -0
  178. package/web/dist/assets/gitGraph-VSP46ZUC-2z85Wuwo.js +1 -0
  179. package/web/dist/assets/gitGraphDiagram-PDYL7XHY-BY4GPiqC.js +106 -0
  180. package/web/dist/assets/graphlib-DS17s2tU.js +1 -0
  181. package/web/dist/assets/index-Bjn56a2i.js +76 -0
  182. package/web/dist/assets/index-Drp6e5iU.css +1 -0
  183. package/web/dist/assets/info-OHQRW6UA-ECzkYTx-.js +1 -0
  184. package/web/dist/assets/infoDiagram-MVDRKAX5-DDk5EAeb.js +2 -0
  185. package/web/dist/assets/init-C-OQMol4.js +1 -0
  186. package/web/dist/assets/ishikawaDiagram-HPEHV2GE-9yPH4J94.js +70 -0
  187. package/web/dist/assets/journeyDiagram-BOSHW7O7-BPTIap3l.js +139 -0
  188. package/web/dist/assets/kanban-definition-FNSA4HC5-BbADHrA8.js +89 -0
  189. package/web/dist/assets/katex-B_JcIwqj.js +257 -0
  190. package/web/dist/assets/line-CHV9_4lL.js +1 -0
  191. package/web/dist/assets/linear-C-eZsBTv.js +1 -0
  192. package/web/dist/assets/mermaid-parser.core-BiAhX1l1.js +7 -0
  193. package/web/dist/assets/mermaid.core-BxP_A_zj.js +44 -0
  194. package/web/dist/assets/mindmap-definition-6OY57UG3-BMWlit95.js +98 -0
  195. package/web/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  196. package/web/dist/assets/packet-JDAUHWVQ-FVApuKOp.js +1 -0
  197. package/web/dist/assets/path-fybaL0A-.js +1 -0
  198. package/web/dist/assets/pegDiagram-ECIUJNBX-WjWJvDHM.js +1 -0
  199. package/web/dist/assets/pie-XZMESJXO-BGCZnvhp.js +1 -0
  200. package/web/dist/assets/pieDiagram-GNMPY74T-DJpAC_JC.js +39 -0
  201. package/web/dist/assets/quadrantDiagram-D57K4MC3-hxx5c7oq.js +7 -0
  202. package/web/dist/assets/radar-ABXABTNO-BU9Shc-O.js +1 -0
  203. package/web/dist/assets/railroad-I3PHUGI6-pWj8d-G4.js +1 -0
  204. package/web/dist/assets/railroad-abnf-LNEFI6M7-D7HZBTAj.js +1 -0
  205. package/web/dist/assets/railroad-ebnf-DWYD2UWJ-CKiXJSuM.js +1 -0
  206. package/web/dist/assets/railroad-peg-JR7BVNR7-DB3VJHgR.js +1 -0
  207. package/web/dist/assets/railroadDiagram-Y72NRBL7-Cev7Y65k.js +1 -0
  208. package/web/dist/assets/requirementDiagram-EQYSW5RA-CqbwMLD8.js +84 -0
  209. package/web/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  210. package/web/dist/assets/sankeyDiagram-SY24JHO5-V2cyVi8q.js +40 -0
  211. package/web/dist/assets/sequenceDiagram-BLEHNHQU-By6NJOPM.js +169 -0
  212. package/web/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  213. package/web/dist/assets/src-Da91fG3g.js +1 -0
  214. package/web/dist/assets/stateDiagram-v2-A6IESKWZ-BnPGX5UI.js +291 -0
  215. package/web/dist/assets/swimlanes-B3ENRKFQ-C58BpIpm.js +1 -0
  216. package/web/dist/assets/swimlanesDiagram-AYQRV4AK-RaivxSsc.js +8 -0
  217. package/web/dist/assets/timeline-definition-JXYKGLGB-Hgo1DhnS.js +120 -0
  218. package/web/dist/assets/treeView-D4JQ5SDB-DF0dfjqB.js +1 -0
  219. package/web/dist/assets/treemap-SAJKECNS-CSSM5FqB.js +1 -0
  220. package/web/dist/assets/usecaseDiagram-WPOLBNNX-BkBBD1NY.js +328 -0
  221. package/web/dist/assets/vennDiagram-Q3Y3IDCW-Bm28keX_.js +34 -0
  222. package/web/dist/assets/wardley-7MLQ67FV-By7KgEQX.js +1 -0
  223. package/web/dist/assets/wardleyDiagram-IVAIDKWA-oVtIrnGa.js +78 -0
  224. package/web/dist/assets/xychartDiagram-4URNHDU4-aI8FH4g2.js +7 -0
  225. package/web/dist/favicon-32.png +0 -0
  226. package/web/dist/favicon.ico +0 -0
  227. package/web/dist/favicon.svg +20 -8
  228. package/web/dist/icons/apple-touch-icon-180.png +0 -0
  229. package/web/dist/icons/icon-192.png +0 -0
  230. package/web/dist/icons/icon-512.png +0 -0
  231. package/web/dist/icons/icon-maskable-512.png +0 -0
  232. package/web/dist/index.html +21 -27
  233. package/web/dist/manifest.webmanifest +17 -0
  234. package/web/dist/maskable.svg +25 -0
  235. package/web/dist/sw.js +40 -0
  236. package/bin/oh-my-agent-web-ps.mjs +0 -203
  237. package/bin/oh-my-agent-web-ws.mjs +0 -593
  238. package/bin/oh-my-agent-web.mjs +0 -321
  239. package/bin/update-check.mjs +0 -142
  240. package/dist/server/codex-client.js +0 -310
  241. package/dist/server/codex-supervisor.js +0 -446
  242. package/dist/server/fs-service.js +0 -144
  243. package/dist/server/request-inspector.js +0 -458
  244. package/dist/shared/protocol.js +0 -112
  245. package/scripts/manage-server.mjs +0 -155
  246. package/scripts/omaw.mjs +0 -74
  247. package/shared/protocol.ts +0 -228
  248. package/web/dist/assets/index-B8-VzDf2.css +0 -1
  249. package/web/dist/assets/index-DeC2eiiq.js +0 -42
  250. package/web/dist/assets/markdown-CYXe6JZx.js +0 -55
@@ -0,0 +1,121 @@
1
+ /**
2
+ * 分时价格的**运行时索引**(`provider/model` → 时段表 + 全天价)。
3
+ *
4
+ * 为什么要有这一层:算钱的时候手里只有「模型名 + token 数 + 时刻」,
5
+ * 而那可能是每一轮里的每一次模型调用 —— 不能每次都去读 JSON。
6
+ * 所以在两处刷新一次、之后只查内存:
7
+ * - 服务启动时(`server/index.ts`);
8
+ * - 模型表热重载时(`engine/model-registry-reload.ts` 本来就要读这两份配置)。
9
+ *
10
+ * 查不到 = 这个模型没配时段 → 调用方继续用引擎给的 cost(既有行为完全不变)。
11
+ * 配置写坏了(手改 JSON 写坏)→ 那一项当作没配,不抛、不把整张表带崩。
12
+ */
13
+ import { readFile } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { costOfUsage, normalizePrice, priceAt, validateSchedule, } from "./pricing.js";
16
+ export const MODELS_FILE = "models.json";
17
+ export const MODELS_STORE_FILE = "models-store.json";
18
+ let byRef = new Map();
19
+ let byModel = new Map();
20
+ function toEntry(model) {
21
+ const parsed = validateSchedule(model.costSchedule, "分时价格");
22
+ const schedule = parsed.ok ? parsed.value : null;
23
+ const base = normalizePrice(model.cost);
24
+ if (!schedule && !base)
25
+ return null;
26
+ return { base, schedule };
27
+ }
28
+ /** 纯函数:把若干 provider 的模型摊成索引(同 provider/model 后者覆盖前者,与「自定义优先」一致) */
29
+ export function buildPriceIndex(sources) {
30
+ const refs = new Map();
31
+ for (const source of sources) {
32
+ for (const model of source.models) {
33
+ if (typeof model?.id !== "string" || model.id === "")
34
+ continue;
35
+ const entry = toEntry(model);
36
+ const ref = `${source.provider}/${model.id}`;
37
+ if (entry)
38
+ refs.set(ref, entry);
39
+ else
40
+ refs.delete(ref);
41
+ }
42
+ }
43
+ // 裸模型名(只有一份时才认):有的引擎只报 `modelId` 不带 provider
44
+ const bare = new Map();
45
+ for (const [ref, entry] of refs) {
46
+ const id = ref.slice(ref.indexOf("/") + 1);
47
+ if (!bare.has(id))
48
+ bare.set(id, entry);
49
+ else if (bare.get(id) !== entry)
50
+ bare.set(id, null); // 重名 → 有歧义,不猜
51
+ }
52
+ return { byRef: refs, byModel: bare };
53
+ }
54
+ /** 用一批配置替换索引(热重载 / 启动都用它) */
55
+ export function setPriceIndex(sources) {
56
+ const built = buildPriceIndex(sources);
57
+ byRef = built.byRef;
58
+ byModel = built.byModel;
59
+ }
60
+ async function readJson(path) {
61
+ try {
62
+ const parsed = JSON.parse(await readFile(path, "utf8"));
63
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
64
+ }
65
+ catch {
66
+ return {}; // 不存在 / 坏文件都按空表处理(可重建,见 ADR-0005)
67
+ }
68
+ }
69
+ /** 从 pi 的两份配置重建索引(自定义那份额外优先,和 provider 列表的顺序一致) */
70
+ export async function refreshPriceIndex(agentDir) {
71
+ const [catalog, store] = await Promise.all([
72
+ readJson(join(agentDir, MODELS_FILE)),
73
+ readJson(join(agentDir, MODELS_STORE_FILE)),
74
+ ]);
75
+ const sources = [];
76
+ const collect = (raw) => {
77
+ if (typeof raw !== "object" || raw === null)
78
+ return;
79
+ for (const [provider, entry] of Object.entries(raw)) {
80
+ if (typeof entry !== "object" || entry === null)
81
+ continue;
82
+ const models = entry.models;
83
+ if (!Array.isArray(models))
84
+ continue;
85
+ sources.push({ provider, models: models });
86
+ }
87
+ };
88
+ collect(catalog.providers);
89
+ collect(store);
90
+ setPriceIndex(sources);
91
+ }
92
+ /** `provider/model` → 价格项;裸模型名也能查(无歧义时) */
93
+ export function priceEntryFor(modelRef) {
94
+ if (typeof modelRef !== "string" || modelRef === "")
95
+ return null;
96
+ const exact = byRef.get(modelRef);
97
+ if (exact)
98
+ return exact;
99
+ // 引擎报的 provider 前缀和我们配置的不一致时,退回裸模型名(有重名就查不到,宁可不改账)
100
+ const slash = modelRef.lastIndexOf("/");
101
+ return byModel.get(slash >= 0 ? modelRef.slice(slash + 1) : modelRef) ?? null;
102
+ }
103
+ /**
104
+ * 这一刻这次调用该记多少钱(美元)。
105
+ *
106
+ * 返回 `undefined` = 这个模型没配分时价格 → 调用方保留引擎算出来的 cost。
107
+ * 配了时段之后**不再用**引擎的 cost:厂商的分时价引擎根本不知道,混着算只会更错。
108
+ */
109
+ export function scheduledCost(modelRef, usage, atMs) {
110
+ const entry = priceEntryFor(modelRef);
111
+ if (!entry || !entry.schedule)
112
+ return undefined;
113
+ const price = priceAt(entry.schedule, entry.base, atMs);
114
+ if (!price)
115
+ return undefined;
116
+ return costOfUsage(usage, price);
117
+ }
118
+ /** 给测试/界面用:当前索引里配了分时价格的模型(`provider/model`) */
119
+ export function scheduledModelRefs() {
120
+ return [...byRef.entries()].filter(([, entry]) => Boolean(entry.schedule)).map(([ref]) => ref);
121
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * 分时价格(模型计费的扩展,ADR-0026 D 期)。
3
+ *
4
+ * 为什么需要:有的厂商按时段定价(高峰期 / 低峰期),一天里切成好几段,
5
+ * 同一百万 tokens 的钱在不同时段不一样。pi 的模型表只认「一个模型一套价」,
6
+ * 它算出来的 cost 在这些厂商身上必然是错的 —— 所以这里多存一张「时段 → 价格」表,
7
+ * 由**我们自己**按调用发生的时刻挑价格(引擎给的 cost 只在没配时段时兜底)。
8
+ *
9
+ * 约定(都写进界面提示里,别让用户猜):
10
+ * - `start` 必填,`end` 可空:空 = 一直用到**下一段**开始(最后一段跨零点回到最早那段);
11
+ * - `start > end` 表示跨零点(22:00–02:00);
12
+ * - 段内的价格字段可以留空:留空 = 沿用模型的「全天价格」(配置里那份 cost);
13
+ * - 时间按**本机时区**解释(界面显示什么就是什么,不做 UTC 换算);
14
+ * - 没配时段 = 全天一个价(既有行为完全不变)。
15
+ *
16
+ * 这一层是纯函数:不读文件、不看时钟(时间一律由调用方传 `atMs`),方便单测。
17
+ */
18
+ export const PRICE_FIELDS = ["input", "output", "cacheRead", "cacheWrite"];
19
+ export const MAX_PRICE_SEGMENTS = 32;
20
+ export const MINUTES_PER_DAY = 1440;
21
+ /** 纯函数:`HH:MM`(也认 `H:MM` / `HH:MM:SS`,秒直接丢掉)→ 当天第几分钟;不合法返回 null */
22
+ export function parseClock(raw) {
23
+ if (typeof raw !== "string")
24
+ return null;
25
+ // 秒可有可无:浏览器 / 系统给的 time 值不统一(有的会带秒),
26
+ // 在这儿报「必须是 HH:MM」只会让用户莫名其妙
27
+ const match = /^(\d{1,2}):(\d{2})(?::\d{2}(?:\.\d+)?)?$/.exec(raw.trim());
28
+ if (!match)
29
+ return null;
30
+ const hours = Number(match[1]);
31
+ const minutes = Number(match[2]);
32
+ if (!Number.isFinite(hours) || !Number.isFinite(minutes))
33
+ return null;
34
+ if (hours > 23 || minutes > 59)
35
+ return null;
36
+ return hours * 60 + minutes;
37
+ }
38
+ /** 纯函数:分钟 → `HH:MM`(界面/日志用) */
39
+ export function formatClock(minutes) {
40
+ const normalized = ((Math.round(minutes) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY;
41
+ const hours = Math.floor(normalized / 60);
42
+ return `${String(hours).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
43
+ }
44
+ /** 纯函数:某一时刻是当天第几分钟(本机时区) */
45
+ export function minutesOfDay(atMs) {
46
+ const date = new Date(atMs);
47
+ return date.getHours() * 60 + date.getMinutes();
48
+ }
49
+ /**
50
+ * 纯函数:把时段表展开成 `[start, end)` 窗口。
51
+ *
52
+ * 没填 end 的段落到下一段开始;**最后一段**落到最早那段的开始(跨零点回去),
53
+ * 这样「只填几个低峰窗口的起点」也能表达完整的 24 小时。
54
+ */
55
+ export function windowsOf(schedule) {
56
+ const parsed = [];
57
+ for (const segment of schedule) {
58
+ const start = parseClock(segment.start);
59
+ if (start === null)
60
+ continue;
61
+ parsed.push({ segment, start });
62
+ }
63
+ parsed.sort((left, right) => left.start - right.start);
64
+ return parsed.map((item, index) => {
65
+ const explicit = parseClock(item.segment.end);
66
+ if (item.segment.end !== undefined && item.segment.end !== "" && explicit !== null) {
67
+ // 结束早于/等于开始 = 跨零点(22:00–02:00)
68
+ return { ...item, end: explicit > item.start ? explicit : explicit + MINUTES_PER_DAY };
69
+ }
70
+ const next = index + 1 < parsed.length
71
+ ? parsed[index + 1].start
72
+ : parsed[0].start + MINUTES_PER_DAY;
73
+ return { ...item, end: next > item.start ? next : next + MINUTES_PER_DAY };
74
+ });
75
+ }
76
+ /**
77
+ * 纯函数:`atMs` 这一刻生效的那一段。
78
+ *
79
+ * 没配时段 / 这一刻没有任何段覆盖 → null(调用方用全天价)。
80
+ * 段与段重叠时**起点更晚的那段优先**(更具体的配置赢)。
81
+ */
82
+ export function activeSegment(schedule, atMs) {
83
+ if (!schedule || schedule.length === 0)
84
+ return null;
85
+ const minutes = minutesOfDay(atMs);
86
+ let best = null;
87
+ let bestOrder = Number.NEGATIVE_INFINITY;
88
+ for (const window of windowsOf(schedule)) {
89
+ // 跨零点的窗口(end > 1440)要把「明天的这份分钟数」也算一遍
90
+ const covered = (minutes >= window.start && minutes < window.end) ||
91
+ (minutes + MINUTES_PER_DAY >= window.start && minutes + MINUTES_PER_DAY < window.end);
92
+ if (!covered)
93
+ continue;
94
+ const order = window.start <= minutes ? window.start : window.start - MINUTES_PER_DAY;
95
+ if (order > bestOrder) {
96
+ bestOrder = order;
97
+ best = window;
98
+ }
99
+ }
100
+ return best ? best.segment : null;
101
+ }
102
+ /** 纯函数:把 cost 字段收敛成价格(驼峰 / 下划线都认;一个数都没有 → null) */
103
+ export function normalizePrice(raw) {
104
+ if (typeof raw !== "object" || raw === null)
105
+ return null;
106
+ const record = raw;
107
+ const number = (field) => {
108
+ const snake = field.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`);
109
+ const value = record[field] ?? record[snake];
110
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
111
+ };
112
+ const input = number("input");
113
+ const output = number("output");
114
+ const cacheRead = number("cacheRead");
115
+ const cacheWrite = number("cacheWrite");
116
+ if (input === null && output === null && cacheRead === null && cacheWrite === null)
117
+ return null;
118
+ return { input: input ?? 0, output: output ?? 0, cacheRead: cacheRead ?? 0, cacheWrite: cacheWrite ?? 0 };
119
+ }
120
+ /**
121
+ * 纯函数:`atMs` 这一刻该用的价格。
122
+ *
123
+ * 段内没填的字段 → 全天价(`base`);全天价也没有 → 0(这一项不收费,和 pi 的口径一致)。
124
+ * 段表和全天价都拿不到 → null(调用方保留引擎给的 cost)。
125
+ */
126
+ export function priceAt(schedule, base, atMs) {
127
+ const segment = activeSegment(schedule, atMs);
128
+ if (!segment && !base)
129
+ return null;
130
+ const pick = (field) => {
131
+ const value = segment?.[field];
132
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0)
133
+ return value;
134
+ return base?.[field] ?? 0;
135
+ };
136
+ return { input: pick("input"), output: pick("output"), cacheRead: pick("cacheRead"), cacheWrite: pick("cacheWrite") };
137
+ }
138
+ /** 纯函数:一次调用(或一轮合计)花多少钱(美元)。价格是「每百万 tokens」 */
139
+ export function costOfUsage(usage, price) {
140
+ const tokens = (value) => (Number.isFinite(value) && value > 0 ? value : 0);
141
+ const total = tokens(usage.input) * price.input +
142
+ tokens(usage.output) * price.output +
143
+ tokens(usage.cacheRead) * price.cacheRead +
144
+ tokens(usage.cacheWrite) * price.cacheWrite;
145
+ return total / 1_000_000;
146
+ }
147
+ /**
148
+ * 纯函数:把配置里的原始值校验 + 收敛成时段表。
149
+ *
150
+ * 一次收集**全部**问题(前端的表单是手填的,挤牙膏式试错太烦)。
151
+ * 空数组 / 没填 = null(= 全天一个价)。
152
+ */
153
+ export function validateSchedule(raw, label = "分时价格") {
154
+ if (raw === undefined || raw === null)
155
+ return { ok: true, value: null };
156
+ if (!Array.isArray(raw))
157
+ return { ok: false, errors: [`${label}必须是数组`] };
158
+ if (raw.length === 0)
159
+ return { ok: true, value: null };
160
+ if (raw.length > MAX_PRICE_SEGMENTS)
161
+ return { ok: false, errors: [`${label}最多 ${MAX_PRICE_SEGMENTS} 段`] };
162
+ const errors = [];
163
+ const seen = new Set();
164
+ const segments = [];
165
+ raw.forEach((item, index) => {
166
+ const position = index + 1;
167
+ if (typeof item !== "object" || item === null) {
168
+ errors.push(`第 ${position} 段不是对象`);
169
+ return;
170
+ }
171
+ const record = item;
172
+ const start = parseClock(record.start);
173
+ if (start === null) {
174
+ errors.push(`第 ${position} 段起始时间必须是 HH:MM`);
175
+ return;
176
+ }
177
+ if (seen.has(start))
178
+ errors.push(`${formatClock(start)} 这个起始时间填了两次`);
179
+ seen.add(start);
180
+ const segment = { start: formatClock(start) };
181
+ const rawEnd = record.end;
182
+ if (rawEnd !== undefined && rawEnd !== null && rawEnd !== "") {
183
+ const end = parseClock(rawEnd);
184
+ if (end === null)
185
+ errors.push(`第 ${position} 段结束时间必须是 HH:MM(留空 = 到下一段开始)`);
186
+ else if (end === start)
187
+ errors.push(`第 ${position} 段结束时间和起始时间相同`);
188
+ else
189
+ segment.end = formatClock(end);
190
+ }
191
+ for (const field of PRICE_FIELDS) {
192
+ const value = record[field];
193
+ if (value === undefined || value === null || value === "")
194
+ continue;
195
+ const number = typeof value === "number" ? value : Number(String(value).trim());
196
+ if (!Number.isFinite(number) || number < 0) {
197
+ errors.push(`第 ${position} 段${PRICE_LABELS[field]}必须是非负数(留空 = 沿用全天价)`);
198
+ continue;
199
+ }
200
+ segment[field] = number;
201
+ }
202
+ segments.push(segment);
203
+ });
204
+ if (errors.length > 0)
205
+ return { ok: false, errors };
206
+ segments.sort((left, right) => (parseClock(left.start) ?? 0) - (parseClock(right.start) ?? 0));
207
+ return { ok: true, value: segments.length > 0 ? segments : null };
208
+ }
209
+ export const PRICE_LABELS = {
210
+ input: "输入价格",
211
+ output: "输出价格",
212
+ cacheRead: "缓存读价格",
213
+ cacheWrite: "缓存写价格",
214
+ };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 启动自检(ADR-0007 §启动自检)。
3
+ *
4
+ * 「能不能跑起来」必须在启动那一刻说清楚,而不是等用户往里打字时才炸。
5
+ * 三项检查:数据目录可写 · 工作区根合法(在 util/env.ts)· 端口可用(在 index.ts)。
6
+ */
7
+ import { mkdir, rm, writeFile } from "node:fs/promises";
8
+ import { join } from "node:path";
9
+ /** 数据目录可写:建目录 + 写一个探针文件再删掉(比只看权限位可靠) */
10
+ export async function checkDataDirWritable(dataDir) {
11
+ try {
12
+ await mkdir(dataDir, { recursive: true });
13
+ const probe = join(dataDir, ".write-probe");
14
+ await writeFile(probe, "ok", "utf8");
15
+ await rm(probe, { force: true });
16
+ return { ok: true };
17
+ }
18
+ catch (error) {
19
+ return {
20
+ ok: false,
21
+ reason: `数据目录不可写:${dataDir}(${error instanceof Error ? error.message : String(error)})`,
22
+ };
23
+ }
24
+ }
25
+ /** 把 listen 的错误翻译成人话(尤其是「端口被占」这种最常见的一种) */
26
+ export function describeListenError(error, port, holderHint) {
27
+ if (error.code === "EADDRINUSE") {
28
+ const hint = holderHint ? `;${holderHint}` : ";先确认是否已有实例在跑(看数据目录里的 server.lock)";
29
+ return `端口 ${port} 已被占用,本进程退出${hint}`;
30
+ }
31
+ if (error.code === "EACCES")
32
+ return `没有权限监听端口 ${port}(小于 1024 的端口通常需要特权)`;
33
+ return `监听端口 ${port} 失败:${error.code ?? "未知错误"}`;
34
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * 上游(GitHub)最新版本号:新会话抬头的 `web v0.1.0` 靠它决定「要不要标红提醒有新版」。
3
+ *
4
+ * 为什么是查上游而不是读本地两份 package.json:抬头那行是在说「我在跑哪个版本」,
5
+ * 只有跟**已发布的版本**比,才知道「该不该提醒你去更新」。
6
+ *
7
+ * 三条纪律:
8
+ * - **绝不抛、绝不阻塞**:离线 / 超时 / 被墙 / 上游文件写坏都只是 `null` = 不知道有没有新版,
9
+ * 界面照旧显示版本号、不标红、不弹提示条。启动不能因为一次网络请求慢半拍。
10
+ * - **只在服务端查**:浏览器直连 raw.githubusercontent 要看 CORS 与代理脸色;
11
+ * 而且「上游是什么」本来就该是服务端作为唯一事实源给出的(ADR-0012)。
12
+ * - **一个进程内低频查**:成功缓存 6 小时、失败缓存 10 分钟(慢网不至于被重试拖死),
13
+ * 再配合定时器轮询 —— 服务器开着好几天也能在下次轮询时看到新版本。
14
+ */
15
+ /** 仓库地址:抬头那行版本号点它跳过去(前端也 import 这份常量,避免两处各写一遍) */
16
+ export const REPO_URL = "https://github.com/duzhenxun/oh-my-agent-web";
17
+ /** 拿版本号的分支:跟 `main` 上的 package.json 对齐(发版就是改那里) */
18
+ export const REPO_BRANCH = "main";
19
+ /** 只要一个版本号,不必走 GitHub API(匿名 API 有速率限制,raw 没有) */
20
+ export const VERSION_URL = `https://raw.githubusercontent.com/duzhenxun/oh-my-agent-web/${REPO_BRANCH}/package.json`;
21
+ /** 一次请求最多等这么久:等不到就当没网 */
22
+ export const FETCH_TIMEOUT_MS = 4000;
23
+ /** 成功结果的保鲜期:6 小时(发版是低频事件,没必要频繁打人家的 CDN) */
24
+ export const OK_TTL_MS = 6 * 60 * 60 * 1000;
25
+ /** 失败结果的保鲜期:10 分钟(断网时别每帧都去试) */
26
+ export const FAIL_TTL_MS = 10 * 60 * 1000;
27
+ /**
28
+ * 从 package.json 文本里抠 `"version"`。
29
+ *
30
+ * 不 `JSON.parse`:上游文件里我们只要这一个字段,别的字段写坏(注释、尾逗号)不该连版本号一起丢。
31
+ */
32
+ export function parseVersion(text) {
33
+ const matched = /"version"\s*:\s*"([^"]+)"/.exec(text);
34
+ const version = matched?.[1]?.trim();
35
+ return version ? version : null;
36
+ }
37
+ /** 版本号切成数字段:`v1.2.3` → `[1,2,3]`;`1.2.3-beta.1` 的预发布后缀忽略(比大小只看前三段) */
38
+ function versionParts(version) {
39
+ return version
40
+ .replace(/^v/i, "")
41
+ .split("-")[0]
42
+ .split(".")
43
+ .map((item) => {
44
+ const parsed = Number.parseInt(item, 10);
45
+ return Number.isFinite(parsed) ? parsed : 0;
46
+ });
47
+ }
48
+ /** 比大小:`a` 新于 `b` 返回正数,相等 0,更旧负数(缺的段按 0 算,所以 `1.0` == `1.0.0`) */
49
+ export function compareVersions(a, b) {
50
+ const left = versionParts(a);
51
+ const right = versionParts(b);
52
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
53
+ const diff = (left[index] ?? 0) - (right[index] ?? 0);
54
+ if (diff !== 0)
55
+ return diff;
56
+ }
57
+ return 0;
58
+ }
59
+ /**
60
+ * 上游比本机新吗(= 该提醒更新了)。
61
+ *
62
+ * 本机反而更新(本地改了版本号还没发版)**不算** —— 那时候标红会误导人跑去「更新」,
63
+ * 而他要的其实是「发布」。
64
+ */
65
+ export function isOutdated(latest, current) {
66
+ if (!latest)
67
+ return false;
68
+ if (current === "" || current === "unknown")
69
+ return false; // 读不到本机版本时不给结论
70
+ return compareVersions(latest, current) > 0;
71
+ }
72
+ /** 真去拉一次(不带缓存):任何异常都咽掉,返回 null */
73
+ export async function fetchLatestVersion(options = {}) {
74
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
75
+ if (!fetchImpl)
76
+ return null;
77
+ const controller = new AbortController();
78
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? FETCH_TIMEOUT_MS);
79
+ timer.unref?.();
80
+ try {
81
+ const response = await fetchImpl(options.url ?? VERSION_URL, { signal: controller.signal });
82
+ if (!response.ok)
83
+ return null;
84
+ return parseVersion(await response.text());
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ finally {
90
+ clearTimeout(timer);
91
+ }
92
+ }
93
+ /**
94
+ * 带缓存的上游版本查询:同一个进程里多处调用只算一次网络(在飞时合并成同一个 promise)。
95
+ *
96
+ * 缓存是模块级的:启动流程、回调、定时器都可能问它,各自持有一份缓存就会各打一次网络。
97
+ */
98
+ let cache = null;
99
+ let inflight = null;
100
+ export async function checkUpstreamVersion(options = {}) {
101
+ const now = options.now ?? (() => Date.now());
102
+ const ttl = cache?.value ? OK_TTL_MS : FAIL_TTL_MS;
103
+ if (cache && now() - cache.at < ttl)
104
+ return cache.value;
105
+ if (inflight)
106
+ return inflight;
107
+ inflight = fetchLatestVersion(options)
108
+ .then((value) => {
109
+ cache = { value, at: now() };
110
+ return value;
111
+ })
112
+ .finally(() => {
113
+ inflight = null;
114
+ });
115
+ return inflight;
116
+ }
117
+ /**
118
+ * 起一个「盯着上游版本」的定时器:先立刻查一次,之后每隔 `intervalMs` 再查(缓存过期的才会真发请求)。
119
+ *
120
+ * 回调只在**拿到的值和上一次不同**(且不是 null)时触发 —— 界面照这个值刷一帧就够,
121
+ * 不必每 6 小时白推一帧一模一样的快照。返回停止函数(测试 / 收摊用)。
122
+ *
123
+ * 轮询间隔取**短的**那个 TTL(10 分钟):多数情况下缓存还没过期,醒一下不花一个字节;
124
+ * 而启动时那次网络万一赶上断网,10 分钟后就能自愈,不用等满 6 小时。
125
+ */
126
+ export function startUpstreamVersionWatch(onChange, options = {}) {
127
+ let last = null;
128
+ const tick = () => {
129
+ void checkUpstreamVersion(options).then((latest) => {
130
+ if (!latest || latest === last)
131
+ return;
132
+ last = latest;
133
+ onChange(latest);
134
+ });
135
+ };
136
+ tick();
137
+ const timer = setInterval(tick, options.intervalMs ?? FAIL_TTL_MS);
138
+ timer.unref?.(); // 别拦着进程退出
139
+ return () => clearInterval(timer);
140
+ }
@@ -0,0 +1,19 @@
1
+ const num = (value) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
2
+ export function parseUsage(raw) {
3
+ if (typeof raw !== "object" || raw === null)
4
+ return null;
5
+ const usage = raw;
6
+ const parsed = {
7
+ input: num(usage.inputTokens ?? usage.input ?? usage.input_tokens ?? usage.promptTokens),
8
+ output: num(usage.outputTokens ?? usage.output ?? usage.output_tokens ?? usage.completionTokens),
9
+ cacheRead: num(usage.cacheReadTokens ?? usage.cacheRead ?? usage.cache_read ?? usage.cachedTokens),
10
+ cacheWrite: num(usage.cacheWriteTokens ?? usage.cacheWrite ?? usage.cache_write),
11
+ };
12
+ if (parsed.input + parsed.output + parsed.cacheRead + parsed.cacheWrite === 0)
13
+ return null;
14
+ // 花费:provider 没配价格 / 免费模型时 pi 不给 cost,这种就不显示
15
+ const costTotal = num(usage.cost?.total);
16
+ if (costTotal > 0)
17
+ parsed.cost = costTotal;
18
+ return parsed;
19
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * 版本号的**唯一来源**:本包的 `package.json`。
3
+ *
4
+ * 三处会用到它,必须读同一份、算同一个值:
5
+ * - 新会话抬头 `web v0.1.0 · pi v1.3.0`(快照里的 `versions.web`)
6
+ * - `/api/health` 的 `version`(`omaw status` 用它判断「跑着的进程是不是旧版」)
7
+ * - `omaw version` / `omaw upgrade`
8
+ *
9
+ * 为什么是「从当前模块往上找 package.json」而不是写死相对路径:开发态在 `server/util/`、
10
+ * 构建后在 `dist/server/util/`,层级不同;而且不能写死包名 —— 包名已经改过一次
11
+ * (`agentweb` → `oh-my-agent-web`),写死就会静默退化成 `unknown`(踩过)。
12
+ */
13
+ import { readFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ let cached = null;
17
+ /**
18
+ * 读本包的 package.json。
19
+ *
20
+ * 从模块所在目录一路往上找,最多 5 层:`dist/server/util` → `dist/server` → `dist` → 包根。
21
+ * 认「有 name + version 的第一份」即可 —— 起点就在我们自己目录里,先撞到的必然是本包。
22
+ */
23
+ export function packageMeta(fromUrl = import.meta.url) {
24
+ if (cached)
25
+ return cached;
26
+ let dir = dirname(fromUrl.startsWith("file:") ? fileURLToPath(fromUrl) : fromUrl);
27
+ for (let depth = 0; depth < 5; depth += 1) {
28
+ try {
29
+ const parsed = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
30
+ if (typeof parsed.name === "string" && typeof parsed.version === "string") {
31
+ cached = { name: parsed.name, version: parsed.version, private: parsed.private === true };
32
+ return cached;
33
+ }
34
+ }
35
+ catch {
36
+ // 这一层没有 / 读不了 → 继续往上
37
+ }
38
+ const parent = dirname(dir);
39
+ if (parent === dir)
40
+ break;
41
+ dir = parent;
42
+ }
43
+ // 读不到也别让界面炸:显示 unknown,其余功能照常
44
+ cached = { name: "oh-my-agent-web", version: "unknown", private: true };
45
+ return cached;
46
+ }
47
+ /** web 版本;找不到就回 `"unknown"` */
48
+ export function webVersion(fromUrl = import.meta.url) {
49
+ return packageMeta(fromUrl).version;
50
+ }
@@ -0,0 +1,95 @@
1
+ import { parseClientRequest } from "../protocol.js";
2
+ export async function handleMessage(socket, raw, deps) {
3
+ let parsed;
4
+ try {
5
+ parsed = JSON.parse(raw);
6
+ }
7
+ catch {
8
+ deps.logger.warn("收到非法 JSON,已忽略");
9
+ return;
10
+ }
11
+ const request = parseClientRequest(parsed);
12
+ if (!request) {
13
+ const id = typeof parsed?.id === "string" ? parsed.id : "unknown";
14
+ const type = parsed?.type;
15
+ const error = typeof type === "string"
16
+ ? { code: "protocol.bad_payload", message: `请求 ${type} 的载荷不合法` }
17
+ : { code: "protocol.unknown_type", message: "无法识别的请求" };
18
+ deps.logger.warn("拒绝非法请求", { id, type, code: error.code });
19
+ deps.reply(socket, id, { ok: false, error });
20
+ return;
21
+ }
22
+ try {
23
+ switch (request.type) {
24
+ case "send_message": {
25
+ deps.reply(socket, request.id, await deps.runtime.sendMessage(request.payload.text, request.payload.attachments, request.payload.mode, undefined, deps.clientId));
26
+ return;
27
+ }
28
+ case "interrupt":
29
+ deps.reply(socket, request.id, await deps.runtime.interrupt(request.payload.conversationId));
30
+ return;
31
+ case "refresh_actions":
32
+ deps.reply(socket, request.id, await deps.runtime.refreshActions(request.payload?.conversationId, request.payload?.count));
33
+ return;
34
+ case "set_engine":
35
+ // 零重启切引擎:项目 / 模型 / 能力都跟着新引擎走(engine-switch.ts)
36
+ deps.reply(socket, request.id, await deps.runtime.setEngine(request.payload.engine));
37
+ return;
38
+ case "approve":
39
+ deps.reply(socket, request.id, deps.runtime.approve(request.payload.requestId, request.payload.decision));
40
+ return;
41
+ case "new_chat":
42
+ deps.reply(socket, request.id, await deps.runtime.newConversation(request.payload?.workspace, deps.clientId));
43
+ return;
44
+ case "rename_session":
45
+ deps.reply(socket, request.id, await deps.runtime.renameSession(request.payload.conversationId, request.payload.title));
46
+ return;
47
+ case "switch_conversation":
48
+ deps.reply(socket, request.id, await deps.runtime.switchConversation(request.payload.conversationId, deps.clientId));
49
+ return;
50
+ case "list_providers":
51
+ case "upsert_provider":
52
+ case "delete_provider":
53
+ case "test_provider":
54
+ case "update_model":
55
+ case "remove_model":
56
+ case "set_enabled_models":
57
+ await deps.runProviderRequest(request, (result) => deps.reply(socket, request.id, result));
58
+ return;
59
+ case "set_approval_mode": {
60
+ deps.reply(socket, request.id, deps.runtime.setApprovalMode(request.payload.mode));
61
+ return;
62
+ }
63
+ case "cancel_queued":
64
+ deps.reply(socket, request.id, deps.runtime.cancelQueued(request.payload.queueId));
65
+ return;
66
+ case "set_thinking_level":
67
+ deps.reply(socket, request.id, await deps.runtime.setThinkingLevel(request.payload.level));
68
+ return;
69
+ case "set_model": {
70
+ deps.reply(socket, request.id, await deps.runtime.setModel(request.payload.modelId));
71
+ return;
72
+ }
73
+ case "set_settings": {
74
+ deps.reply(socket, request.id, await deps.runtime.applySettings(request.payload));
75
+ return;
76
+ }
77
+ case "get_state": {
78
+ // 重连/刷新后的第一件事:拉一份权威快照(ADR-0004 §3);顺便刷一次 pi 会话列表(带 10s 下限)
79
+ await deps.runtime.refreshPiSessions();
80
+ await deps.runtime.refreshProviders();
81
+ // 必须按**这个客户端**生成:用全局快照会让重连的手机拿到电脑的会话
82
+ // (真实症状:电脑上切会话,手机跟着切 —— get_state 是每次重连都会调的)
83
+ deps.sender.pushSnapshot(deps.runtime.snapshotFor(deps.clientId));
84
+ deps.sender.flush(Date.now());
85
+ deps.reply(socket, request.id, { ok: true });
86
+ return;
87
+ }
88
+ }
89
+ }
90
+ catch (error) {
91
+ const message = error instanceof Error ? error.message : String(error);
92
+ deps.logger.error("处理请求失败", { type: request.type, message });
93
+ deps.reply(socket, request.id, { ok: false, error: { code: "protocol.handler_failed", message } });
94
+ }
95
+ }