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,45 @@
1
+ import { pick } from '../../core/language.ts';
2
+
3
+ const zh = {
4
+ sectionDisk: '落盘 data/(重启后仍在)',
5
+ sectionMemory: '内存暂存(重启即清零)',
6
+ nukeAll: '⚠ 一键清空全部',
7
+ clear: '清除',
8
+ dangerTitle: (label: string) => `⚠ 危险操作:${label}`,
9
+ dangerBody: (note: string) => `${note}\n\n确定不可恢复地清除?`,
10
+ clearTitle: (label: string) => `清除「${label}」?`,
11
+ cleared: '已清除',
12
+ clearFailed: (err: string) => '清除失败: ' + err,
13
+ nukeTitle: '⚠⚠ 一键清空全部存储',
14
+ nukeBody: '清除服务端清单里的全部存储项,含各 World 与 Memory 页的。此操作无法撤销。',
15
+ partialFailed: (keys: string) => '部分失败: ' + keys,
16
+ nukedAll: (count: number) => `✓ 已全部清空(${count} 项)`,
17
+ nukeFailed: (err: string) => '一键清空失败: ' + err,
18
+ noList: '(服务端未挂载存储清单)',
19
+ empty: '这一页没有存储项',
20
+ loadFailed: (err: string) => '存储清单加载失败: ' + err,
21
+ loading: '加载中…',
22
+ };
23
+
24
+ const en: typeof zh = {
25
+ sectionDisk: 'Persisted data/ (survives restart)',
26
+ sectionMemory: 'In-memory (cleared on restart)',
27
+ nukeAll: '⚠ Clear everything',
28
+ clear: 'Clear',
29
+ dangerTitle: (label: string) => `⚠ Dangerous: ${label}`,
30
+ dangerBody: (note: string) => `${note}\n\nClear irreversibly?`,
31
+ clearTitle: (label: string) => `Clear "${label}"?`,
32
+ cleared: 'Cleared',
33
+ clearFailed: (err: string) => 'Clear failed: ' + err,
34
+ nukeTitle: '⚠⚠ Clear all storage',
35
+ nukeBody: 'Clear every storage item on the server, including those of World and Memory pages. This cannot be undone.',
36
+ partialFailed: (keys: string) => 'Partially failed: ' + keys,
37
+ nukedAll: (count: number) => `✓ Everything cleared (${count} items)`,
38
+ nukeFailed: (err: string) => 'Clear all failed: ' + err,
39
+ noList: '(The server has no storage list mounted)',
40
+ empty: 'This page has no storage items',
41
+ loadFailed: (err: string) => 'Failed to load storage list: ' + err,
42
+ loading: 'Loading…',
43
+ };
44
+
45
+ export const S = pick({ zh, en });
@@ -0,0 +1,154 @@
1
+ /**
2
+ * 存储清单视图:列本页的项,落盘在内存前,每项可清除。清单由服务端声明,清除范围和结果由各项的实现决定;
3
+ * `clearAll` 打开时多一颗一键清空,清的是服务端整张清单。
4
+ */
5
+
6
+ import type { ConsoleUi } from '../../../shared/client-panel.ts';
7
+ import { get, post } from '../../core/api.ts';
8
+ import { S } from './strings.ts';
9
+
10
+ /** 一条可清除的存储部分。与 `src/core/types.ts` 的 `OwnedStoragePart` 同形(去掉两个函数)。 */
11
+ export interface StoragePartView {
12
+ key: string;
13
+ label: string;
14
+ kind: 'disk' | 'memory';
15
+ /** 归属:core、persona、memory 或 world:<id>,装配层盖章。 */
16
+ owner: string;
17
+ location?: string;
18
+ danger?: boolean;
19
+ note?: string;
20
+ /** 当前规模描述,服务端实时算。 */
21
+ stat?: string;
22
+ }
23
+
24
+ export interface StorageViewDeps {
25
+ ui: ConsoleUi;
26
+ signal: AbortSignal;
27
+ /** 这一处画哪些项。省略 = 全部。 */
28
+ filter?(part: StoragePartView): boolean;
29
+ /** 画「一键清空全部」。 */
30
+ clearAll?: boolean;
31
+ }
32
+
33
+ export interface StorageView {
34
+ el: HTMLElement;
35
+ /** 取一次 `/api/storage` 并重建。可反复调。 */
36
+ load(): Promise<void>;
37
+ }
38
+
39
+ function errText(err: unknown): string {
40
+ return err instanceof Error ? err.message : String(err);
41
+ }
42
+
43
+ function isAbort(err: unknown): boolean {
44
+ return (err as { name?: unknown } | null)?.name === 'AbortError';
45
+ }
46
+
47
+ export function createStorageView(deps: StorageViewDeps): StorageView {
48
+ const { ui, signal } = deps;
49
+ const el = ui.h('div', 'storageview');
50
+ const body = ui.h('div');
51
+ const bar = ui.actions();
52
+ const msg = ui.msgline();
53
+ bar.append(msg, ui.h('span', 'grow'));
54
+ const nukeBtn = deps.clearAll ? ui.button(S.nukeAll, { variant: 'danger', onClick: () => void nuke() }) : null;
55
+ if (nukeBtn) bar.appendChild(nukeBtn);
56
+ el.append(body, bar);
57
+
58
+ function setMsg(text: string, bad?: boolean): void {
59
+ msg.textContent = text;
60
+ msg.className = 'msgline' + (bad ? ' bad' : '');
61
+ }
62
+
63
+ function row(p: StoragePartView): HTMLDivElement {
64
+ const item = ui.h('div', 'strow');
65
+ const info = ui.h('div', 'stinfo');
66
+ const label = ui.h('div', 'stlabel', p.label);
67
+ if (p.location) label.appendChild(ui.h('span', 'stloc', p.location));
68
+ info.appendChild(label);
69
+ if (p.note) info.appendChild(ui.h('div', 'stnote', p.note));
70
+ const stat = ui.h('div', 'ststat', p.stat || '');
71
+ const btn = ui.button(S.clear, {
72
+ size: 'sm',
73
+ variant: p.danger ? 'danger' : 'plain',
74
+ onClick: () => void clearOne(p, btn),
75
+ });
76
+ item.append(info, stat, btn);
77
+ return item;
78
+ }
79
+
80
+ async function clearOne(p: StoragePartView, btn: HTMLButtonElement): Promise<void> {
81
+ const ok = await ui.confirm(
82
+ p.danger
83
+ ? { title: S.dangerTitle(p.label), body: S.dangerBody(p.note || ''), danger: true }
84
+ : { title: S.clearTitle(p.label), body: p.note || '' },
85
+ );
86
+ if (!ok || signal.aborted) return;
87
+ const lock = ui.disable(btn);
88
+ try {
89
+ const out = await post<{ result?: string }>(
90
+ `/api/storage/clear?key=${encodeURIComponent(p.key)}`,
91
+ undefined,
92
+ { signal },
93
+ );
94
+ if (signal.aborted) return;
95
+ setMsg('✓ ' + (out.result || S.cleared));
96
+ await load();
97
+ } catch (err) {
98
+ if (isAbort(err) || signal.aborted) return;
99
+ setMsg(S.clearFailed(errText(err)), true);
100
+ } finally {
101
+ lock.dispose();
102
+ }
103
+ }
104
+
105
+ async function nuke(): Promise<void> {
106
+ if (!nukeBtn) return;
107
+ const ok = await ui.confirm({ title: S.nukeTitle, body: S.nukeBody, danger: true });
108
+ if (!ok || signal.aborted) return;
109
+ const lock = ui.disable(nukeBtn);
110
+ try {
111
+ const out = await post<{ results?: Array<{ key: string; ok: boolean }> }>(
112
+ '/api/storage/clear-all',
113
+ undefined,
114
+ { signal },
115
+ );
116
+ if (signal.aborted) return;
117
+ const results = out.results || [];
118
+ const bad = results.filter((x) => !x.ok);
119
+ setMsg(bad.length ? S.partialFailed(bad.map((x) => x.key).join(',')) : S.nukedAll(results.length), bad.length > 0);
120
+ await load();
121
+ } catch (err) {
122
+ if (isAbort(err) || signal.aborted) return;
123
+ setMsg(S.nukeFailed(errText(err)), true);
124
+ } finally {
125
+ lock.dispose();
126
+ }
127
+ }
128
+
129
+ async function load(): Promise<void> {
130
+ try {
131
+ const data = await get<{ parts?: StoragePartView[] }>('/api/storage', { signal });
132
+ if (signal.aborted) return;
133
+ const all = data.parts || [];
134
+ const parts = deps.filter ? all.filter((p) => deps.filter!(p)) : all;
135
+ body.replaceChildren();
136
+ if (!parts.length) {
137
+ body.appendChild(ui.placeholder(all.length ? S.empty : S.noList));
138
+ return;
139
+ }
140
+ for (const [kind, title] of [['disk', S.sectionDisk], ['memory', S.sectionMemory]] as const) {
141
+ const items = parts.filter((p) => p.kind === kind);
142
+ if (!items.length) continue;
143
+ body.appendChild(ui.h('div', 'stacklabel', title));
144
+ for (const p of items) body.appendChild(row(p));
145
+ }
146
+ } catch (err) {
147
+ if (isAbort(err) || signal.aborted) return;
148
+ body.replaceChildren(ui.placeholder(S.loadFailed(errText(err))));
149
+ }
150
+ }
151
+
152
+ body.appendChild(ui.placeholder(S.loading));
153
+ return { el, load };
154
+ }
@@ -0,0 +1,486 @@
1
+ /**
2
+ * 用量页的堆叠柱图,包含 byRoleModel 寻址、成本档位和类型→角色→模型层级,归用量 feature 所有。
3
+ * 组合、取值、配色、格式化和桶标签均为独立导出的纯函数;renderChart 仅操作自身创建的 SVG。
4
+ */
5
+
6
+ import type { ConsoleUi } from '../../../shared/client-panel.ts';
7
+ import { consoleFormat } from '../../ui/format.ts';
8
+ import { esc } from '../../ui/dom.ts';
9
+ import { clampNumber, hexToHsl, hslCss } from '../../theme/palette.ts';
10
+ import { U_PALETTE, U_TYPES } from './labels.ts';
11
+ import { S } from './strings.ts';
12
+ import type {
13
+ ColorResolver,
14
+ UsageAccum,
15
+ UsageAggregate,
16
+ UsageBucketUnit,
17
+ UsageComposite,
18
+ UsageDim,
19
+ UsageDimFlags,
20
+ UsageGroupStat,
21
+ UsageMetric,
22
+ UsagePoint,
23
+ UsageTypeKey,
24
+ } from './types.ts';
25
+ import type { UsageTip } from './tooltip.ts';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // 取值归一
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /** 一个累计单元在某个指标下的总量。 */
32
+ export function uMetricVal(o: UsageAccum | null | undefined, metric: UsageMetric): number {
33
+ if (!o) return 0;
34
+ if (metric === 'cost') return o.cost || 0;
35
+ if (metric === 'calls') return o.calls || 0;
36
+ return (o.promptTokens || 0) + (o.completionTokens || 0);
37
+ }
38
+
39
+ /**
40
+ * 三档分量。`o` 可以是桶点,也可以是任一子拆分 accum——两者同形,
41
+ * 所以"类型"这一维可以和角色/模型任意交叉。
42
+ */
43
+ export function uTypeVal(
44
+ o: UsageAccum | null | undefined,
45
+ key: UsageTypeKey,
46
+ metric: UsageMetric,
47
+ ): number {
48
+ if (!o) return 0;
49
+ if (key === 'other') return metric === 'cost' ? o.costOther || 0 : o.unclassifiedInputTokens || 0;
50
+ if (metric === 'cost') {
51
+ return key === 'cacheHit' ? (o.costCacheHit || 0)
52
+ : key === 'cacheMiss' ? (o.costCacheMiss || 0)
53
+ : (o.costOutput || 0);
54
+ }
55
+ return key === 'cacheHit' ? (o.cacheHitTokens || 0)
56
+ : key === 'cacheMiss' ? (o.cacheMissTokens || 0)
57
+ : (o.completionTokens || 0);
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // 维度 → 组合
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /** 某个维度在这份数据里的分组表。`type` 不是分组,走 `U_TYPES`。 */
65
+ function dimGroups(d: UsageAggregate, dim: UsageDim): readonly UsageGroupStat[] {
66
+ return (dim === 'role' ? d.byRole : d.byModel) || [];
67
+ }
68
+
69
+ function dimKeys(d: UsageAggregate, dim: UsageDim): string[] {
70
+ if (dim === 'type') return U_TYPES.filter(t => t.key !== 'other' || (d.totals?.costOther || 0) > 0 || (d.totals?.unclassifiedInputTokens || 0) > 0).map(t => t.key);
71
+ return dimGroups(d, dim).map((g) => g.key);
72
+ }
73
+
74
+ /** 显示 UsageGroupStat.label;缺少 label 时显示原 id。 */
75
+ export function dimKeyLabel(d: UsageAggregate, dim: UsageDim, key: string): string {
76
+ if (dim === 'type') return U_TYPES.find((t) => t.key === key)?.label ?? key;
77
+ return groupLabel(dimGroups(d, dim), key);
78
+ }
79
+
80
+ /** 一条分组的人话名:声明方给了就用,没给就用 id。 */
81
+ function groupLabel(groups: readonly UsageGroupStat[], key: string): string {
82
+ return groups.find((g) => g.key === key)?.label || key;
83
+ }
84
+
85
+ /** 按键的字典序分配颜色,使同一键集的颜色不随成本排序变化。 */
86
+ export function paletteSeat(keys: readonly string[], key: string): string {
87
+ const sorted = [...keys].sort();
88
+ const i = sorted.indexOf(key);
89
+ return U_PALETTE[(i < 0 ? 0 : i) % U_PALETTE.length];
90
+ }
91
+
92
+ function dimBaseColor(
93
+ d: UsageAggregate,
94
+ dim: UsageDim,
95
+ key: string,
96
+ color: ColorResolver,
97
+ ): string {
98
+ if (dim === 'type') return color(U_TYPES.find((t) => t.key === key)?.color ?? U_PALETTE[0]);
99
+ return color(paletteSeat(dimKeys(d, dim), key));
100
+ }
101
+
102
+ /** 勾选状态 → 有序维度列表。顺序即嵌套序:类型 > 角色 > 模型。 */
103
+ export function activeDims(dims: UsageDimFlags, allowType: boolean): UsageDim[] {
104
+ const out: UsageDim[] = [];
105
+ if (allowType && dims.type) out.push('type');
106
+ if (dims.role) out.push('role');
107
+ if (dims.model) out.push('model');
108
+ return out;
109
+ }
110
+
111
+ /** 各维度键集的笛卡尔积。无维度 → `[[]]`(一根合计柱),不是 `[]`。 */
112
+ export function buildComposites(d: UsageAggregate, dimsList: readonly UsageDim[]): UsageComposite[] {
113
+ let combos: UsageComposite[] = [[]];
114
+ for (const dim of dimsList) {
115
+ const keys = dimKeys(d, dim);
116
+ const nx: UsageComposite[] = [];
117
+ for (const c of combos) for (const k of keys) nx.push(c.concat([{ dim, key: k }]));
118
+ combos = nx;
119
+ }
120
+ return combos;
121
+ }
122
+
123
+ /** 组合的稳定 id。分隔符取 U+241F(单元分隔符的可见形),键里不会出现。 */
124
+ export function compId(comp: UsageComposite): string {
125
+ return comp.map((c) => c.dim + ':' + c.key).join('␟');
126
+ }
127
+
128
+ /** 组合的显示名。要 `d` 是因为角色/模型的人话名只能从数据里取(见 `dimKeyLabel`)。 */
129
+ export function compLabel(d: UsageAggregate, comp: UsageComposite): string {
130
+ return comp.length ? comp.map((c) => dimKeyLabel(d, c.dim, c.key)).join(' · ') : S.total;
131
+ }
132
+
133
+ /**
134
+ * 组合 → 桶里对应的那个累计单元。
135
+ *
136
+ * "类型"不参与寻址(它是同一个 accum 内部的分量),所以只看 role / model:
137
+ * 两者都有走交叉表,只有一个走单维表,都没有就是整个桶。
138
+ */
139
+ export function compScopeAccum(p: UsagePoint, comp: UsageComposite): UsageAccum | undefined {
140
+ const role = comp.find((c) => c.dim === 'role');
141
+ const model = comp.find((c) => c.dim === 'model');
142
+ if (role && model) return (p.byRoleModel || {})[role.key]?.[model.key];
143
+ if (role) return (p.byRole || {})[role.key];
144
+ if (model) return (p.byModel || {})[model.key];
145
+ return p;
146
+ }
147
+
148
+ export function compVal(p: UsagePoint, comp: UsageComposite, metric: UsageMetric): number {
149
+ const acc = compScopeAccum(p, comp);
150
+ if (!acc) return 0;
151
+ const type = comp.find((c) => c.dim === 'type');
152
+ return type ? uTypeVal(acc, type.key as UsageTypeKey, metric) : uMetricVal(acc, metric);
153
+ }
154
+
155
+ /** 最外层维度决定色系,同色系按位次调整明度;排序不改色。 */
156
+ export function compositeColors(
157
+ d: UsageAggregate,
158
+ composites: readonly UsageComposite[],
159
+ dimsList: readonly UsageDim[],
160
+ color: ColorResolver,
161
+ ): Record<string, string> {
162
+ const colorOf: Record<string, string> = {};
163
+ if (!dimsList.length) {
164
+ colorOf[compId([])] = 'var(--accent-2)';
165
+ return colorOf;
166
+ }
167
+ const fam = new Map<string, UsageComposite[]>();
168
+ for (const comp of composites) {
169
+ const fk = comp[0]!.key;
170
+ if (!fam.has(fk)) fam.set(fk, []);
171
+ fam.get(fk)!.push(comp);
172
+ }
173
+ for (const [fk, members] of fam) {
174
+ const base = dimBaseColor(d, dimsList[0]!, fk, color);
175
+ const hsl = hexToHsl(base);
176
+ const n = members.length;
177
+ members.forEach((comp, i) => {
178
+ let col = base;
179
+ if (n > 1) {
180
+ // 明度在 24–82 之间铺开:再深就黑成一片,再浅就白得看不见分界。
181
+ const l = clampNumber(hsl.l - 12 + (i / (n - 1)) * 30, 24, 82);
182
+ col = hslCss({ h: hsl.h, s: clampNumber(hsl.s, 18, 72), l });
183
+ }
184
+ colorOf[compId(comp)] = col;
185
+ });
186
+ }
187
+ return colorOf;
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // 格式化
192
+ // ---------------------------------------------------------------------------
193
+
194
+ /** 读数:成本带币种、调用带"次"、token 带 "tok"。 */
195
+ export function fmtMetric(v: number, metric: UsageMetric, cur?: string): string {
196
+ if (metric === 'cost') return consoleFormat.money(v, cur);
197
+ if (metric === 'calls') return Math.round(v).toLocaleString() + S.callsUnit;
198
+ return consoleFormat.count(Math.round(v)) + ' tok';
199
+ }
200
+
201
+ /** 纵轴刻度:比读数短一截,不带单位。 */
202
+ export function fmtAxis(v: number, metric: UsageMetric): string {
203
+ if (metric === 'cost') return v < 1 ? v.toFixed(3) : v.toFixed(1);
204
+ if (metric === 'calls') return String(Math.round(v));
205
+ return consoleFormat.count(Math.round(v));
206
+ }
207
+
208
+ /** 缓存命中率。分母为 0 时是 `null`(不是 0)——"没有输入"与"一次没命中"不是一回事。 */
209
+ export function cacheRateOf(p: UsageAccum): number | null {
210
+ const d = (p.cacheHitTokens || 0) + (p.cacheMissTokens || 0);
211
+ return d > 0 ? (p.cacheHitTokens || 0) / d : null;
212
+ }
213
+
214
+ /** 横轴上的短标签。 */
215
+ export function bucketLabel(bucket: string, unit: UsageBucketUnit): string {
216
+ if (unit === 'minute') return bucket.slice(11, 16);
217
+ if (unit === 'hour') return bucket.slice(11, 13) + 'h';
218
+ if (unit === 'week') return S.weekShort(bucket.slice(5));
219
+ if (unit === 'month') return bucket.slice(0, 7);
220
+ return bucket.slice(5);
221
+ }
222
+
223
+ /** tooltip 抬头上的完整标签。 */
224
+ export function bucketFull(bucket: string, unit: UsageBucketUnit): string {
225
+ if (unit === 'minute') return bucket.replace('T', ' ');
226
+ if (unit === 'hour') return bucket.replace('T', ' ') + ':00';
227
+ if (unit === 'week') return S.weekFull(bucket);
228
+ if (unit === 'month') return S.monthFull(bucket);
229
+ return bucket;
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // tooltip 正文(纯函数:进去一个桶,出来一段 HTML)
234
+ // ---------------------------------------------------------------------------
235
+
236
+ export interface UsageTipOptions {
237
+ metric: UsageMetric;
238
+ stacked: boolean;
239
+ cur?: string;
240
+ unit: UsageBucketUnit;
241
+ /** 鼠标此刻压着的那个段;空 = 压在整列的感应块上。 */
242
+ curId: string | null;
243
+ }
244
+
245
+ /** tooltip 里最多列几行明细,再多就"其余从略"。 */
246
+ const TIP_ROWS_MAX = 16;
247
+
248
+ export function uTipHtml(
249
+ d: UsageAggregate,
250
+ p: UsagePoint,
251
+ order: readonly UsageComposite[],
252
+ colorOf: Record<string, string>,
253
+ o: UsageTipOptions,
254
+ ): string {
255
+ const total = uMetricVal(p, o.metric);
256
+ const pctOf = (v: number): number => (total > 0 ? Math.round((v / total) * 100) : 0);
257
+ let s = `<div class="tt-h">${esc(bucketFull(p.bucket, o.unit))}</div>`;
258
+ // 鼠标当前所指的块:醒目单列出来,回答"这块是什么"
259
+ if (o.stacked && o.curId) {
260
+ const comp = order.find((c) => compId(c) === o.curId);
261
+ if (comp) {
262
+ const v = compVal(p, comp, o.metric);
263
+ s += `<div class="tt-cur"><span class="tt-sw" style="background:${colorOf[o.curId]}"></span>`
264
+ + `<span class="tt-l">▸ ${esc(compLabel(d, comp))}</span>`
265
+ + `<span class="tt-v">${fmtMetric(v, o.metric, o.cur)} · ${pctOf(v)}%</span></div>`;
266
+ }
267
+ }
268
+ s += `<div class="tt-tot">${o.stacked ? S.totalPrefix : ''}${fmtMetric(total, o.metric, o.cur)}</div>`;
269
+ if (o.stacked) {
270
+ let shown = 0;
271
+ for (const comp of order) {
272
+ const v = compVal(p, comp, o.metric);
273
+ if (v <= 0) continue;
274
+ if (shown++ >= TIP_ROWS_MAX) {
275
+ s += `<div class="tt-extra">${S.restOmitted}</div>`;
276
+ break;
277
+ }
278
+ const cid = compId(comp);
279
+ const cur = o.curId && cid === o.curId ? ' cur' : '';
280
+ s += `<div class="tt-row${cur}"><span class="tt-sw" style="background:${colorOf[cid]}"></span>`
281
+ + `<span class="tt-l">${esc(compLabel(d, comp))}</span>`
282
+ + `<span class="tt-v">${fmtMetric(v, o.metric, o.cur)} · ${pctOf(v)}%</span></div>`;
283
+ }
284
+ }
285
+ s += `<div class="tt-extra">${S.tipFoot(
286
+ p.calls || 0,
287
+ consoleFormat.percent(cacheRateOf(p)),
288
+ consoleFormat.count(p.reasoningTokens || 0),
289
+ )}</div>`;
290
+ return s;
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // SVG
295
+ // ---------------------------------------------------------------------------
296
+
297
+ const SVGNS = 'http://www.w3.org/2000/svg';
298
+
299
+ /** SVG 元素使用 SVG namespace。 */
300
+ function mkSvgEl(
301
+ doc: Document,
302
+ tag: string,
303
+ attrs: Record<string, string | number>,
304
+ text?: string,
305
+ ): SVGElement {
306
+ const e = doc.createElementNS(SVGNS, tag) as SVGElement;
307
+ for (const k in attrs) e.setAttribute(k, String(attrs[k]));
308
+ if (text != null) e.textContent = text;
309
+ return e;
310
+ }
311
+
312
+ export interface ChartDeps {
313
+ doc: Document;
314
+ ui: ConsoleUi;
315
+ /** 图画在这里。宽度取它的 `clientWidth`。 */
316
+ box: HTMLElement;
317
+ /** 图例容器;不拆分时清空后留白。 */
318
+ legend: HTMLElement;
319
+ tip: UsageTip;
320
+ color: ColorResolver;
321
+ }
322
+
323
+ export interface ChartOptions {
324
+ metric: UsageMetric;
325
+ dims: UsageDimFlags;
326
+ /** 调用图没有"类型"这一维(次数没有三档分量可分)。 */
327
+ allowType?: boolean;
328
+ /** 按占比降序统一堆叠;关掉则按 类型>角色>模型 的嵌套序。 */
329
+ sort?: boolean;
330
+ }
331
+
332
+ const PAD_L = 52;
333
+ const PAD_B = 46;
334
+ const PAD_T = 12;
335
+ const CHART_H = 248;
336
+ /** 横轴最多印几个刻度标签,超了按 stride 抽稀。 */
337
+ const XLABEL_MAX = 18;
338
+
339
+ /** 成本、token 与调用次数的堆叠柱图;SVG 统一处理 hover 与离开事件。 */
340
+ export function renderChart(deps: ChartDeps, d: UsageAggregate, opts: ChartOptions): void {
341
+ const { doc, ui, box, legend, tip, color } = deps;
342
+ box.replaceChildren();
343
+ tip.clearHi();
344
+ legend.replaceChildren();
345
+
346
+ const series = d.series || [];
347
+ const unit: UsageBucketUnit = d.bucket || 'day';
348
+ const cur = d.currency || 'USD';
349
+ const metric = opts.metric;
350
+ if (!series.length) {
351
+ box.appendChild(ui.placeholder(S.noCalls));
352
+ return;
353
+ }
354
+
355
+ const dimsList = activeDims(opts.dims, opts.allowType !== false);
356
+ const stacked = dimsList.length > 0;
357
+ let composites = buildComposites(d, dimsList);
358
+ const tot: Record<string, number> = {};
359
+ for (const c of composites) tot[compId(c)] = 0;
360
+ for (const p of series) for (const c of composites) tot[compId(c)] += compVal(p, c, metric);
361
+ if (stacked) {
362
+ // 丢掉整段全 0 的组合,减少图例/色块噪声
363
+ composites = composites.filter((c) => (tot[compId(c)] || 0) > 0);
364
+ if (!composites.length) composites = [[]];
365
+ }
366
+ const colorOf = compositeColors(d, composites, dimsList, color);
367
+ const order = (stacked && opts.sort)
368
+ ? composites.slice().sort((a, b) => (tot[compId(b)] || 0) - (tot[compId(a)] || 0))
369
+ : composites;
370
+
371
+ if (stacked) {
372
+ for (const comp of order) {
373
+ const it = ui.h('span', 'legitem');
374
+ const sw = ui.h('span', 'legsw');
375
+ sw.style.background = colorOf[compId(comp)] ?? '';
376
+ it.append(sw, ui.h('span', 'leglbl', compLabel(d, comp)));
377
+ legend.appendChild(it);
378
+ }
379
+ }
380
+
381
+ const chartH = CHART_H - PAD_T - PAD_B;
382
+ const avail = Math.max(320, box.getBoundingClientRect().width || box.clientWidth || 700);
383
+ let step = Math.floor((avail - PAD_L - 22) / series.length);
384
+ step = Math.max(18, Math.min(72, step));
385
+ const W = Math.max(avail, series.length * step + PAD_L + 24);
386
+ const bw = Math.max(6, Math.min(40, step - 6));
387
+ let max = 0;
388
+ for (const p of series) {
389
+ const t = uMetricVal(p, metric);
390
+ if (t > max) max = t;
391
+ }
392
+ if (!(max > 0)) max = 1;
393
+
394
+ const svg = mkSvgEl(doc, 'svg', { viewBox: `0 0 ${W} ${CHART_H}`, width: W, height: CHART_H });
395
+ const defs = mkSvgEl(doc, 'defs', {});
396
+ svg.appendChild(defs);
397
+ const clipPrefix = (box.id || 'usage-chart').replace(/[^a-zA-Z0-9_-]/g, '-');
398
+ for (let i = 0; i <= 4; i++) {
399
+ const y = PAD_T + chartH * (1 - i / 4);
400
+ svg.appendChild(mkSvgEl(doc, 'line', {
401
+ x1: PAD_L, y1: y, x2: W - 8, y2: y, class: 'axis', opacity: i === 0 ? 0.9 : 0.35,
402
+ }));
403
+ svg.appendChild(mkSvgEl(doc, 'text', {
404
+ x: PAD_L - 6, y: y + 3, 'text-anchor': 'end', class: 'vlabel',
405
+ }, fmtAxis((max * i) / 4, metric)));
406
+ }
407
+
408
+ const sep = stacked && order.length > 1;
409
+ const stride = Math.ceil(series.length / XLABEL_MAX);
410
+ series.forEach((p, i) => {
411
+ const cx = PAD_L + i * step; // 列起点
412
+ const bx = cx + Math.max(1, (step - bw) / 2); // 柱体居中
413
+ const g = mkSvgEl(doc, 'g', { class: 'barg' });
414
+ // 全宽透明感应块:整列都归这个桶,相邻列平铺无缝隙,移动不断档
415
+ g.appendChild(mkSvgEl(doc, 'rect', {
416
+ x: cx, y: PAD_T, width: step, height: CHART_H - PAD_T - PAD_B,
417
+ fill: 'transparent', class: 'bhit', 'data-i': i,
418
+ }));
419
+ const shownTotal = stacked
420
+ ? order.reduce((sum, comp) => sum + compVal(p, comp, metric), 0)
421
+ : uMetricVal(p, metric);
422
+ const totalH = chartH * (shownTotal / max);
423
+ const clipId = `${clipPrefix}-bar-${i}`;
424
+ const clip = mkSvgEl(doc, 'clipPath', { id: clipId });
425
+ clip.appendChild(mkSvgEl(doc, 'rect', {
426
+ x: bx,
427
+ y: CHART_H - PAD_B - totalH,
428
+ width: bw,
429
+ height: Math.max(0.6, totalH),
430
+ rx: Math.min(4, bw / 2, Math.max(0.3, totalH / 2)),
431
+ }));
432
+ defs.appendChild(clip);
433
+ const stack = mkSvgEl(doc, 'g', { 'clip-path': `url(#${clipId})` });
434
+ let acc = 0;
435
+ for (const comp of order) {
436
+ const v = compVal(p, comp, metric);
437
+ if (v <= 0) continue;
438
+ const cid = compId(comp);
439
+ const segH = chartH * (v / max);
440
+ const y = CHART_H - PAD_B - acc - segH;
441
+ const at: Record<string, string | number> = {
442
+ x: bx, y, width: bw, height: Math.max(0.6, segH),
443
+ fill: colorOf[cid] ?? '', class: 'bseg', 'data-i': i,
444
+ };
445
+ if (stacked) at['data-cid'] = cid;
446
+ if (sep) {
447
+ at.stroke = 'var(--sheet)';
448
+ at['stroke-opacity'] = 0.55;
449
+ at['stroke-width'] = 0.5;
450
+ }
451
+ stack.appendChild(mkSvgEl(doc, 'rect', at));
452
+ acc += segH;
453
+ }
454
+ g.appendChild(stack);
455
+ svg.appendChild(g);
456
+ if (i % stride === 0) {
457
+ svg.appendChild(mkSvgEl(doc, 'text', {
458
+ x: bx + bw / 2, y: CHART_H - PAD_B + 14, 'text-anchor': 'middle', class: 'glabel',
459
+ }, bucketLabel(p.bucket, unit)));
460
+ }
461
+ });
462
+
463
+ const tipHtmlFor = (i: number, curId: string | null): string =>
464
+ uTipHtml(d, series[i]!, order, colorOf, { metric, stacked, cur, unit, curId });
465
+ // 监听挂在这棵刚造出来的 svg 上:整棵树下次重画时被丢弃,监听随之消失。
466
+ svg.addEventListener('mousemove', (ev) => {
467
+ const t = ev.target as Element | null;
468
+ const cl = t?.classList;
469
+ if (stacked && cl?.contains('bseg')) {
470
+ const i = Number(t!.getAttribute('data-i'));
471
+ const cid = t!.getAttribute('data-cid') || '';
472
+ tip.setHi(t!);
473
+ tip.show(tipHtmlFor(i, cid), `${i}|${cid}`);
474
+ tip.move(ev as MouseEvent);
475
+ } else if (cl && (cl.contains('bhit') || cl.contains('bseg'))) {
476
+ const i = Number(t!.getAttribute('data-i'));
477
+ tip.clearHi();
478
+ tip.show(tipHtmlFor(i, null), `${i}|`);
479
+ tip.move(ev as MouseEvent);
480
+ } else {
481
+ tip.hide();
482
+ }
483
+ });
484
+ svg.addEventListener('mouseleave', () => tip.hide());
485
+ box.appendChild(svg);
486
+ }