@xiaobuyu/nodesign 0.0.1 → 0.0.2

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 (282) hide show
  1. package/README.md +156 -106
  2. package/bin/nodesign.js +0 -0
  3. package/package.json +1 -1
  4. package/server/api/README.md +45 -45
  5. package/server/api/_guard.js +75 -75
  6. package/server/api/admin.js +187 -187
  7. package/server/api/assets/docx-page.js +103 -103
  8. package/server/api/assets/helpers.js +22 -22
  9. package/server/api/assets/notes.js +144 -144
  10. package/server/api/assets.js +896 -896
  11. package/server/api/board.js +57 -57
  12. package/server/api/browse.js +138 -138
  13. package/server/api/canvas.js +444 -444
  14. package/server/api/chatai.js +324 -324
  15. package/server/api/exports/build-standalone.js +980 -980
  16. package/server/api/exports/cards.js +118 -118
  17. package/server/api/exports/docx-pdf.js +46 -46
  18. package/server/api/exports/export-page.js +142 -142
  19. package/server/api/exports/handoff.js +147 -147
  20. package/server/api/exports.js +690 -690
  21. package/server/api/instruction.js +75 -75
  22. package/server/api/me.js +116 -116
  23. package/server/api/memory.js +185 -185
  24. package/server/api/pending-changes.js +366 -366
  25. package/server/api/plugins.js +164 -164
  26. package/server/api/projects.js +220 -220
  27. package/server/api/publish.js +64 -64
  28. package/server/api/recent.js +73 -73
  29. package/server/api/sessions.js +589 -589
  30. package/server/api/skills.js +77 -77
  31. package/server/api/standalone-fit.js +168 -168
  32. package/server/api/turn-compose.js +180 -180
  33. package/server/api/turn-inflight.js +44 -44
  34. package/server/auth/README.md +17 -17
  35. package/server/auth/middleware.js +138 -138
  36. package/server/auth/origin-guard.js +71 -71
  37. package/server/auth/session.js +108 -108
  38. package/server/auth/tier.js +113 -113
  39. package/server/auth/users-store.js +348 -348
  40. package/server/edit/README.md +29 -29
  41. package/server/engine/README.md +113 -113
  42. package/server/engine/agent/agent-shared.js +550 -550
  43. package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -4
  44. package/server/engine/agent/auto-mode-rules.js +78 -78
  45. package/server/engine/agent/context.js +418 -418
  46. package/server/engine/agent/events.js +464 -464
  47. package/server/engine/agent/hooks/canvas-validate.js +199 -199
  48. package/server/engine/agent/hooks/failure.js +120 -120
  49. package/server/engine/agent/hooks/file-events.js +75 -75
  50. package/server/engine/agent/hooks/lifecycle.js +233 -233
  51. package/server/engine/agent/hooks/post-canvas-focus.js +80 -80
  52. package/server/engine/agent/hooks/post-guidance.js +206 -206
  53. package/server/engine/agent/hooks/post-subagent-report.js +104 -104
  54. package/server/engine/agent/hooks/post-trim.js +58 -58
  55. package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -34
  56. package/server/engine/agent/hooks/pre-defaults.js +84 -84
  57. package/server/engine/agent/hooks/pre-injectors.js +277 -277
  58. package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -78
  59. package/server/engine/agent/hooks/pre-starter-files.js +61 -61
  60. package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -78
  61. package/server/engine/agent/hooks/site-validate.js +94 -94
  62. package/server/engine/agent/hooks/tool-prompts.js +31 -31
  63. package/server/engine/agent/hooks/turn-state-memory.js +56 -56
  64. package/server/engine/agent/hooks/user-prompt-submit.js +215 -215
  65. package/server/engine/agent/hooks.js +354 -354
  66. package/server/engine/agent/init-contract.js +69 -69
  67. package/server/engine/agent/isolation.js +159 -159
  68. package/server/engine/agent/model-table.js +292 -292
  69. package/server/engine/agent/plugin-loader.js +266 -266
  70. package/server/engine/agent/prompts/nodesign-prelude.md +336 -336
  71. package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -71
  72. package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -98
  73. package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -583
  74. package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -483
  75. package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -100
  76. package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -270
  77. package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -70
  78. package/server/engine/agent/prompts/tools/site-reference.md +106 -106
  79. package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -125
  80. package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -48
  81. package/server/engine/agent/session-loop.js +1024 -1024
  82. package/server/engine/agent/session-model.js +164 -164
  83. package/server/engine/agent/skill.js +204 -204
  84. package/server/engine/agent/task-events.js +78 -78
  85. package/server/engine/agents/ds-extractor.md +132 -132
  86. package/server/engine/agents/explorer.md +190 -190
  87. package/server/engine/agents/index.js +230 -230
  88. package/server/engine/agents/schemas/design-system.json +156 -156
  89. package/server/engine/agents/schemas/tweak-schema.json +113 -113
  90. package/server/engine/agents/tweak-proposer.md +127 -127
  91. package/server/engine/agents/vision-checker.md +254 -254
  92. package/server/engine/browse/capture.js +422 -422
  93. package/server/engine/browse/card.js +137 -137
  94. package/server/engine/browse/handover.js +59 -59
  95. package/server/engine/browse/page-digest.js +119 -119
  96. package/server/engine/browse/refs.js +196 -196
  97. package/server/engine/browse/registry.js +307 -307
  98. package/server/engine/browse/screencast.js +170 -170
  99. package/server/engine/browse/state.js +121 -121
  100. package/server/engine/chatai/chat-log.js +79 -79
  101. package/server/engine/chatai/index.js +193 -193
  102. package/server/engine/chatai/openai-compat.js +152 -152
  103. package/server/engine/chatai/orchestrate.js +309 -309
  104. package/server/engine/chatai/perform.js +40 -40
  105. package/server/engine/chatai/summarize.js +79 -79
  106. package/server/engine/mcp/capability-gate.js +52 -52
  107. package/server/engine/mcp/index.js +339 -339
  108. package/server/engine/mcp/param-sanitizer.js +142 -142
  109. package/server/engine/mcp/tools/arrange-on-board.js +124 -124
  110. package/server/engine/mcp/tools/artifact-session.js +278 -278
  111. package/server/engine/mcp/tools/browse-computer.js +421 -421
  112. package/server/engine/mcp/tools/browse-find-batch.js +183 -183
  113. package/server/engine/mcp/tools/browse-screenshot.js +157 -157
  114. package/server/engine/mcp/tools/browse.js +480 -480
  115. package/server/engine/mcp/tools/build-docx.js +101 -101
  116. package/server/engine/mcp/tools/clear-pending-changes.js +99 -99
  117. package/server/engine/mcp/tools/create-on-board.js +155 -155
  118. package/server/engine/mcp/tools/crystallize-skill.js +168 -168
  119. package/server/engine/mcp/tools/explain-style.js +237 -237
  120. package/server/engine/mcp/tools/export-handoff.js +133 -133
  121. package/server/engine/mcp/tools/expose-tweaks.js +149 -149
  122. package/server/engine/mcp/tools/generate-image.js +842 -842
  123. package/server/engine/mcp/tools/get-computed-styles.js +164 -164
  124. package/server/engine/mcp/tools/get-pending-changes.js +205 -205
  125. package/server/engine/mcp/tools/h3box-ssh.js +84 -84
  126. package/server/engine/mcp/tools/helpers/acquire-page.js +82 -82
  127. package/server/engine/mcp/tools/helpers/motion-lab.js +554 -554
  128. package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -112
  129. package/server/engine/mcp/tools/helpers/perception-page.js +215 -215
  130. package/server/engine/mcp/tools/helpers/reference-download.js +103 -103
  131. package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -80
  132. package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -280
  133. package/server/engine/mcp/tools/highlight.js +68 -68
  134. package/server/engine/mcp/tools/list-pages.js +222 -222
  135. package/server/engine/mcp/tools/lookup-tags.js +59 -59
  136. package/server/engine/mcp/tools/navigate-to-page.js +60 -60
  137. package/server/engine/mcp/tools/organize-board.js +64 -64
  138. package/server/engine/mcp/tools/paint-still.js +434 -434
  139. package/server/engine/mcp/tools/pin-to-board.js +130 -130
  140. package/server/engine/mcp/tools/preview-deck.js +130 -130
  141. package/server/engine/mcp/tools/profile-scroll.js +305 -305
  142. package/server/engine/mcp/tools/publish-site.js +102 -102
  143. package/server/engine/mcp/tools/query-elements.js +195 -195
  144. package/server/engine/mcp/tools/read-board.js +103 -103
  145. package/server/engine/mcp/tools/read-document.js +93 -93
  146. package/server/engine/mcp/tools/read-page.js +264 -264
  147. package/server/engine/mcp/tools/read-tavern-json.js +143 -143
  148. package/server/engine/mcp/tools/record-decision.js +140 -140
  149. package/server/engine/mcp/tools/relate-on-board.js +110 -110
  150. package/server/engine/mcp/tools/report-issue.js +115 -115
  151. package/server/engine/mcp/tools/roll-film.js +266 -266
  152. package/server/engine/mcp/tools/screenshot-docx.js +138 -138
  153. package/server/engine/mcp/tools/screenshot-url.js +195 -195
  154. package/server/engine/mcp/tools/screenshot.js +583 -583
  155. package/server/engine/mcp/tools/tier-gate.js +96 -96
  156. package/server/engine/mcp/tools/trace-motion.js +215 -215
  157. package/server/engine/mcp/tools/web-search.js +548 -548
  158. package/server/engine/motion/inventory.js +349 -349
  159. package/server/engine/perception/session.js +235 -235
  160. package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -5
  161. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -192
  162. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -667
  163. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -22
  164. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -25
  165. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -23
  166. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -21
  167. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -23
  168. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -21
  169. package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -162
  170. package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -407
  171. package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -80
  172. package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -228
  173. package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +78 -78
  174. package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -268
  175. package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -323
  176. package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -134
  177. package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -61
  178. package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -163
  179. package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -86
  180. package/server/engine/runs/active-runs.js +698 -698
  181. package/server/engine/runs/live-turn.js +252 -252
  182. package/server/engine/runs/store.js +364 -364
  183. package/server/engine/runs/turn-relay.js +217 -217
  184. package/server/engine/runtime/workspace.js +164 -164
  185. package/server/lib/artifact-file-path.js +80 -80
  186. package/server/lib/artifact-target.js +284 -284
  187. package/server/lib/asset-refs.js +239 -239
  188. package/server/lib/async-queue.js +104 -104
  189. package/server/lib/auto-relations.js +130 -130
  190. package/server/lib/binding-types.js +60 -60
  191. package/server/lib/board-kind-sizes.js +62 -62
  192. package/server/lib/board-relations.js +133 -133
  193. package/server/lib/browse-proxy.js +180 -180
  194. package/server/lib/canvas-id.js +36 -36
  195. package/server/lib/cover.js +227 -227
  196. package/server/lib/danbooru-tags.js +291 -291
  197. package/server/lib/doc-extract.js +156 -156
  198. package/server/lib/docx/build-from-source.js +260 -260
  199. package/server/lib/docx/build.js +490 -490
  200. package/server/lib/docx/dump-styles.js +287 -287
  201. package/server/lib/docx/fonts/nodesign-cjk.conf +164 -164
  202. package/server/lib/docx/merge-runs.js +129 -129
  203. package/server/lib/docx/numbering.js +218 -218
  204. package/server/lib/docx/order.js +152 -152
  205. package/server/lib/docx/rawzip.js +172 -172
  206. package/server/lib/docx/render.js +92 -92
  207. package/server/lib/docx/text-lint.js +225 -225
  208. package/server/lib/docx/tokens.js +335 -335
  209. package/server/lib/docx/units.js +29 -29
  210. package/server/lib/docx/xml.js +291 -291
  211. package/server/lib/docx-pages.js +187 -187
  212. package/server/lib/export-collect.js +289 -289
  213. package/server/lib/export-package.js +210 -210
  214. package/server/lib/html-srcset.js +145 -145
  215. package/server/lib/image-variant.js +460 -460
  216. package/server/lib/ingress/forward-openai-chat.js +305 -305
  217. package/server/lib/ingress/openai-chat.js +491 -491
  218. package/server/lib/ingress/session-notice.js +66 -66
  219. package/server/lib/ingress/session-routes.js +68 -68
  220. package/server/lib/ingress/slot-probe.js +130 -130
  221. package/server/lib/ingress/upstream-billing.js +52 -52
  222. package/server/lib/ingress/upstream-fail-streak.js +72 -72
  223. package/server/lib/ingress/upstream-truncation.js +48 -48
  224. package/server/lib/issues-store.js +182 -182
  225. package/server/lib/kinds/deck.js +72 -72
  226. package/server/lib/kinds/docx.js +283 -283
  227. package/server/lib/kinds/file-kinds.js +113 -113
  228. package/server/lib/kinds/index.js +202 -202
  229. package/server/lib/kinds/site.js +161 -161
  230. package/server/lib/model-ingress.js +543 -543
  231. package/server/lib/moderation.js +255 -255
  232. package/server/lib/notice-store.js +103 -103
  233. package/server/lib/plugin-install.js +104 -104
  234. package/server/lib/plugin-validator.js +721 -721
  235. package/server/lib/publish-store.js +115 -115
  236. package/server/lib/quick-summary.js +52 -52
  237. package/server/lib/quota.js +270 -270
  238. package/server/lib/rate-window.js +29 -29
  239. package/server/lib/reference-assets.js +92 -92
  240. package/server/lib/region-shot.js +135 -135
  241. package/server/lib/safe-path.js +73 -73
  242. package/server/lib/sdk-session.js +33 -33
  243. package/server/lib/showcase-store.js +92 -92
  244. package/server/lib/site-publish.js +465 -465
  245. package/server/lib/ssrf-guard.js +432 -432
  246. package/server/lib/task-scan.js +158 -158
  247. package/server/lib/tavern-json.js +154 -154
  248. package/server/lib/video-variant.js +237 -237
  249. package/server/lib/workspace-path.js +33 -33
  250. package/server/ops/fix-sdk-musl.mjs +40 -40
  251. package/server/ops/install-macos-fonts.sh +114 -114
  252. package/server/ops/macos-fonts.conf +336 -336
  253. package/server/ops/sandbox-shim/bwrap +57 -57
  254. package/server/projects/assets-summary.js +119 -119
  255. package/server/projects/auto-name.js +53 -53
  256. package/server/projects/board-store.js +640 -640
  257. package/server/projects/move-entry.js +103 -103
  258. package/server/projects/store.js +270 -270
  259. package/server/projects/ui-config.js +54 -54
  260. package/server/projects/workspace-templates.js +53 -53
  261. package/server/projects/workspace.js +1025 -1025
  262. package/server/runtime/local-config.js +177 -177
  263. package/server/runtime/profile.js +72 -72
  264. package/server/services/rembg-service.py +334 -334
  265. package/server/shared/README.md +25 -25
  266. package/server/shared/deck.js +54 -54
  267. package/server/shared/time.js +49 -49
  268. package/server/style-pipeline/README.md +53 -53
  269. package/server/ws/broker.js +36 -36
  270. package/server/ws/browse-channel.js +177 -177
  271. package/server/ws/index.js +386 -386
  272. package/web/README.md +89 -89
  273. package/web/dist/assets/BrowserWindow-BYCgizHw.js +11 -0
  274. package/web/dist/assets/{DeckWindow-4G0xX7P3.js → DeckWindow-pNydEM4L.js} +1 -1
  275. package/web/dist/assets/{DocxWindow-BXQsoPkh.js → DocxWindow-PPgzh3Gq.js} +1 -1
  276. package/web/dist/assets/{SiteWindow-D6eS-2gM.js → SiteWindow-DM0LQPCO.js} +1 -1
  277. package/web/dist/assets/{index-Cbl3ATvp.js → index-BHTBJlpY.js} +32 -32
  278. package/web/dist/assets/{pending-edit-apply-DTjo2geW.js → pending-edit-apply-C0I13CbD.js} +1 -1
  279. package/web/dist/assets/{rotate-cw-LMT3x1Zj.js → rotate-cw-B1UQ8Hld.js} +1 -1
  280. package/web/dist/assets/{square-dashed-mouse-pointer-CBoAkfhi.js → square-dashed-mouse-pointer-8lrgqmfv.js} +1 -1
  281. package/web/dist/index.html +17 -17
  282. package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +0 -11
@@ -1,324 +1,324 @@
1
- /**
2
- * server/api/chatai.js —— 演出端点:页面 → 服务端 → 中转站。
3
- *
4
- * agent 写的演出前端(iframe 里的站点页)打这两条路由;chatai 的 key 在服务端
5
- * env,页面永远拿不到。页面靠同源 cookie 认证,不发任何新凭证。
6
- *
7
- * ⭐ 演出路**不过内容审查**(2026-08-15 拍板):chatai 无工具、无会话、改不了
8
- * 任何文件,出格也只是文本;审查只存在于 agent 侧。但不过审 ≠ 不设闸,四道闸
9
- * 全钉在这一个口上:
10
- *
11
- * 1. 通路批准制 —— auth/tier.js localGenApproved:档位有资格(admin/pro)且被站主逐人批过(同 roll_film 那套);basic 档不开。
12
- * 中转站计量单位不明,问清之前不对全量内测用户开。
13
- * 2. 金额闸门 —— checkQuota 同一个 USD 池:每轮花费按我们的单价表记进
14
- * runs + run_model_usage(skill_id='chatai'),跟 agent 会话共享日限/终身额度。
15
- * 3. 频率闸门 —— 滑动窗口每用户每分钟 N 轮(env NODESIGN_CHATAI_TURNS_PER_MIN,
16
- * 默认 10)。金额日限拦不住单日尖峰,这道闸才是 setInterval 烧钱机的对手。
17
- * 4. 单演出串行 —— 同一文件夹同时只跑一轮(保护 对话.jsonl 成对追加与摘要
18
- * 折叠),撞上返 409 而不是排队。
19
- *
20
- * 流式默认 SSE(data: {delta} … data: {done, usage, costUsd, 摘要});
21
- * body.stream=false 走整段 JSON。中转站分片本来就粗,别指望逐字。
22
- */
23
-
24
- import path from 'node:path';
25
- import fs from 'node:fs/promises';
26
- import express from 'express';
27
- import { guardProject } from './_guard.js';
28
- import { getWorkspaceRoot } from '../projects/workspace.js';
29
- import { performTurn } from '../engine/chatai/perform.js';
30
- import {
31
- loadOrchestration, normalizeOrchestration, serializeOrchestration,
32
- resolveInside, estTokens, CONFIG_FILE,
33
- } from '../engine/chatai/orchestrate.js';
34
- import { readLog, readSummary, SUMMARY_FILE } from '../engine/chatai/chat-log.js';
35
- import { modelCatalog } from '../engine/chatai/index.js';
36
- import { checkQuota, fmtUsd } from '../lib/quota.js';
37
- import { can, localGenApproved } from '../auth/tier.js';
38
- import { makeRateWindow } from '../lib/rate-window.js';
39
- import {
40
- createRun, markRunStarted, markRunSucceeded, markRunFailed,
41
- setRunMetrics, setRunModelUsage,
42
- } from '../engine/runs/store.js';
43
-
44
- const router = express.Router();
45
- export default router;
46
-
47
- const MAX_INPUT_CHARS = 8000;
48
-
49
- const rate = makeRateWindow({
50
- limit: Number(process.env.NODESIGN_CHATAI_TURNS_PER_MIN) || 10,
51
- windowMs: 60_000,
52
- });
53
-
54
- /** 正在演出的文件夹(abs path → true)。串行是保护 jsonl,不是并发额度。 */
55
- const playing = new Set();
56
-
57
- function gateApproved(req, res) {
58
- // 档位 + 逐人批准(auth/tier.js localGenApproved):basic 档不开,pro 档要被站主批过
59
- if (localGenApproved(req.user)) return true;
60
- res.status(403).json({ error: '演出通路尚未对这个账号开放' });
61
- return false;
62
- }
63
-
64
- /** 解析演出文件夹:相对工作区根,防逃逸,必须真是目录。失败时已发响应。 */
65
- async function resolvePerformanceDir(req, res) {
66
- const root = path.resolve(getWorkspaceRoot(req.params.pid));
67
- const rel = String(req.method === 'GET' ? req.query.dir ?? '' : req.body?.dir ?? '');
68
- const abs = path.resolve(root, rel);
69
- if (abs !== root && !abs.startsWith(root + path.sep)) {
70
- res.status(400).json({ error: 'dir 跑出了项目工作区' });
71
- return null;
72
- }
73
- try {
74
- if (!(await fs.stat(abs)).isDirectory()) throw new Error();
75
- } catch {
76
- res.status(404).json({ error: '演出文件夹不存在' });
77
- return null;
78
- }
79
- return { abs, rel };
80
- }
81
-
82
- function statusOfOrchError(err) {
83
- if (err?.code === 'ORCH_NO_CONFIG') return 404;
84
- if (err?.code === 'ORCH_INVALID') return 422;
85
- if (err?.code === 'CHATAI_UNCONFIGURED') return 503;
86
- if (err?.code === 'CHATAI_UPSTREAM') return 502;
87
- return 500;
88
- }
89
-
90
- /** 把一轮(正文 + 可能的摘要调用)的用量并成 run_model_usage 行 */
91
- function usageRows(out) {
92
- const rows = new Map();
93
- const add = (model, usage, costUsd) => {
94
- if (!usage) return;
95
- const key = model || 'unknown';
96
- const acc = rows.get(key) || {
97
- inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, costUsd: 0,
98
- };
99
- // 列口径对齐 agent 行:input 不含缓存读(openai 格式的 prompt_tokens 含,要拆);
100
- // 思考 token 按输出计(Gemini 口径,costOf 同款)
101
- acc.inputTokens += Math.max(0, (usage.inputTokens || 0) - (usage.cacheReadTokens || 0));
102
- acc.outputTokens += (usage.outputTokens || 0) + (usage.reasoningTokens || 0);
103
- acc.cacheReadTokens += usage.cacheReadTokens || 0;
104
- acc.costUsd += costUsd || 0;
105
- rows.set(key, acc);
106
- };
107
- add(out.model, out.usage, out.costUsd);
108
- if (out.摘要?.用量) add(out.摘要.模型, out.摘要.用量, out.摘要.花费);
109
- return rows;
110
- }
111
-
112
- function meterRun(runId, out) {
113
- const rows = usageRows(out);
114
- setRunModelUsage(runId, Object.fromEntries(rows));
115
- const total = [...rows.values()].reduce((a, r) => ({
116
- inputTokens: a.inputTokens + r.inputTokens,
117
- outputTokens: a.outputTokens + r.outputTokens,
118
- cacheReadTokens: a.cacheReadTokens + r.cacheReadTokens,
119
- totalCostUsd: a.totalCostUsd + r.costUsd,
120
- }), { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, totalCostUsd: 0 });
121
- setRunMetrics(runId, total);
122
- return total.totalCostUsd;
123
- }
124
-
125
- /**
126
- * GET /:pid/chatai/log?dir=… —— 页面打开时渲染既有对话。
127
- * 读不花钱不限频;只要项目归属过关就给。
128
- */
129
- router.get('/:pid/chatai/log', async (req, res, next) => {
130
- try {
131
- if (!guardProject(req, res)) return;
132
- const dir = await resolvePerformanceDir(req, res);
133
- if (!dir) return;
134
- let config;
135
- try {
136
- config = await loadOrchestration(dir.abs);
137
- } catch (err) {
138
- return res.status(statusOfOrchError(err)).json({ error: err.message });
139
- }
140
- const [records, summary] = await Promise.all([
141
- readLog(dir.abs, config), readSummary(dir.abs),
142
- ]);
143
- res.json({
144
- records,
145
- 摘要: summary ? { 至: summary.至, 内容: summary.内容 } : null,
146
- 配置: { 模型: config.模型, 最大输出: config.最大输出, 上下文预算: config.上下文预算 },
147
- });
148
- } catch (err) { next(err); }
149
- });
150
-
151
- /**
152
- * GET /:pid/chatai/config?dir=… —— 设置页的一次取齐:归一后的配置 +
153
- * 条目引用的文件内容 + 历史/摘要状况。token 估算前端自己算(同款下限口径)。
154
- */
155
- router.get('/:pid/chatai/config', async (req, res, next) => {
156
- try {
157
- if (!guardProject(req, res)) return;
158
- const dir = await resolvePerformanceDir(req, res);
159
- if (!dir) return;
160
- let config;
161
- try {
162
- config = await loadOrchestration(dir.abs);
163
- } catch (err) {
164
- return res.status(statusOfOrchError(err)).json({ error: err.message, code: err.code || null });
165
- }
166
- const 文件 = {};
167
- for (const e of [...config.系统层, ...config.尾部]) {
168
- if (e.文件 == null || e.文件 in 文件) continue;
169
- try {
170
- 文件[e.文件] = await fs.readFile(resolveInside(dir.abs, e.文件, `条目「${e.名字}」`), 'utf8');
171
- } catch { 文件[e.文件] = null; } // 引用悬空:设置页要红给用户看,不是 500
172
- }
173
- const [records, summary] = await Promise.all([readLog(dir.abs, config), readSummary(dir.abs)]);
174
- const boundary = summary?.至 ?? 0;
175
- res.json({
176
- 配置: config,
177
- 文件,
178
- // 模型/单价/思考档由服务端一张表说了算(MODEL_SPECS),前端只负责画——
179
- // 中转站改名或换档位时不用两头改(单一真相源)。
180
- 模型表: modelCatalog(),
181
- 状况: {
182
- 记录条数: records.length,
183
- 轮数: records.filter(r => r.role === 'user').length,
184
- 活历史tok: records.filter(r => r.seq > boundary).reduce((n, r) => n + estTokens(r.text), 0),
185
- 摘要: summary ? { 至: summary.至, 内容: summary.内容 } : null,
186
- },
187
- });
188
- } catch (err) { next(err); }
189
- });
190
-
191
- /**
192
- * PUT /:pid/chatai/config body { dir, 配置, 文件? }
193
- *
194
- * 校验(normalizeOrchestration,跟演出路同一份)→ 先写引用文件、再原子写
195
- * 编排.yaml。改动下一轮生效——正在跑的那轮已经编译完了,不追。
196
- * 文件表只许写文件夹内部,且不许碰 编排.yaml / 摘要.json / 对话记录本体。
197
- */
198
- router.put('/:pid/chatai/config', async (req, res, next) => {
199
- try {
200
- if (!guardProject(req, res)) return;
201
- const dir = await resolvePerformanceDir(req, res);
202
- if (!dir) return;
203
- let config;
204
- try {
205
- config = normalizeOrchestration(req.body?.配置);
206
- } catch (err) {
207
- return res.status(422).json({ error: err.message });
208
- }
209
- const files = req.body?.文件 && typeof req.body.文件 === 'object' ? req.body.文件 : {};
210
- const forbidden = new Set([CONFIG_FILE, SUMMARY_FILE, config.历史.文件]);
211
- for (const [rel, text] of Object.entries(files)) {
212
- if (forbidden.has(rel)) {
213
- return res.status(422).json({ error: `「${rel}」不归设置页写(配置/摘要/对话记录各有各的落盘路)` });
214
- }
215
- if (typeof text !== 'string') return res.status(422).json({ error: `文件「${rel}」的内容要是字符串` });
216
- let abs;
217
- try { abs = resolveInside(dir.abs, rel, '文件表'); } catch (err) {
218
- return res.status(422).json({ error: err.message });
219
- }
220
- await fs.mkdir(path.dirname(abs), { recursive: true });
221
- await fs.writeFile(abs, text, 'utf8');
222
- }
223
- const target = path.join(dir.abs, CONFIG_FILE);
224
- const tmp = `${target}.tmp`;
225
- await fs.writeFile(tmp, serializeOrchestration(config), 'utf8');
226
- await fs.rename(tmp, target);
227
- res.json({ ok: true, 配置: config });
228
- } catch (err) { next(err); }
229
- });
230
-
231
- /**
232
- * POST /:pid/chatai/turn body { dir, input, stream? }
233
- */
234
- router.post('/:pid/chatai/turn', async (req, res, next) => {
235
- try {
236
- if (!guardProject(req, res)) return;
237
- if (!gateApproved(req, res)) return;
238
-
239
- const input = String(req.body?.input ?? '').trim();
240
- if (!input) return res.status(422).json({ error: 'input 是空的' });
241
- if (input.length > MAX_INPUT_CHARS) {
242
- return res.status(422).json({ error: `input 超长(上限 ${MAX_INPUT_CHARS} 字符)` });
243
- }
244
-
245
- // 08-21 经营态:演出通路烧的是带钥匙的 API 钱,公开注册号(免费档)不开 —— 跟订阅 Claude 同一把资格
246
- if (!can(req.user, 'subscription')) {
247
- return res.status(403).json({ error: '演出模式仅限 Pro 档,暂未对外开放;当前档位请用设计会话', code: 'MODEL_LOCKED' });
248
- }
249
- const quota = checkQuota(req.user);
250
- if (!quota.ok) {
251
- const word = quota.kind === 'lifetime' ? '试用额度' : '今日额度';
252
- return res.status(429).json({ error: `${word}用完了(${fmtUsd(quota.used)} / ${fmtUsd(quota.limit)})` });
253
- }
254
- const r = rate.take(req.user?.id ?? 'anon');
255
- if (!r.ok) {
256
- res.setHeader('Retry-After', Math.ceil(r.retryAfterMs / 1000));
257
- return res.status(429).json({ error: '太快了,歇几秒再发' });
258
- }
259
-
260
- const dir = await resolvePerformanceDir(req, res);
261
- if (!dir) return;
262
- if (playing.has(dir.abs)) {
263
- return res.status(409).json({ error: '这场演出正有一轮在跑,等它回完' });
264
- }
265
-
266
- const stream = req.body?.stream !== false;
267
- const run = createRun({
268
- skillId: 'chatai',
269
- brief: input.slice(0, 120),
270
- projectId: req.params.pid,
271
- userId: req.user?.id ?? null,
272
- metadata: { dir: dir.rel, kind: 'chatai-turn' },
273
- });
274
- markRunStarted(run.id);
275
-
276
- // 客户端断开就掐上游:落盘在调用成功之后,掐掉的轮子不留半条记录
277
- const ctrl = new AbortController();
278
- req.on('close', () => { if (!res.writableEnded) ctrl.abort(new Error('客户端断开')); });
279
-
280
- playing.add(dir.abs);
281
- try {
282
- if (stream) {
283
- res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
284
- res.setHeader('Cache-Control', 'no-cache');
285
- res.setHeader('X-Accel-Buffering', 'no');
286
- res.flushHeaders?.();
287
- const send = (obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);
288
- try {
289
- const out = await performTurn({
290
- dir: dir.abs, userInput: input, signal: ctrl.signal,
291
- onDelta: (piece) => send({ delta: piece }),
292
- });
293
- const costUsd = meterRun(run.id, out);
294
- markRunSucceeded(run.id);
295
- send({
296
- done: true, text: out.text, usage: out.usage, costUsd,
297
- 模型: out.model, 档: out.档 ?? null, ...(out.降级 ? { 降级: out.降级 } : {}),
298
- 摘要: out.摘要 ? { 折叠: !out.摘要.失败, ...(out.摘要.失败 ? { 失败: out.摘要.失败 } : {}) } : null,
299
- });
300
- } catch (err) {
301
- markRunFailed(run.id, err.message);
302
- send({ error: err.message, code: err.code || null });
303
- }
304
- res.end();
305
- } else {
306
- try {
307
- const out = await performTurn({ dir: dir.abs, userInput: input, signal: ctrl.signal });
308
- const costUsd = meterRun(run.id, out);
309
- markRunSucceeded(run.id);
310
- res.json({
311
- text: out.text, usage: out.usage, costUsd, meta: out.meta,
312
- 模型: out.model, 档: out.档 ?? null, ...(out.降级 ? { 降级: out.降级 } : {}),
313
- 摘要: out.摘要 ? { 折叠: !out.摘要.失败, ...(out.摘要.失败 ? { 失败: out.摘要.失败 } : {}) } : null,
314
- });
315
- } catch (err) {
316
- markRunFailed(run.id, err.message);
317
- res.status(statusOfOrchError(err)).json({ error: err.message, code: err.code || null });
318
- }
319
- }
320
- } finally {
321
- playing.delete(dir.abs);
322
- }
323
- } catch (err) { next(err); }
324
- });
1
+ /**
2
+ * server/api/chatai.js —— 演出端点:页面 → 服务端 → 中转站。
3
+ *
4
+ * agent 写的演出前端(iframe 里的站点页)打这两条路由;chatai 的 key 在服务端
5
+ * env,页面永远拿不到。页面靠同源 cookie 认证,不发任何新凭证。
6
+ *
7
+ * ⭐ 演出路**不过内容审查**(2026-08-15 拍板):chatai 无工具、无会话、改不了
8
+ * 任何文件,出格也只是文本;审查只存在于 agent 侧。但不过审 ≠ 不设闸,四道闸
9
+ * 全钉在这一个口上:
10
+ *
11
+ * 1. 通路批准制 —— auth/tier.js localGenApproved:档位有资格(admin/pro)且被站主逐人批过(同 roll_film 那套);basic 档不开。
12
+ * 中转站计量单位不明,问清之前不对全量内测用户开。
13
+ * 2. 金额闸门 —— checkQuota 同一个 USD 池:每轮花费按我们的单价表记进
14
+ * runs + run_model_usage(skill_id='chatai'),跟 agent 会话共享日限/终身额度。
15
+ * 3. 频率闸门 —— 滑动窗口每用户每分钟 N 轮(env NODESIGN_CHATAI_TURNS_PER_MIN,
16
+ * 默认 10)。金额日限拦不住单日尖峰,这道闸才是 setInterval 烧钱机的对手。
17
+ * 4. 单演出串行 —— 同一文件夹同时只跑一轮(保护 对话.jsonl 成对追加与摘要
18
+ * 折叠),撞上返 409 而不是排队。
19
+ *
20
+ * 流式默认 SSE(data: {delta} … data: {done, usage, costUsd, 摘要});
21
+ * body.stream=false 走整段 JSON。中转站分片本来就粗,别指望逐字。
22
+ */
23
+
24
+ import path from 'node:path';
25
+ import fs from 'node:fs/promises';
26
+ import express from 'express';
27
+ import { guardProject } from './_guard.js';
28
+ import { getWorkspaceRoot } from '../projects/workspace.js';
29
+ import { performTurn } from '../engine/chatai/perform.js';
30
+ import {
31
+ loadOrchestration, normalizeOrchestration, serializeOrchestration,
32
+ resolveInside, estTokens, CONFIG_FILE,
33
+ } from '../engine/chatai/orchestrate.js';
34
+ import { readLog, readSummary, SUMMARY_FILE } from '../engine/chatai/chat-log.js';
35
+ import { modelCatalog } from '../engine/chatai/index.js';
36
+ import { checkQuota, fmtUsd } from '../lib/quota.js';
37
+ import { can, localGenApproved } from '../auth/tier.js';
38
+ import { makeRateWindow } from '../lib/rate-window.js';
39
+ import {
40
+ createRun, markRunStarted, markRunSucceeded, markRunFailed,
41
+ setRunMetrics, setRunModelUsage,
42
+ } from '../engine/runs/store.js';
43
+
44
+ const router = express.Router();
45
+ export default router;
46
+
47
+ const MAX_INPUT_CHARS = 8000;
48
+
49
+ const rate = makeRateWindow({
50
+ limit: Number(process.env.NODESIGN_CHATAI_TURNS_PER_MIN) || 10,
51
+ windowMs: 60_000,
52
+ });
53
+
54
+ /** 正在演出的文件夹(abs path → true)。串行是保护 jsonl,不是并发额度。 */
55
+ const playing = new Set();
56
+
57
+ function gateApproved(req, res) {
58
+ // 档位 + 逐人批准(auth/tier.js localGenApproved):basic 档不开,pro 档要被站主批过
59
+ if (localGenApproved(req.user)) return true;
60
+ res.status(403).json({ error: '演出通路尚未对这个账号开放' });
61
+ return false;
62
+ }
63
+
64
+ /** 解析演出文件夹:相对工作区根,防逃逸,必须真是目录。失败时已发响应。 */
65
+ async function resolvePerformanceDir(req, res) {
66
+ const root = path.resolve(getWorkspaceRoot(req.params.pid));
67
+ const rel = String(req.method === 'GET' ? req.query.dir ?? '' : req.body?.dir ?? '');
68
+ const abs = path.resolve(root, rel);
69
+ if (abs !== root && !abs.startsWith(root + path.sep)) {
70
+ res.status(400).json({ error: 'dir 跑出了项目工作区' });
71
+ return null;
72
+ }
73
+ try {
74
+ if (!(await fs.stat(abs)).isDirectory()) throw new Error();
75
+ } catch {
76
+ res.status(404).json({ error: '演出文件夹不存在' });
77
+ return null;
78
+ }
79
+ return { abs, rel };
80
+ }
81
+
82
+ function statusOfOrchError(err) {
83
+ if (err?.code === 'ORCH_NO_CONFIG') return 404;
84
+ if (err?.code === 'ORCH_INVALID') return 422;
85
+ if (err?.code === 'CHATAI_UNCONFIGURED') return 503;
86
+ if (err?.code === 'CHATAI_UPSTREAM') return 502;
87
+ return 500;
88
+ }
89
+
90
+ /** 把一轮(正文 + 可能的摘要调用)的用量并成 run_model_usage 行 */
91
+ function usageRows(out) {
92
+ const rows = new Map();
93
+ const add = (model, usage, costUsd) => {
94
+ if (!usage) return;
95
+ const key = model || 'unknown';
96
+ const acc = rows.get(key) || {
97
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, costUsd: 0,
98
+ };
99
+ // 列口径对齐 agent 行:input 不含缓存读(openai 格式的 prompt_tokens 含,要拆);
100
+ // 思考 token 按输出计(Gemini 口径,costOf 同款)
101
+ acc.inputTokens += Math.max(0, (usage.inputTokens || 0) - (usage.cacheReadTokens || 0));
102
+ acc.outputTokens += (usage.outputTokens || 0) + (usage.reasoningTokens || 0);
103
+ acc.cacheReadTokens += usage.cacheReadTokens || 0;
104
+ acc.costUsd += costUsd || 0;
105
+ rows.set(key, acc);
106
+ };
107
+ add(out.model, out.usage, out.costUsd);
108
+ if (out.摘要?.用量) add(out.摘要.模型, out.摘要.用量, out.摘要.花费);
109
+ return rows;
110
+ }
111
+
112
+ function meterRun(runId, out) {
113
+ const rows = usageRows(out);
114
+ setRunModelUsage(runId, Object.fromEntries(rows));
115
+ const total = [...rows.values()].reduce((a, r) => ({
116
+ inputTokens: a.inputTokens + r.inputTokens,
117
+ outputTokens: a.outputTokens + r.outputTokens,
118
+ cacheReadTokens: a.cacheReadTokens + r.cacheReadTokens,
119
+ totalCostUsd: a.totalCostUsd + r.costUsd,
120
+ }), { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, totalCostUsd: 0 });
121
+ setRunMetrics(runId, total);
122
+ return total.totalCostUsd;
123
+ }
124
+
125
+ /**
126
+ * GET /:pid/chatai/log?dir=… —— 页面打开时渲染既有对话。
127
+ * 读不花钱不限频;只要项目归属过关就给。
128
+ */
129
+ router.get('/:pid/chatai/log', async (req, res, next) => {
130
+ try {
131
+ if (!guardProject(req, res)) return;
132
+ const dir = await resolvePerformanceDir(req, res);
133
+ if (!dir) return;
134
+ let config;
135
+ try {
136
+ config = await loadOrchestration(dir.abs);
137
+ } catch (err) {
138
+ return res.status(statusOfOrchError(err)).json({ error: err.message });
139
+ }
140
+ const [records, summary] = await Promise.all([
141
+ readLog(dir.abs, config), readSummary(dir.abs),
142
+ ]);
143
+ res.json({
144
+ records,
145
+ 摘要: summary ? { 至: summary.至, 内容: summary.内容 } : null,
146
+ 配置: { 模型: config.模型, 最大输出: config.最大输出, 上下文预算: config.上下文预算 },
147
+ });
148
+ } catch (err) { next(err); }
149
+ });
150
+
151
+ /**
152
+ * GET /:pid/chatai/config?dir=… —— 设置页的一次取齐:归一后的配置 +
153
+ * 条目引用的文件内容 + 历史/摘要状况。token 估算前端自己算(同款下限口径)。
154
+ */
155
+ router.get('/:pid/chatai/config', async (req, res, next) => {
156
+ try {
157
+ if (!guardProject(req, res)) return;
158
+ const dir = await resolvePerformanceDir(req, res);
159
+ if (!dir) return;
160
+ let config;
161
+ try {
162
+ config = await loadOrchestration(dir.abs);
163
+ } catch (err) {
164
+ return res.status(statusOfOrchError(err)).json({ error: err.message, code: err.code || null });
165
+ }
166
+ const 文件 = {};
167
+ for (const e of [...config.系统层, ...config.尾部]) {
168
+ if (e.文件 == null || e.文件 in 文件) continue;
169
+ try {
170
+ 文件[e.文件] = await fs.readFile(resolveInside(dir.abs, e.文件, `条目「${e.名字}」`), 'utf8');
171
+ } catch { 文件[e.文件] = null; } // 引用悬空:设置页要红给用户看,不是 500
172
+ }
173
+ const [records, summary] = await Promise.all([readLog(dir.abs, config), readSummary(dir.abs)]);
174
+ const boundary = summary?.至 ?? 0;
175
+ res.json({
176
+ 配置: config,
177
+ 文件,
178
+ // 模型/单价/思考档由服务端一张表说了算(MODEL_SPECS),前端只负责画——
179
+ // 中转站改名或换档位时不用两头改(单一真相源)。
180
+ 模型表: modelCatalog(),
181
+ 状况: {
182
+ 记录条数: records.length,
183
+ 轮数: records.filter(r => r.role === 'user').length,
184
+ 活历史tok: records.filter(r => r.seq > boundary).reduce((n, r) => n + estTokens(r.text), 0),
185
+ 摘要: summary ? { 至: summary.至, 内容: summary.内容 } : null,
186
+ },
187
+ });
188
+ } catch (err) { next(err); }
189
+ });
190
+
191
+ /**
192
+ * PUT /:pid/chatai/config body { dir, 配置, 文件? }
193
+ *
194
+ * 校验(normalizeOrchestration,跟演出路同一份)→ 先写引用文件、再原子写
195
+ * 编排.yaml。改动下一轮生效——正在跑的那轮已经编译完了,不追。
196
+ * 文件表只许写文件夹内部,且不许碰 编排.yaml / 摘要.json / 对话记录本体。
197
+ */
198
+ router.put('/:pid/chatai/config', async (req, res, next) => {
199
+ try {
200
+ if (!guardProject(req, res)) return;
201
+ const dir = await resolvePerformanceDir(req, res);
202
+ if (!dir) return;
203
+ let config;
204
+ try {
205
+ config = normalizeOrchestration(req.body?.配置);
206
+ } catch (err) {
207
+ return res.status(422).json({ error: err.message });
208
+ }
209
+ const files = req.body?.文件 && typeof req.body.文件 === 'object' ? req.body.文件 : {};
210
+ const forbidden = new Set([CONFIG_FILE, SUMMARY_FILE, config.历史.文件]);
211
+ for (const [rel, text] of Object.entries(files)) {
212
+ if (forbidden.has(rel)) {
213
+ return res.status(422).json({ error: `「${rel}」不归设置页写(配置/摘要/对话记录各有各的落盘路)` });
214
+ }
215
+ if (typeof text !== 'string') return res.status(422).json({ error: `文件「${rel}」的内容要是字符串` });
216
+ let abs;
217
+ try { abs = resolveInside(dir.abs, rel, '文件表'); } catch (err) {
218
+ return res.status(422).json({ error: err.message });
219
+ }
220
+ await fs.mkdir(path.dirname(abs), { recursive: true });
221
+ await fs.writeFile(abs, text, 'utf8');
222
+ }
223
+ const target = path.join(dir.abs, CONFIG_FILE);
224
+ const tmp = `${target}.tmp`;
225
+ await fs.writeFile(tmp, serializeOrchestration(config), 'utf8');
226
+ await fs.rename(tmp, target);
227
+ res.json({ ok: true, 配置: config });
228
+ } catch (err) { next(err); }
229
+ });
230
+
231
+ /**
232
+ * POST /:pid/chatai/turn body { dir, input, stream? }
233
+ */
234
+ router.post('/:pid/chatai/turn', async (req, res, next) => {
235
+ try {
236
+ if (!guardProject(req, res)) return;
237
+ if (!gateApproved(req, res)) return;
238
+
239
+ const input = String(req.body?.input ?? '').trim();
240
+ if (!input) return res.status(422).json({ error: 'input 是空的' });
241
+ if (input.length > MAX_INPUT_CHARS) {
242
+ return res.status(422).json({ error: `input 超长(上限 ${MAX_INPUT_CHARS} 字符)` });
243
+ }
244
+
245
+ // 08-21 经营态:演出通路烧的是带钥匙的 API 钱,公开注册号(免费档)不开 —— 跟订阅 Claude 同一把资格
246
+ if (!can(req.user, 'subscription')) {
247
+ return res.status(403).json({ error: '演出模式仅限 Pro 档,暂未对外开放;当前档位请用设计会话', code: 'MODEL_LOCKED' });
248
+ }
249
+ const quota = checkQuota(req.user);
250
+ if (!quota.ok) {
251
+ const word = quota.kind === 'lifetime' ? '试用额度' : '今日额度';
252
+ return res.status(429).json({ error: `${word}用完了(${fmtUsd(quota.used)} / ${fmtUsd(quota.limit)})` });
253
+ }
254
+ const r = rate.take(req.user?.id ?? 'anon');
255
+ if (!r.ok) {
256
+ res.setHeader('Retry-After', Math.ceil(r.retryAfterMs / 1000));
257
+ return res.status(429).json({ error: '太快了,歇几秒再发' });
258
+ }
259
+
260
+ const dir = await resolvePerformanceDir(req, res);
261
+ if (!dir) return;
262
+ if (playing.has(dir.abs)) {
263
+ return res.status(409).json({ error: '这场演出正有一轮在跑,等它回完' });
264
+ }
265
+
266
+ const stream = req.body?.stream !== false;
267
+ const run = createRun({
268
+ skillId: 'chatai',
269
+ brief: input.slice(0, 120),
270
+ projectId: req.params.pid,
271
+ userId: req.user?.id ?? null,
272
+ metadata: { dir: dir.rel, kind: 'chatai-turn' },
273
+ });
274
+ markRunStarted(run.id);
275
+
276
+ // 客户端断开就掐上游:落盘在调用成功之后,掐掉的轮子不留半条记录
277
+ const ctrl = new AbortController();
278
+ req.on('close', () => { if (!res.writableEnded) ctrl.abort(new Error('客户端断开')); });
279
+
280
+ playing.add(dir.abs);
281
+ try {
282
+ if (stream) {
283
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
284
+ res.setHeader('Cache-Control', 'no-cache');
285
+ res.setHeader('X-Accel-Buffering', 'no');
286
+ res.flushHeaders?.();
287
+ const send = (obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);
288
+ try {
289
+ const out = await performTurn({
290
+ dir: dir.abs, userInput: input, signal: ctrl.signal,
291
+ onDelta: (piece) => send({ delta: piece }),
292
+ });
293
+ const costUsd = meterRun(run.id, out);
294
+ markRunSucceeded(run.id);
295
+ send({
296
+ done: true, text: out.text, usage: out.usage, costUsd,
297
+ 模型: out.model, 档: out.档 ?? null, ...(out.降级 ? { 降级: out.降级 } : {}),
298
+ 摘要: out.摘要 ? { 折叠: !out.摘要.失败, ...(out.摘要.失败 ? { 失败: out.摘要.失败 } : {}) } : null,
299
+ });
300
+ } catch (err) {
301
+ markRunFailed(run.id, err.message);
302
+ send({ error: err.message, code: err.code || null });
303
+ }
304
+ res.end();
305
+ } else {
306
+ try {
307
+ const out = await performTurn({ dir: dir.abs, userInput: input, signal: ctrl.signal });
308
+ const costUsd = meterRun(run.id, out);
309
+ markRunSucceeded(run.id);
310
+ res.json({
311
+ text: out.text, usage: out.usage, costUsd, meta: out.meta,
312
+ 模型: out.model, 档: out.档 ?? null, ...(out.降级 ? { 降级: out.降级 } : {}),
313
+ 摘要: out.摘要 ? { 折叠: !out.摘要.失败, ...(out.摘要.失败 ? { 失败: out.摘要.失败 } : {}) } : null,
314
+ });
315
+ } catch (err) {
316
+ markRunFailed(run.id, err.message);
317
+ res.status(statusOfOrchError(err)).json({ error: err.message, code: err.code || null });
318
+ }
319
+ }
320
+ } finally {
321
+ playing.delete(dir.abs);
322
+ }
323
+ } catch (err) { next(err); }
324
+ });