dsh-plugin-mind-tree 0.3.0 → 0.4.1

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.
package/README.md CHANGED
@@ -16,16 +16,25 @@
16
16
  选择保存在本机(`localStorage`),随时可「恢复默认」;
17
17
  默认色调经**对比度优化**(白字对比不足 4.5:1 时自动改用深色字),比原版更耐看而不刺眼;
18
18
  - **气泡字号适中偏大**(主文字 13px / 摘要 12px,行高 34px),贴合 DSH 设计语言,窄侧栏里也清晰可读;
19
- - **过程信息三档削减**(默认「精简」,工具栏可切换;导出跟随当前档位):
19
+ - **过程信息四档削减**(默认「精简」,工具栏可切换;导出跟随当前档位):
20
20
 
21
21
  | 档位 | 保留内容 | 真实会话实测(2491 节点 / 15 轮) |
22
22
  |---|---|---|
23
+ | **输入** | 轮次 + 你的真实发言 + 分支点(= 把整场对话压成需求清单) | 41 节点 |
23
24
  | **精简**(默认) | 轮次 + 你的真实发言 + 全部分支点 + 每轮「工具活动 ×N」聚合行 + 每轮答复 | **119 节点**,大纲 8 KB |
24
25
  | **标准** | 精简 + 实质性过程文本(≥60 字)与每条工具调用(不含工具输出) | 1259 节点,136 KB |
25
26
  | **完整** | 全部节点,与事件日志一一对应 | 2491 节点,286 KB |
26
27
 
27
28
  工具聚合行自带被折叠调用的清单(点它即展开)、可搜索、可定位到对应轮次;
28
29
  削减只作用于展示与导出,**宿主始终返回完整数据**,随时可切回「完整」。
30
+ - **成本估算**:按**该轮实际模型**(`message.source.model`)与**高峰/空闲时段**把 token 折算成人民币:
31
+ 每轮行尾显示 `¥`、详情面板给出模型与成本、搜索框下方显示整场合计;未知模型按兜底价并标 `≈`。
32
+ 单价来源 DeepSeek 官方「模型 & 价格」页(`lib/pricing.js` 里带版本号,改价只改一处);
33
+ 缓存命中/未命中、输出分别计价,缓存写入按未命中输入计费。
34
+ - **缩放 / 平移 / 图片导出**:`Ctrl/⌘ + 滚轮` 以光标为中心缩放、拖动平移、双击或「适配」回到整体;
35
+ 「导出 ▾」新增 **SVG 矢量图** 与 **PNG 图片**(2 倍像素,超大画布自动降倍率),可直接贴进文档/聊天。
36
+ - **实时跟随**:会话产生新事件时**静默刷新**(不闪 loading、900ms 节流),工具栏显示「● 运行中」,
37
+ 最新一轮自动加虚线环与脉动圆点;流中断自动重连。
29
38
  - **导出 Markdown / Canvas**:把思维树导出为可归档文档(含分支标记、token 统计;大纲与 JSON 跟随当前档位);
30
39
  - **分支节点识别**:
31
40
  - 替换分支(消息被编辑/重试后旧内容脱离上下文,橙色虚线连接)
@@ -84,6 +93,15 @@
84
93
  > 正确写法:`https://github.com/ShOpenMind/dsh-plugin-mind-tree/releases/latest/download/dsh-plugin-mind-tree.tgz`
85
94
  > 已经被卡住的话:`cd ~/.dsh/profiles/<profile> && npx pnpm install --update-checksums`。
86
95
 
96
+ ### 方式零(最省事):从 npm 装
97
+
98
+ ```sh
99
+ dsh plugin install dsh-plugin-mind-tree
100
+ ```
101
+
102
+ 或在 Harness 的「插件」面板里搜索 `dsh-plugin-mind-tree`。
103
+ npm 上的版本号不可变,不存在「同一地址内容变了、哈希对不上」的问题。
104
+
87
105
  ### 方式一(推荐):粘贴一行地址,30 秒装好,**不需要 git**
88
106
 
89
107
  在 DeepSeek Harness 的**插件页 → 添加插件**里粘贴下面的地址(**固定链接,始终指向最新版**):
package/lib/client.js CHANGED
@@ -20955,6 +20955,7 @@ function reduceGraph(tree, level = "compact") {
20955
20955
  if (level === "full") return tree;
20956
20956
  const nodes = tree.nodes ?? [];
20957
20957
  const keepIds = /* @__PURE__ */ new Set();
20958
+ const isUserOnly = level === "user";
20958
20959
  const isCompact = level === "compact";
20959
20960
  const minAssistant = isCompact ? Number.POSITIVE_INFINITY : MIN_ASSISTANT_STANDARD;
20960
20961
  const lastOfTurn = lastAssistantIds(nodes);
@@ -20975,13 +20976,15 @@ function reduceGraph(tree, level = "compact") {
20975
20976
  case "info":
20976
20977
  continue;
20977
20978
  // system / developer 注入内容
20978
- case "user":
20979
- if (isInjectedLabel(node2.label)) continue;
20980
- break;
20981
20979
  case "assistant":
20980
+ if (isUserOnly) continue;
20982
20981
  if (!(lastOfTurn.has(node2.id) || charLength(node2.label) >= minAssistant)) continue;
20983
20982
  break;
20983
+ case "user":
20984
+ if (isInjectedLabel(node2.label)) continue;
20985
+ break;
20984
20986
  case "tool": {
20987
+ if (isUserOnly) continue;
20985
20988
  if (!isCompact) break;
20986
20989
  const key = node2.turn ?? -1;
20987
20990
  if (!groupBuckets.has(key)) groupBuckets.set(key, []);
@@ -21071,7 +21074,7 @@ var DETAIL_LEVELS, MIN_ASSISTANT_STANDARD, GROUP_NAME_LIMIT, charLength, DETAIL_
21071
21074
  var init_reduce = __esm({
21072
21075
  "lib/reduce.js"() {
21073
21076
  init_format();
21074
- DETAIL_LEVELS = ["compact", "standard", "full"];
21077
+ DETAIL_LEVELS = ["user", "compact", "standard", "full"];
21075
21078
  MIN_ASSISTANT_STANDARD = 60;
21076
21079
  GROUP_NAME_LIMIT = 4;
21077
21080
  charLength = (text) => [...String(text ?? "")].length;
@@ -21083,12 +21086,96 @@ var init_reduce = __esm({
21083
21086
  }
21084
21087
  });
21085
21088
 
21089
+ // lib/pricing.js
21090
+ var pricing_exports = {};
21091
+ __export(pricing_exports, {
21092
+ FALLBACK_PRICE: () => FALLBACK_PRICE,
21093
+ MODEL_PRICES: () => MODEL_PRICES,
21094
+ PRICING_VERSION: () => PRICING_VERSION,
21095
+ costOf: () => costOf,
21096
+ costOfTree: () => costOfTree,
21097
+ formatCny: () => formatCny,
21098
+ isPeakHour: () => isPeakHour,
21099
+ priceOf: () => priceOf,
21100
+ sumCost: () => sumCost
21101
+ });
21102
+ function isPeakHour(atMs) {
21103
+ const at = typeof atMs === "number" && Number.isFinite(atMs) ? atMs : Date.now();
21104
+ const shifted = new Date(at + BJ_OFFSET_MS);
21105
+ const day = shifted.getUTCDay();
21106
+ const minutes = shifted.getUTCHours() * 60 + shifted.getUTCMinutes();
21107
+ const weekday = day >= 1 && day <= 5;
21108
+ const inMorning = minutes >= 9 * 60 && minutes < 12 * 60;
21109
+ const inAfternoon = minutes >= 14 * 60 && minutes < 18 * 60;
21110
+ return weekday && (inMorning || inAfternoon);
21111
+ }
21112
+ function priceOf(model, atMs) {
21113
+ const table = MODEL_PRICES[model] ?? FALLBACK_PRICE;
21114
+ const slot = isPeakHour(atMs) ? 1 : 0;
21115
+ return {
21116
+ model: MODEL_PRICES[model] ? model : null,
21117
+ peak: slot === 1,
21118
+ hit: table.hit[slot],
21119
+ miss: table.miss[slot],
21120
+ out: table.out[slot]
21121
+ };
21122
+ }
21123
+ function costOf(usage, model, atMs) {
21124
+ const input2 = Math.max(0, Number(usage?.input) || 0);
21125
+ const output2 = Math.max(0, Number(usage?.output) || 0);
21126
+ const cacheRead = Math.max(0, Number(usage?.cacheRead) || 0);
21127
+ const cacheWrite = Math.max(0, Number(usage?.cacheWrite) || 0);
21128
+ const price = priceOf(model, atMs);
21129
+ const cny = ((input2 + cacheWrite) * price.miss + cacheRead * price.hit + output2 * price.out) / 1e6;
21130
+ return { cny, peak: price.peak, model: price.model, known: price.model !== null };
21131
+ }
21132
+ function sumCost(entries) {
21133
+ let cny = 0;
21134
+ let anyUnknown = false;
21135
+ for (const entry of entries ?? []) {
21136
+ if (!entry) continue;
21137
+ cny += Number(entry.cny) || 0;
21138
+ if (entry.known === false) anyUnknown = true;
21139
+ }
21140
+ return { cny, anyUnknown };
21141
+ }
21142
+ function formatCny(value) {
21143
+ const cny = Number(value) || 0;
21144
+ if (cny <= 0) return "\xA50";
21145
+ if (cny < 5e-3) return "<\xA50.01";
21146
+ if (cny < 1) return `\xA5${cny.toFixed(3)}`;
21147
+ return `\xA5${cny.toFixed(2)}`;
21148
+ }
21149
+ function costOfTree(tree) {
21150
+ const entries = [];
21151
+ for (const node2 of tree?.nodes ?? []) {
21152
+ if (node2?.kind !== "turn" || !node2.usage) continue;
21153
+ entries.push(costOf(node2.usage, node2.usage.model, node2.usage.startedAt));
21154
+ }
21155
+ return sumCost(entries);
21156
+ }
21157
+ var PRICING_VERSION, MODEL_PRICES, FALLBACK_PRICE, BJ_OFFSET_MS;
21158
+ var init_pricing = __esm({
21159
+ "lib/pricing.js"() {
21160
+ PRICING_VERSION = "2026-11-15";
21161
+ MODEL_PRICES = {
21162
+ "deepseek-flash": { hit: [0.02, 0.04], miss: [1, 2], out: [4, 8] },
21163
+ "deepseek-v4-flash": { hit: [0.02, 0.04], miss: [1, 2], out: [4, 8] },
21164
+ "deepseek-v4-flash-vision-exp": { hit: [0.02, 0.04], miss: [1, 2], out: [4, 8] },
21165
+ "deepseek-v4-pro": { hit: [0.15, 0.3], miss: [4.5, 9], out: [13.5, 27] }
21166
+ };
21167
+ FALLBACK_PRICE = MODEL_PRICES["deepseek-flash"];
21168
+ BJ_OFFSET_MS = 8 * 3600 * 1e3;
21169
+ }
21170
+ });
21171
+
21086
21172
  // src/client.js
21087
21173
  var React = require("react");
21088
21174
  var { MenuItemButton } = require("@deepseek-ai/dsh-client-ui-primitives");
21089
21175
  var { TYPERT_REMOTE: TYPERT_REMOTE2 } = (init_remote(), __toCommonJS(remote_exports));
21090
21176
  var FMT = (init_format(), __toCommonJS(format_exports));
21091
21177
  var REDUCE = (init_reduce(), __toCommonJS(reduce_exports));
21178
+ var PRICING = (init_pricing(), __toCommonJS(pricing_exports));
21092
21179
  var h = React.createElement;
21093
21180
  var PACKAGE_ID = "dsh-plugin-mind-tree";
21094
21181
  var REGISTRY_ID = `${PACKAGE_ID}/mind-tree`;
@@ -21190,13 +21277,29 @@ var MESSAGES = {
21190
21277
  "export.cancelled": "\u5DF2\u53D6\u6D88",
21191
21278
  "export.failed": "\u5BFC\u51FA\u5931\u8D25",
21192
21279
  "warning": "\u517C\u5BB9\u6027\u63D0\u793A",
21280
+ "level.user": "\u8F93\u5165",
21193
21281
  "level.compact": "\u7CBE\u7B80",
21194
21282
  "level.standard": "\u6807\u51C6",
21195
21283
  "level.full": "\u5B8C\u6574",
21196
- "level.hint": "\u4FE1\u606F\u91CF",
21284
+ "level.hint": "\u4FE1\u606F\u91CF(\u8F93\u5165 / \u7CBE\u7B80 / \u6807\u51C6 / \u5B8C\u6574)",
21285
+ "level.user.hint": "\u53EA\u7559\u8F6E\u6B21\u3001\u4F60\u7684\u53D1\u8A00\u4E0E\u5206\u652F\u70B9 \u2014\u2014 \u76F8\u5F53\u4E8E\u628A\u6574\u573A\u5BF9\u8BDD\u538B\u6210\u9700\u6C42\u6E05\u5355",
21197
21286
  "level.compact.hint": "\u53EA\u7559\u8F6E\u6B21\u3001\u4F60\u7684\u53D1\u8A00\u3001\u5206\u652F\u70B9\u3001\u5DE5\u5177\u6D3B\u52A8\u5408\u8BA1\u4E0E\u6BCF\u8F6E\u7B54\u590D",
21198
21287
  "level.standard.hint": "\u5728\u7CBE\u7B80\u57FA\u7840\u4E0A,\u4FDD\u7559\u5B9E\u8D28\u8FC7\u7A0B\u6587\u672C\u4E0E\u6BCF\u6761\u5DE5\u5177\u8C03\u7528(\u4E0D\u542B\u5DE5\u5177\u8F93\u51FA)",
21199
21288
  "level.full.hint": "\u5168\u90E8\u8282\u70B9,\u4E0E\u4E8B\u4EF6\u65E5\u5FD7\u4E00\u4E00\u5BF9\u5E94",
21289
+ "zoom.hint": "Ctrl/\u2318 + \u6EDA\u8F6E\u7F29\u653E,\u62D6\u52A8\u5E73\u79FB,\u53CC\u51FB\u56DE\u5230\u6574\u4F53",
21290
+ "zoom.in": "\u653E\u5927",
21291
+ "zoom.out": "\u7F29\u5C0F",
21292
+ "zoom.fit": "\u9002\u914D",
21293
+ "live.running": "\u8FD0\u884C\u4E2D",
21294
+ "live.hint": "\u8BE5\u4F1A\u8BDD\u6B63\u5728\u4EA7\u751F\u65B0\u4E8B\u4EF6,\u6811\u4F1A\u81EA\u52A8\u5237\u65B0\u5E76\u6807\u51FA\u6700\u65B0\u4E00\u8F6E",
21295
+ "total.turns": "\u8F6E",
21296
+ "total.costHint": "\u6309\u6A21\u578B\u5B98\u65B9\u5355\u4EF7\u4F30\u7B97(\u9AD8\u5CF0/\u7A7A\u95F2\u65F6\u6BB5\u81EA\u52A8\u5207\u6362)",
21297
+ "cost.label": "\u6210\u672C",
21298
+ "cost.model": "\u6A21\u578B",
21299
+ "cost.peak": "\u6309\u9AD8\u5CF0\u65F6\u6BB5\u5355\u4EF7",
21300
+ "cost.offPeak": "\u6309\u7A7A\u95F2\u65F6\u6BB5\u5355\u4EF7",
21301
+ "export.svg": "SVG \u77E2\u91CF\u56FE",
21302
+ "export.png": "PNG \u56FE\u7247",
21200
21303
  "node.toolgroup": "\u5DE5\u5177\u6D3B\u52A8",
21201
21304
  "detail.grouped": "\u6298\u53E0\u7684\u5DE5\u5177\u8C03\u7528",
21202
21305
  "expand.short": "\u5C55\u5F00",
@@ -21313,13 +21416,29 @@ var MESSAGES = {
21313
21416
  "export.cancelled": "Cancelled",
21314
21417
  "export.failed": "Export failed",
21315
21418
  "warning": "Compatibility",
21419
+ "level.user": "Input",
21316
21420
  "level.compact": "Compact",
21317
21421
  "level.standard": "Standard",
21318
21422
  "level.full": "Full",
21319
- "level.hint": "Detail level",
21423
+ "level.hint": "Detail level (Input / Compact / Standard / Full)",
21424
+ "level.user.hint": "Turns, your messages and branch points only \u2014 the session as a requirement list",
21320
21425
  "level.compact.hint": "Turns, your messages, branch points, tool totals and each turn answer only",
21321
21426
  "level.standard.hint": "Adds substantive process text and every tool call (no tool output)",
21322
21427
  "level.full.hint": "Every node, one-to-one with the event log",
21428
+ "zoom.hint": "Ctrl/\u2318 + wheel to zoom, drag to pan, double-click to fit",
21429
+ "zoom.in": "Zoom in",
21430
+ "zoom.out": "Zoom out",
21431
+ "zoom.fit": "Fit",
21432
+ "live.running": "Live",
21433
+ "live.hint": "This session is producing new events; the tree refreshes and marks the latest turn",
21434
+ "total.turns": "turns",
21435
+ "total.costHint": "Estimated from official model prices (peak / off-peak aware)",
21436
+ "cost.label": "Cost",
21437
+ "cost.model": "Model",
21438
+ "cost.peak": "Priced at peak hour",
21439
+ "cost.offPeak": "Priced at off-peak hour",
21440
+ "export.svg": "SVG vector",
21441
+ "export.png": "PNG image",
21323
21442
  "node.toolgroup": "Tool activity",
21324
21443
  "detail.grouped": "Folded tool calls",
21325
21444
  "expand.short": "Expand",
@@ -21872,7 +21991,7 @@ function flattenFiltered(data, query) {
21872
21991
  for (const root of roots) walk(root, 0, null);
21873
21992
  return { rows, index, matches };
21874
21993
  }
21875
- function OutlineRow({ row, width, selected, highlighted, onSelect, onToggle, onJump, t, palette }) {
21994
+ function OutlineRow({ row, width, selected, highlighted, onSelect, onToggle, onJump, t, palette, costs, live }) {
21876
21995
  const { node: node2, depth, kidCount, collapsed } = row;
21877
21996
  const x = PAD_X + depth * INDENT;
21878
21997
  const railX = x + 7;
@@ -21885,7 +22004,10 @@ function OutlineRow({ row, width, selected, highlighted, onSelect, onToggle, onJ
21885
22004
  const shadowed = node2.surface === "shadowed";
21886
22005
  const isTurn = node2.kind === "turn";
21887
22006
  const usage = node2.usage;
21888
- const metaText = isTurn && usage ? `#${node2.seq >= 0 ? node2.seq : "\u2014"} \xB7 ${usage.messageCount ?? 0}\u6761${fmtTokens2(usage.total || usage.input + usage.output) ? ` \xB7 ${fmtTokens2(usage.total || usage.input + usage.output)} tok` : ""}` : `#${node2.seq >= 0 ? node2.seq : "\u2014"} ${t(`node.${node2.kind}`)}`;
22007
+ const cost = costs?.get(node2.id);
22008
+ const tokenText = isTurn && usage ? fmtTokens2(usage.total || usage.input + usage.output) : "";
22009
+ const costText = cost && cost.cny > 0 ? PRICING.formatCny(cost.cny) : "";
22010
+ const metaText = isTurn && usage ? `#${node2.seq >= 0 ? node2.seq : "\u2014"} \xB7 ${usage.messageCount ?? 0}\u6761${tokenText ? ` \xB7 ${tokenText} tok` : ""}${costText ? ` \xB7 ${costText}` : ""}` : `#${node2.seq >= 0 ? node2.seq : "\u2014"} ${t(`node.${node2.kind}`)}`;
21889
22011
  const metaWidth = measureText(metaText, 10.5) + 9;
21890
22012
  const metaX = rowWidth - 8 - metaWidth;
21891
22013
  let cursor = metaX;
@@ -21953,6 +22075,27 @@ ${usage.messageCount ?? 0} \u6761\u6D88\u606F \xB7 ${fmtTokens2(usage.input)} \u
21953
22075
  strokeWidth: 1.2,
21954
22076
  strokeDasharray: "5 3"
21955
22077
  }),
22078
+ // 实时高亮:正在跑的那一轮加虚线环 + 脉动圆点。
22079
+ live && h("rect", {
22080
+ x,
22081
+ y: 2,
22082
+ width: rowWidth,
22083
+ height: ROW_H - 6,
22084
+ rx: 6,
22085
+ ry: 6,
22086
+ fill: "none",
22087
+ stroke: "var(--mt-live, #ef4444)",
22088
+ strokeWidth: 1.4,
22089
+ strokeDasharray: "5 4",
22090
+ opacity: 0.9
22091
+ }),
22092
+ live && h("circle", {
22093
+ className: "mt-live-dot",
22094
+ cx: x + 4,
22095
+ cy: ROW_H / 2,
22096
+ r: 3.2,
22097
+ fill: "var(--mt-live, #ef4444)"
22098
+ }),
21956
22099
  kidCount > 0 && h(
21957
22100
  "g",
21958
22101
  {
@@ -22045,7 +22188,7 @@ ${usage.messageCount ?? 0} \u6761\u6D88\u606F \xB7 ${fmtTokens2(usage.input)} \u
22045
22188
  h("title", null, title)
22046
22189
  );
22047
22190
  }
22048
- function TreeCanvas({ data, collapsed, query, onToggle, onSelect, onJump, selectedId, t, width, palette }) {
22191
+ function TreeCanvas({ data, collapsed, query, onToggle, onSelect, onJump, selectedId, t, width, palette, costs, liveTurn, zoom, onZoom, svgRef }) {
22049
22192
  const searching = typeof query === "string" && query.trim() !== "";
22050
22193
  let rows;
22051
22194
  let index;
@@ -22104,12 +22247,86 @@ function TreeCanvas({ data, collapsed, query, onToggle, onSelect, onJump, select
22104
22247
  opacity: 0.7
22105
22248
  }));
22106
22249
  }
22250
+ const contentH = Math.max(140, height);
22251
+ const zooming = zoom && zoom.mode === "custom";
22252
+ const scale = zooming ? Math.min(4, Math.max(0.4, zoom.scale)) : 1;
22253
+ const viewBox = zooming ? `${zoom.x} ${zoom.y} ${canvasWidth / scale} ${contentH / scale}` : `0 0 ${canvasWidth} ${contentH}`;
22254
+ const dragRef = React.useRef(null);
22255
+ const svgElRef = React.useRef(null);
22256
+ const attachRef = (element) => {
22257
+ svgElRef.current = element;
22258
+ if (typeof svgRef === "function") svgRef(element);
22259
+ else if (svgRef) svgRef.current = element;
22260
+ };
22261
+ const localPoint = (event) => {
22262
+ const element = svgElRef.current;
22263
+ if (!element) return { fx: 0.5, fy: 0.5 };
22264
+ const rect = element.getBoundingClientRect();
22265
+ if (rect.width === 0 || rect.height === 0) return { fx: 0.5, fy: 0.5 };
22266
+ return {
22267
+ fx: (event.clientX - rect.left) / rect.width,
22268
+ fy: (event.clientY - rect.top) / rect.height
22269
+ };
22270
+ };
22271
+ const handleWheel = (event) => {
22272
+ if (!(event.ctrlKey || event.metaKey)) return;
22273
+ event.preventDefault();
22274
+ const { fx, fy } = localPoint(event);
22275
+ const direction = event.deltaY < 0 ? 1 : -1;
22276
+ const nextScale = Math.min(4, Math.max(0.4, scale * (direction > 0 ? 1.15 : 1 / 1.15)));
22277
+ const vbW = canvasWidth / scale;
22278
+ const vbH = contentH / scale;
22279
+ const baseX = zooming ? zoom.x : 0;
22280
+ const baseY = zooming ? zoom.y : 0;
22281
+ const anchorX = baseX + fx * vbW;
22282
+ const anchorY = baseY + fy * vbH;
22283
+ const nextW = canvasWidth / nextScale;
22284
+ const nextH = contentH / nextScale;
22285
+ onZoom?.({ mode: "custom", scale: nextScale, x: anchorX - fx * nextW, y: anchorY - fy * nextH });
22286
+ };
22287
+ const handlePointerDown = (event) => {
22288
+ if (!zooming) return;
22289
+ if (event.button !== 0 && event.button !== 1) return;
22290
+ dragRef.current = { startX: event.clientX, startY: event.clientY, originX: zoom.x, originY: zoom.y };
22291
+ if (typeof event.currentTarget.setPointerCapture === "function") {
22292
+ try {
22293
+ event.currentTarget.setPointerCapture(event.pointerId);
22294
+ } catch {
22295
+ }
22296
+ }
22297
+ };
22298
+ const handlePointerMove = (event) => {
22299
+ const drag = dragRef.current;
22300
+ if (!drag) return;
22301
+ const element = svgElRef.current;
22302
+ const rect = element?.getBoundingClientRect();
22303
+ if (!rect || rect.width === 0) return;
22304
+ const unitsPerPxX = canvasWidth / scale / rect.width;
22305
+ const unitsPerPxY = contentH / scale / rect.height;
22306
+ onZoom?.({
22307
+ mode: "custom",
22308
+ scale,
22309
+ x: drag.originX - (event.clientX - drag.startX) * unitsPerPxX,
22310
+ y: drag.originY - (event.clientY - drag.startY) * unitsPerPxY
22311
+ });
22312
+ };
22313
+ const handlePointerUp = () => {
22314
+ dragRef.current = null;
22315
+ };
22107
22316
  return h(
22108
22317
  "svg",
22109
22318
  {
22319
+ ref: attachRef,
22110
22320
  width: canvasWidth,
22111
- height: Math.max(140, height),
22112
- style: { display: "block" }
22321
+ height: contentH,
22322
+ viewBox,
22323
+ style: { display: "block", cursor: zooming ? "grab" : "default" },
22324
+ onWheel: handleWheel,
22325
+ onPointerDown: handlePointerDown,
22326
+ onPointerMove: handlePointerMove,
22327
+ onPointerUp: handlePointerUp,
22328
+ onPointerCancel: handlePointerUp,
22329
+ onDoubleClick: zooming ? () => onZoom?.({ mode: "fit", scale: 1, x: 0, y: 0 }) : void 0
22113
22330
  },
22114
22331
  h(
22115
22332
  "g",
@@ -22125,7 +22342,9 @@ function TreeCanvas({ data, collapsed, query, onToggle, onSelect, onJump, select
22125
22342
  onToggle,
22126
22343
  onJump,
22127
22344
  t,
22128
- palette
22345
+ palette,
22346
+ costs,
22347
+ live: liveTurn != null && row.node.kind === "turn" && row.node.turn === liveTurn
22129
22348
  })),
22130
22349
  searching && rows.length === 0 && h("text", {
22131
22350
  x: canvasWidth / 2,
@@ -22137,7 +22356,7 @@ function TreeCanvas({ data, collapsed, query, onToggle, onSelect, onJump, select
22137
22356
  )
22138
22357
  );
22139
22358
  }
22140
- function DetailPanel({ node: node2, detail, loadingDetail, onClose, onJump, onEvent, onOpenSession, onShowTree, onBack, canJump, showBack, t }) {
22359
+ function DetailPanel({ node: node2, detail, loadingDetail, onClose, onJump, onEvent, onOpenSession, onShowTree, onBack, canJump, showBack, t, cost }) {
22141
22360
  const branch = node2?.branch;
22142
22361
  return h(
22143
22362
  "div",
@@ -22169,6 +22388,15 @@ function DetailPanel({ node: node2, detail, loadingDetail, onClose, onJump, onEv
22169
22388
  node2.usage.messageCount != null && h("span", { className: "mt-chip" }, `${t("token.messages")} ${node2.usage.messageCount}`),
22170
22389
  (node2.usage.durationMs ?? 0) > 0 && h("span", { className: "mt-chip" }, `${t("token.duration")} ${fmtDuration(node2.usage.durationMs)}`)
22171
22390
  ),
22391
+ (node2?.usage?.model || cost) && h(
22392
+ "div",
22393
+ { className: "mt-detail-meta" },
22394
+ node2?.usage?.model && h("span", { className: "mt-chip" }, `${t("cost.model")} ${node2.usage.model}`),
22395
+ cost && h("span", {
22396
+ className: "mt-chip",
22397
+ title: cost.peak ? t("cost.peak") : t("cost.offPeak")
22398
+ }, `${t("cost.label")} ${cost.known === false ? "\u2248" : ""}${PRICING.formatCny(cost.cny)}`)
22399
+ ),
22172
22400
  branch && h(
22173
22401
  "div",
22174
22402
  { className: "mt-detail-branch" },
@@ -22395,6 +22623,11 @@ function MindTreeBodyInner({ ctx, sessionId, signal, uiWorkspace }) {
22395
22623
  if (exportNoticeTimer.current !== null) clearTimeout(exportNoticeTimer.current);
22396
22624
  }, []);
22397
22625
  const [exportMenuOpen, setExportMenuOpen] = React.useState(false);
22626
+ const [zoom, setZoom] = React.useState({ mode: "fit", scale: 1, x: 0, y: 0 });
22627
+ const svgRef = React.useRef(null);
22628
+ const [lastFrameAt, setLastFrameAt] = React.useState(0);
22629
+ const [liveNow, setLiveNow] = React.useState(false);
22630
+ const pendingRefresh = React.useRef(null);
22398
22631
  const exportMenuRef = React.useRef(null);
22399
22632
  const [palette, setPalette] = React.useState(() => loadPalette());
22400
22633
  const [paletteMenuOpen, setPaletteMenuOpen] = React.useState(false);
@@ -22440,12 +22673,15 @@ function MindTreeBodyInner({ ctx, sessionId, signal, uiWorkspace }) {
22440
22673
  return () => document.removeEventListener("pointerdown", close);
22441
22674
  }, [exportMenuOpen]);
22442
22675
  const activeSession = targetSession ?? sessionId;
22443
- const load = React.useCallback((mode, session, abort) => {
22676
+ const load = React.useCallback((mode, session, abort, options) => {
22444
22677
  if (!service) return;
22678
+ const silent = options?.silent === true;
22445
22679
  DIAG.loadAttempts += 1;
22446
22680
  const startedAt = Date.now();
22447
- setLoading(true);
22448
- setError(null);
22681
+ if (!silent) {
22682
+ setLoading(true);
22683
+ setError(null);
22684
+ }
22449
22685
  const call = mode === "tree" ? service.tree(session, abort?.signal ?? void 0) : mode === "lineage" ? service.lineage(session, abort?.signal ?? void 0) : service.forest(abort?.signal ?? void 0);
22450
22686
  withTimeout(Promise.resolve(call), FETCH_TIMEOUT_MS, `mindTree.${mode}`).then(unwrapResult).then((result) => {
22451
22687
  if (abort?.signal?.aborted) return;
@@ -22473,10 +22709,29 @@ function MindTreeBodyInner({ ctx, sessionId, signal, uiWorkspace }) {
22473
22709
  load(view, activeSession, controller);
22474
22710
  return () => controller.abort();
22475
22711
  }, [service, view, activeSession, tick]);
22712
+ const refreshRef = React.useRef(null);
22713
+ refreshRef.current = () => {
22714
+ const controller = new AbortController();
22715
+ load(view, activeSession, controller, { silent: true });
22716
+ };
22476
22717
  React.useEffect(() => {
22477
22718
  if (!service) return;
22478
22719
  const controller = new AbortController();
22479
22720
  let stopped = false;
22721
+ let timer = null;
22722
+ let dirty = false;
22723
+ const flush = () => {
22724
+ timer = null;
22725
+ if (stopped) return;
22726
+ if (!dirty) return;
22727
+ dirty = false;
22728
+ refreshRef.current?.();
22729
+ };
22730
+ const schedule = () => {
22731
+ setLastFrameAt(Date.now());
22732
+ dirty = true;
22733
+ if (timer === null) timer = setTimeout(flush, 900);
22734
+ };
22480
22735
  const run = async () => {
22481
22736
  let attempts = 0;
22482
22737
  while (!stopped && attempts < 12) {
@@ -22485,7 +22740,7 @@ function MindTreeBodyInner({ ctx, sessionId, signal, uiWorkspace }) {
22485
22740
  for await (const rawFrame of service.follow(controller.signal)) {
22486
22741
  if (stopped) return;
22487
22742
  const frame = rawFrame && typeof rawFrame === "object" && "ok" in rawFrame && rawFrame.ok === true ? rawFrame.value : rawFrame;
22488
- if (frame?.sessionId === activeSession) setTick((n) => n + 1);
22743
+ if (frame?.sessionId === activeSession) schedule();
22489
22744
  }
22490
22745
  return;
22491
22746
  } catch {
@@ -22497,9 +22752,16 @@ function MindTreeBodyInner({ ctx, sessionId, signal, uiWorkspace }) {
22497
22752
  run();
22498
22753
  return () => {
22499
22754
  stopped = true;
22755
+ if (timer !== null) clearTimeout(timer);
22500
22756
  controller.abort();
22501
22757
  };
22502
22758
  }, [service, activeSession]);
22759
+ React.useEffect(() => {
22760
+ if (!lastFrameAt) return void 0;
22761
+ setLiveNow(true);
22762
+ const timer = setTimeout(() => setLiveNow(false), 2500);
22763
+ return () => clearTimeout(timer);
22764
+ }, [lastFrameAt]);
22503
22765
  const rawData = view === "tree" ? treeData : view === "lineage" ? lineageData : forestData;
22504
22766
  const displayed = React.useMemo(() => view === "tree" ? rawData : sessionsAsDisplay(rawData), [view, rawData]);
22505
22767
  const data = React.useMemo(
@@ -22511,13 +22773,55 @@ function MindTreeBodyInner({ ctx, sessionId, signal, uiWorkspace }) {
22511
22773
  const nodeMap = React.useMemo(() => new Map(nodes.map((n) => [n.id, n])), [data]);
22512
22774
  const selectedNode = selected ? nodeMap.get(selected) : null;
22513
22775
  const canJump = activeSession === sessionId;
22776
+ const costs = React.useMemo(() => {
22777
+ const map2 = /* @__PURE__ */ new Map();
22778
+ for (const node2 of nodes) {
22779
+ if (node2.kind !== "turn" || !node2.usage) continue;
22780
+ map2.set(node2.id, PRICING.costOf(node2.usage, node2.usage.model, node2.usage.startedAt));
22781
+ }
22782
+ return map2;
22783
+ }, [data]);
22784
+ const totals = React.useMemo(() => {
22785
+ if (view !== "tree") return null;
22786
+ let turns = 0;
22787
+ let tokens = 0;
22788
+ let cny = 0;
22789
+ let unknown2 = false;
22790
+ for (const node2 of nodes) {
22791
+ if (node2.kind !== "turn" || !node2.usage) continue;
22792
+ turns += 1;
22793
+ tokens += Number(node2.usage.total || node2.usage.input + node2.usage.output) || 0;
22794
+ const cost = costs.get(node2.id);
22795
+ if (cost) {
22796
+ cny += cost.cny;
22797
+ if (cost.known === false) unknown2 = true;
22798
+ }
22799
+ }
22800
+ return turns > 0 ? { turns, tokens, cny, unknown: unknown2 } : null;
22801
+ }, [view, data, costs]);
22802
+ const liveTurn = React.useMemo(() => {
22803
+ if (!liveNow || view !== "tree") return null;
22804
+ let max = null;
22805
+ for (const node2 of nodes) {
22806
+ if (node2.kind !== "turn" || typeof node2.turn !== "number") continue;
22807
+ if (max === null || node2.turn > max) max = node2.turn;
22808
+ }
22809
+ return max;
22810
+ }, [liveNow, view, data]);
22514
22811
  const switchView = (next) => {
22515
22812
  setView(next);
22516
22813
  setSelected(null);
22517
22814
  setDetail(null);
22518
22815
  setQuery("");
22816
+ setZoom({ mode: "fit", scale: 1, x: 0, y: 0 });
22519
22817
  if (next === "tree") setTargetSession(null);
22520
22818
  };
22819
+ const zoomBy = (factor) => setZoom((current) => {
22820
+ const base = current.mode === "custom" ? current : { mode: "custom", scale: 1, x: 0, y: 0 };
22821
+ const nextScale = Math.min(4, Math.max(0.4, base.scale * factor));
22822
+ return { ...base, scale: nextScale };
22823
+ });
22824
+ const fitZoom = () => setZoom({ mode: "fit", scale: 1, x: 0, y: 0 });
22521
22825
  const fetchDetail = (node2) => {
22522
22826
  if (Array.isArray(node2.grouped) && node2.grouped.length > 0) {
22523
22827
  setLoadingDetail(false);
@@ -22676,7 +22980,8 @@ function MindTreeBodyInner({ ctx, sessionId, signal, uiWorkspace }) {
22676
22980
  title: `${t(`level.${level}`)} \u2014 ${t(`level.${level}.hint`)}`,
22677
22981
  onClick: () => setDetailLevel(level)
22678
22982
  }, t(`level.${level}`)));
22679
- const saveExport = async (content, fileName, mime, description, extensions) => {
22983
+ const saveExport = async (content, fileName, mime, description, extensions, options) => {
22984
+ const binary = options?.binary === true;
22680
22985
  if (typeof window !== "undefined" && typeof window.showSaveFilePicker === "function") {
22681
22986
  try {
22682
22987
  const handle = await window.showSaveFilePicker({
@@ -22698,7 +23003,7 @@ function MindTreeBodyInner({ ctx, sessionId, signal, uiWorkspace }) {
22698
23003
  DIAG.lastExport = `picker-failed:${error63?.message ?? error63}`;
22699
23004
  }
22700
23005
  }
22701
- const blob = new Blob([content], { type: `${mime};charset=utf-8` });
23006
+ const blob = content instanceof Blob ? content : new Blob([content], { type: binary ? mime : `${mime};charset=utf-8` });
22702
23007
  const url2 = URL.createObjectURL(blob);
22703
23008
  const anchor2 = document.createElement("a");
22704
23009
  anchor2.href = url2;
@@ -22710,6 +23015,68 @@ function MindTreeBodyInner({ ctx, sessionId, signal, uiWorkspace }) {
22710
23015
  DIAG.lastExport = "downloaded";
22711
23016
  setExportNotice(`${t("export.saved")}: ${fileName}`);
22712
23017
  };
23018
+ const imageBaseName = () => `mind-tree-${String(activeSession ?? "session").slice(0, 8)}`;
23019
+ const buildStandaloneSvg = () => {
23020
+ const element = svgRef.current;
23021
+ if (!element) throw new Error("\u753B\u5E03\u5C1A\u672A\u5C31\u7EEA");
23022
+ const clone2 = element.cloneNode(true);
23023
+ clone2.setAttribute("xmlns", "http://www.w3.org/2000/svg");
23024
+ const vars = COLOR_KINDS.map((key) => `${COLOR_VARS[key]}:${palette.colors[key]}`).join(";");
23025
+ const fontFamily = '-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif';
23026
+ clone2.setAttribute("style", `${vars};font-family:${fontFamily};background:#ffffff`);
23027
+ const width = Number(element.getAttribute("width")) || 800;
23028
+ const height = Number(element.getAttribute("height")) || 600;
23029
+ clone2.setAttribute("viewBox", `0 0 ${width} ${height}`);
23030
+ clone2.setAttribute("width", String(width));
23031
+ clone2.setAttribute("height", String(height));
23032
+ const background = document.createElementNS("http://www.w3.org/2000/svg", "rect");
23033
+ background.setAttribute("x", "0");
23034
+ background.setAttribute("y", "0");
23035
+ background.setAttribute("width", "100%");
23036
+ background.setAttribute("height", "100%");
23037
+ background.setAttribute("fill", "#ffffff");
23038
+ clone2.insertBefore(background, clone2.firstChild);
23039
+ return { text: new XMLSerializer().serializeToString(clone2), width, height };
23040
+ };
23041
+ const exportImage = async (kind) => {
23042
+ try {
23043
+ const { text, width, height } = buildStandaloneSvg();
23044
+ if (kind === "svg") {
23045
+ await saveExport(text, `${imageBaseName()}.svg`, "image/svg+xml", "SVG \u77E2\u91CF\u56FE", [".svg"]);
23046
+ return;
23047
+ }
23048
+ const factor = Math.max(0.1, Math.min(2, 32e3 / Math.max(1, height)));
23049
+ const svgBlob = new Blob([text], { type: "image/svg+xml;charset=utf-8" });
23050
+ const url2 = URL.createObjectURL(svgBlob);
23051
+ let image;
23052
+ try {
23053
+ image = await new Promise((resolve, reject) => {
23054
+ const element = new Image();
23055
+ element.onload = () => resolve(element);
23056
+ element.onerror = () => reject(new Error("SVG \u89E3\u7801\u5931\u8D25"));
23057
+ element.src = url2;
23058
+ });
23059
+ } finally {
23060
+ setTimeout(() => URL.revokeObjectURL(url2), 5e3);
23061
+ }
23062
+ const canvas = document.createElement("canvas");
23063
+ canvas.width = Math.max(1, Math.round(width * factor));
23064
+ canvas.height = Math.max(1, Math.round(height * factor));
23065
+ const context = typeof canvas.getContext === "function" ? canvas.getContext("2d") : null;
23066
+ if (!context) throw new Error("\u5F53\u524D\u73AF\u5883\u4E0D\u652F\u6301 canvas");
23067
+ context.fillStyle = "#ffffff";
23068
+ context.fillRect(0, 0, canvas.width, canvas.height);
23069
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
23070
+ const blob = await new Promise((resolve) => {
23071
+ canvas.toBlob((result) => resolve(result), "image/png");
23072
+ });
23073
+ if (!blob) throw new Error("PNG \u751F\u6210\u5931\u8D25");
23074
+ await saveExport(blob, `${imageBaseName()}.png`, "image/png", "PNG \u56FE\u7247", [".png"], { binary: true });
23075
+ } catch (error63) {
23076
+ DIAG.lastExport = `image-failed:${error63?.message ?? error63}`;
23077
+ setExportNotice(`${t("export.failed")}: ${error63?.message ?? error63}`);
23078
+ }
23079
+ };
22713
23080
  const exportAs = async (format) => {
22714
23081
  try {
22715
23082
  if (displayed == null || (displayed.nodes ?? []).length === 0) return;
@@ -22739,7 +23106,19 @@ function MindTreeBodyInner({ ctx, sessionId, signal, uiWorkspace }) {
22739
23106
  className: `mt-status-chip ${failed ? "mt-status-chip-bad" : service ? "mt-status-chip-ok" : ""}`,
22740
23107
  title: failed ? String(DIAG.lastError ?? "") : ""
22741
23108
  }, failed ? t("status.failed") : service ? t("status.ready") : t("status.connecting")),
23109
+ liveNow && h("span", { className: "mt-live-chip", title: t("live.hint") }, `\u25CF ${t("live.running")}`),
22742
23110
  h("span", { className: "mt-level-group", title: t("level.hint") }, levelButtons),
23111
+ h(
23112
+ "span",
23113
+ { className: "mt-zoom-group", title: t("zoom.hint") },
23114
+ h("button", { className: "mt-btn mt-zoom-btn", onClick: () => zoomBy(1 / 1.25), title: t("zoom.out") }, "\u2212"),
23115
+ h("button", {
23116
+ className: `mt-btn mt-zoom-btn ${zoom.mode === "custom" ? "mt-btn-on" : ""}`,
23117
+ onClick: fitZoom,
23118
+ title: t("zoom.fit")
23119
+ }, zoom.mode === "custom" ? `${Math.round(zoom.scale * 100)}%` : t("zoom.fit")),
23120
+ h("button", { className: "mt-btn mt-zoom-btn", onClick: () => zoomBy(1.25), title: t("zoom.in") }, "+")
23121
+ ),
22743
23122
  h("button", { className: "mt-btn", onClick: expandAll, title: t("expandAll") }, t("expand.short")),
22744
23123
  h("button", { className: "mt-btn", onClick: collapseAll, title: t("collapseAll") }, t("collapse.short")),
22745
23124
  h(
@@ -22828,6 +23207,21 @@ function MindTreeBodyInner({ ctx, sessionId, signal, uiWorkspace }) {
22828
23207
  exportAs("canvas");
22829
23208
  }
22830
23209
  }, t("export.canvas")),
23210
+ h("div", { className: "mt-menu-divider" }),
23211
+ h("button", {
23212
+ className: "mt-menu-item",
23213
+ onClick: () => {
23214
+ setExportMenuOpen(false);
23215
+ exportImage("svg");
23216
+ }
23217
+ }, t("export.svg")),
23218
+ h("button", {
23219
+ className: "mt-menu-item",
23220
+ onClick: () => {
23221
+ setExportMenuOpen(false);
23222
+ exportImage("png");
23223
+ }
23224
+ }, t("export.png")),
22831
23225
  h("div", { className: "mt-menu-hint" }, t("export.hint"))
22832
23226
  )
22833
23227
  ),
@@ -22851,6 +23245,17 @@ function MindTreeBodyInner({ ctx, sessionId, signal, uiWorkspace }) {
22851
23245
  }),
22852
23246
  query && h("button", { className: "mt-btn", onClick: () => setQuery("") }, "\xD7")
22853
23247
  ),
23248
+ // 合计条:轮次 / token / 估算成本(单价随高峰-空闲时段自动切换)。
23249
+ totals && h(
23250
+ "div",
23251
+ { className: "mt-total" },
23252
+ h("span", { className: "mt-total-item" }, `${totals.turns} ${t("total.turns")}`),
23253
+ h("span", { className: "mt-total-item" }, `${fmtTokens2(totals.tokens)} tok`),
23254
+ h("span", {
23255
+ className: "mt-total-item mt-total-cost",
23256
+ title: `${t("total.costHint")} (${PRICING.PRICING_VERSION})`
23257
+ }, `${totals.unknown ? "\u2248" : ""}${PRICING.formatCny(totals.cny)}`)
23258
+ ),
22854
23259
  activeSession && activeSession !== sessionId && h(
22855
23260
  "div",
22856
23261
  { className: "mt-breadcrumb" },
@@ -22919,7 +23324,12 @@ function MindTreeBodyInner({ ctx, sessionId, signal, uiWorkspace }) {
22919
23324
  },
22920
23325
  selectedId: selected,
22921
23326
  t,
22922
- width: canvasWidth
23327
+ width: canvasWidth,
23328
+ costs,
23329
+ liveTurn,
23330
+ zoom,
23331
+ onZoom: setZoom,
23332
+ svgRef
22923
23333
  }),
22924
23334
  data != null && nodes.length > 0 && h("div", { className: "mt-hint" }, t("click.hint"))
22925
23335
  ),
@@ -22927,6 +23337,7 @@ function MindTreeBodyInner({ ctx, sessionId, signal, uiWorkspace }) {
22927
23337
  node: selectedNode,
22928
23338
  detail,
22929
23339
  loadingDetail,
23340
+ cost: costs.get(selectedNode.id),
22930
23341
  onClose: () => {
22931
23342
  setSelected(null);
22932
23343
  setDetail(null);
@@ -22992,6 +23403,16 @@ module.exports = {
22992
23403
  .mt-warn{margin:4px 8px 0;padding:5px 8px;border-radius:6px;font-size:10.5px;line-height:1.5;color:var(--dsw-alias-state-warning-primary,#b45309);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary,#b45309) 12%, transparent);border:1px solid color-mix(in srgb, var(--dsw-alias-state-warning-primary,#b45309) 35%, transparent)}
22993
23404
  .mt-warn-title{font-weight:600}
22994
23405
  .mt-level-group{display:inline-flex;flex:0 0 auto;gap:2px;padding:1px;border-radius:7px;background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04))}
23406
+ .mt-zoom-group{display:inline-flex;flex:0 0 auto;gap:2px;padding:1px;border-radius:7px;background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04))}
23407
+ .mt-zoom-btn{padding:3px 6px;font-size:10.5px;min-width:24px;justify-content:center}
23408
+ .mt-zoom-btn.mt-btn-on{background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 1px 2px rgba(0,0,0,.12)}
23409
+ .mt-live-chip{display:inline-flex;align-items:center;gap:3px;flex:0 0 auto;padding:2px 7px;border-radius:99px;font-size:10px;font-weight:600;color:#fff;background:var(--mt-live,#ef4444);animation:mt-live-breathe 1.6s ease-in-out infinite}
23410
+ @keyframes mt-live-breathe{0%,100%{opacity:.65}50%{opacity:1}}
23411
+ .mt-live-dot{animation:mt-live-breathe 1.3s ease-in-out infinite}
23412
+ .mt-total{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:3px 8px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.08));font-size:10.5px;color:var(--dsw-alias-label-secondary,#61666b);font-variant-numeric:tabular-nums}
23413
+ .mt-total-item{display:inline-flex;align-items:center;padding:1px 6px;border-radius:99px;background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04))}
23414
+ .mt-total-cost{font-weight:600;color:var(--dsw-alias-label-primary,#111827)}
23415
+ .mt-menu-divider{height:1px;margin:3px 6px;background:var(--dsw-alias-border-l1,rgba(0,0,0,.08))}
22995
23416
  .mt-btn-level{padding:3px 6px;font-size:10.5px;opacity:.75}
22996
23417
  .mt-btn-level.mt-btn-on{opacity:1;background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 1px 2px rgba(0,0,0,.12)}
22997
23418
  .mt-menu-wide{min-width:250px;max-height:70vh;overflow:auto}
package/lib/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // 另注册 mind_tree 工具,让终端/GUI 里的 agent 也能直接导出思维树(不依赖网页客户端)。
5
5
  import { buildExport } from './format.js';
6
6
  import { reduceGraph, DETAIL_LEVELS } from './reduce.js';
7
+ import { costOfTree, formatCny } from './pricing.js';
7
8
 
8
9
  export const name = 'mindTree';
9
10
  export const inject = ['sessionQuery'];
@@ -361,6 +362,7 @@ export function apply(ctx) {
361
362
  const turnAssistantText = new Map(); // turn -> 该轮首条中文助手文本(回退)
362
363
  const turnUsage = new Map(); // turn -> 该轮 token 消耗聚合
363
364
  const turnTimes = new Map(); // turn -> {start, end}
365
+ const turnModel = new Map(); // turn -> 该轮实际使用的模型(用于成本估算)
364
366
  let nodeSeqCounter = 0;
365
367
 
366
368
  const pushNode = (node) => {
@@ -436,7 +438,12 @@ export function apply(ctx) {
436
438
  turnAssistantText.set(currentTurn, source);
437
439
  }
438
440
  }
439
- if (role === 'assistant') addTurnUsage(currentTurn, data.usage);
441
+ if (role === 'assistant') {
442
+ addTurnUsage(currentTurn, data.usage);
443
+ // 记录该轮使用的模型(成本估算按模型单价折算)。
444
+ const modelId = message?.source?.model ?? data?.source?.model;
445
+ if (typeof modelId === 'string' && modelId && currentTurn !== null) turnModel.set(currentTurn, modelId);
446
+ }
440
447
  const baseLabel = squash(text, 160) || (role === 'user' ? '用户消息' : role === 'assistant' ? '助手回复' : role);
441
448
  const node = {
442
449
  id: `n${seq}`, seq, type: event.type, kind,
@@ -680,6 +687,8 @@ export function apply(ctx) {
680
687
  cacheWrite: usage.cacheWrite,
681
688
  reasoning: usage.reasoning,
682
689
  messageCount: usage.messageCount,
690
+ ...(turnModel.has(node.turn) ? { model: turnModel.get(node.turn) } : {}),
691
+ ...(times?.start !== undefined ? { startedAt: times.start } : {}),
683
692
  ...(times?.start !== undefined && times?.end !== undefined && times.end > times.start
684
693
  ? { durationMs: times.end - times.start }
685
694
  : {}),
@@ -901,8 +910,8 @@ export function apply(ctx) {
901
910
  },
902
911
  level: {
903
912
  type: 'string',
904
- enum: ['compact', 'standard', 'full'],
905
- description: '过程信息量:compact(默认,只留轮次/用户发言/分支点/工具活动合计/每轮答复)、standard(加过程文本与工具明细)、full(全量)。',
913
+ enum: ['user', 'compact', 'standard', 'full'],
914
+ description: '过程信息量:user(只留轮次/你的发言/分支点,可当需求清单)、compact(默认,另加工具活动合计与每轮答复)、standard(加过程文本与工具明细)、full(全量)。',
906
915
  },
907
916
  },
908
917
  },
@@ -958,6 +967,11 @@ export function apply(ctx) {
958
967
  const keptCount = (reduced?.nodes ?? []).length;
959
968
  const dropped = nodeCount - keptCount;
960
969
  const levelNote = dropped > 0 ? `,已按 ${level} 档削减 ${dropped} 个过程节点` : '';
970
+ // 成本估算:只在"会话思维树"上有意义(轮次带 usage/model)。
971
+ const cost = view === 'tree' ? costOfTree(displayed) : null;
972
+ const costNote = cost && cost.cny > 0
973
+ ? `,估算成本 ${cost.anyUnknown ? '≈' : ''}${formatCny(cost.cny)}`
974
+ : '';
961
975
  const writeTo = typeof args?.writeTo === 'string' ? args.writeTo.trim() : '';
962
976
  if (writeTo !== '') {
963
977
  const fs = ctx.get('fs');
@@ -968,7 +982,7 @@ export function apply(ctx) {
968
982
  await fs.writeText(target, exported.content);
969
983
  const displayPath = typeof fs.processPath === 'function' ? fs.processPath(target) : writeTo;
970
984
  return {
971
- summary: `已导出 ${exported.format} 到 ${displayPath}(${exported.content.length} 字节,${turns} 轮 / ${keptCount} 节点${levelNote})`,
985
+ summary: `已导出 ${exported.format} 到 ${displayPath}(${exported.content.length} 字节,${turns} 轮 / ${keptCount} 节点${levelNote}${costNote})`,
972
986
  format: exported.format,
973
987
  sessionId: String(sessionId ?? 'forest'),
974
988
  fileName: exported.fileName,
@@ -983,7 +997,7 @@ export function apply(ctx) {
983
997
  ? `${exported.content.slice(0, TOOL_CONTENT_LIMIT)}\n\n…(内容超过 ${TOOL_CONTENT_LIMIT} 字符已截断;完整内容请用 writeTo 写入文件)`
984
998
  : exported.content;
985
999
  return {
986
- summary: `已生成 ${exported.format}(${exported.content.length} 字节,${turns} 轮 / ${keptCount} 节点${levelNote}${truncated ? ',已截断' : ''})`,
1000
+ summary: `已生成 ${exported.format}(${exported.content.length} 字节,${turns} 轮 / ${keptCount} 节点${levelNote}${costNote}${truncated ? ',已截断' : ''})`,
987
1001
  format: exported.format,
988
1002
  sessionId: String(sessionId ?? 'forest'),
989
1003
  fileName: exported.fileName,
package/lib/pricing.js ADDED
@@ -0,0 +1,99 @@
1
+ // 成本估算层:把 token 消耗折算成人民币。
2
+ // 单价来源:DeepSeek 官方「模型 & 价格」页(抓取于 2026-11-15),单位:元 / 百万 tokens。
3
+ // 高峰时段:北京时间 周一至周五(不含法定节假日)9:00–12:00、14:00–18:00;其余为**空闲时段**,价格减半。
4
+ // 说明:法定节假日无法从本地推断,此处只按"工作日 + 时段"近似。
5
+ //
6
+ // 纯函数、无依赖,客户端(界面显示)与宿主(mind_tree 工具)共用。
7
+
8
+ /** 单价表版本标记(改了数字就更新它,便于界面/文档追溯)。 */
9
+ export const PRICING_VERSION = '2026-11-15';
10
+
11
+ /**
12
+ * 每百万 token 单价(元)。每个字段是 [空闲时段, 高峰时段]。
13
+ * hit = 缓存命中输入;miss = 缓存未命中输入;out = 输出。
14
+ */
15
+ export const MODEL_PRICES = {
16
+ 'deepseek-flash': { hit: [0.02, 0.04], miss: [1, 2], out: [4, 8] },
17
+ 'deepseek-v4-flash': { hit: [0.02, 0.04], miss: [1, 2], out: [4, 8] },
18
+ 'deepseek-v4-flash-vision-exp': { hit: [0.02, 0.04], miss: [1, 2], out: [4, 8] },
19
+ 'deepseek-v4-pro': { hit: [0.15, 0.3], miss: [4.5, 9], out: [13.5, 27] },
20
+ };
21
+
22
+ /** 未知模型时的兜底单价(按 flash 计,界面用 ≈ 标注)。 */
23
+ export const FALLBACK_PRICE = MODEL_PRICES['deepseek-flash'];
24
+
25
+ const BJ_OFFSET_MS = 8 * 3600 * 1000;
26
+
27
+ /** 是否处于高峰时段(按北京时间近似判断,忽略法定节假日)。 */
28
+ export function isPeakHour(atMs) {
29
+ const at = typeof atMs === 'number' && Number.isFinite(atMs) ? atMs : Date.now();
30
+ const shifted = new Date(at + BJ_OFFSET_MS);
31
+ const day = shifted.getUTCDay(); // 0=周日 … 6=周六
32
+ const minutes = shifted.getUTCHours() * 60 + shifted.getUTCMinutes();
33
+ const weekday = day >= 1 && day <= 5;
34
+ const inMorning = minutes >= 9 * 60 && minutes < 12 * 60;
35
+ const inAfternoon = minutes >= 14 * 60 && minutes < 18 * 60;
36
+ return weekday && (inMorning || inAfternoon);
37
+ }
38
+
39
+ /** 取某个模型在某时段的单价三元组。 */
40
+ export function priceOf(model, atMs) {
41
+ const table = MODEL_PRICES[model] ?? FALLBACK_PRICE;
42
+ const slot = isPeakHour(atMs) ? 1 : 0;
43
+ return {
44
+ model: MODEL_PRICES[model] ? model : null,
45
+ peak: slot === 1,
46
+ hit: table.hit[slot],
47
+ miss: table.miss[slot],
48
+ out: table.out[slot],
49
+ };
50
+ }
51
+
52
+ /**
53
+ * 一次 token 消耗折算成元。
54
+ * @param usage - { input, output, cacheRead, cacheWrite }(单位 token;input 为**未命中缓存**部分)
55
+ * @param model - 模型 id,如 deepseek-v4-pro;未知模型按兜底价并标记 known=false
56
+ * @param atMs - 该次调用时间(毫秒),用于判断高峰/空闲;缺省按当前时间
57
+ * @returns { cny, peak, model, known }
58
+ */
59
+ export function costOf(usage, model, atMs) {
60
+ const input = Math.max(0, Number(usage?.input) || 0);
61
+ const output = Math.max(0, Number(usage?.output) || 0);
62
+ const cacheRead = Math.max(0, Number(usage?.cacheRead) || 0);
63
+ const cacheWrite = Math.max(0, Number(usage?.cacheWrite) || 0);
64
+ const price = priceOf(model, atMs);
65
+ // 缓存写入按"未命中输入"计费;缓存读取按"命中输入"计费。
66
+ const cny = ((input + cacheWrite) * price.miss + cacheRead * price.hit + output * price.out) / 1_000_000;
67
+ return { cny, peak: price.peak, model: price.model, known: price.model !== null };
68
+ }
69
+
70
+ /** 汇总多份成本。 */
71
+ export function sumCost(entries) {
72
+ let cny = 0;
73
+ let anyUnknown = false;
74
+ for (const entry of entries ?? []) {
75
+ if (!entry) continue;
76
+ cny += Number(entry.cny) || 0;
77
+ if (entry.known === false) anyUnknown = true;
78
+ }
79
+ return { cny, anyUnknown };
80
+ }
81
+
82
+ /** 人民币显示:小于 1 分显示 <¥0.01,其余两位小数。 */
83
+ export function formatCny(value) {
84
+ const cny = Number(value) || 0;
85
+ if (cny <= 0) return '¥0';
86
+ if (cny < 0.005) return '<¥0.01';
87
+ if (cny < 1) return `¥${cny.toFixed(3)}`;
88
+ return `¥${cny.toFixed(2)}`;
89
+ }
90
+
91
+ /** 从整棵树聚合成本(按轮 usage + 模型)。 */
92
+ export function costOfTree(tree) {
93
+ const entries = [];
94
+ for (const node of tree?.nodes ?? []) {
95
+ if (node?.kind !== 'turn' || !node.usage) continue;
96
+ entries.push(costOf(node.usage, node.usage.model, node.usage.startedAt));
97
+ }
98
+ return sumCost(entries);
99
+ }
package/lib/reduce.js CHANGED
@@ -4,12 +4,13 @@
4
4
  import { buildHierarchy, isInjectedLabel } from './format.js';
5
5
 
6
6
  /**
7
- * 三档信息量:
7
+ * 四档信息量(由轻到重):
8
+ * - user:只留 轮次 + 你的真实发言 + 分支节点 —— 相当于"把整场对话压成需求清单",可直接喂给别的模型;
8
9
  * - compact (默认):只留 轮次 + 用户真实发言 + 分支节点 + 每轮工具活动聚合 1 行 + 每轮最终助手回复;
9
10
  * - standard:在 compact 基础上保留实质性助手过程文本(≥ MIN_ASSISTANT_STANDARD 字)与工具明细行,去掉工具结果;
10
11
  * - full:不做任何削减(与事件日志一一对应)。
11
12
  */
12
- export const DETAIL_LEVELS = ['compact', 'standard', 'full'];
13
+ export const DETAIL_LEVELS = ['user', 'compact', 'standard', 'full'];
13
14
 
14
15
  /** standard 档保留的助手文本最小字数。 */
15
16
  export const MIN_ASSISTANT_STANDARD = 60;
@@ -50,6 +51,7 @@ export function reduceGraph(tree, level = 'compact') {
50
51
  if (level === 'full') return tree;
51
52
  const nodes = tree.nodes ?? [];
52
53
  const keepIds = new Set();
54
+ const isUserOnly = level === 'user';
53
55
  const isCompact = level === 'compact';
54
56
  const minAssistant = isCompact ? Number.POSITIVE_INFINITY : MIN_ASSISTANT_STANDARD;
55
57
  const lastOfTurn = lastAssistantIds(nodes);
@@ -66,13 +68,15 @@ export function reduceGraph(tree, level = 'compact') {
66
68
  continue; // 工具输出:折叠掉(可在工具行详情中查看)
67
69
  case 'info':
68
70
  continue; // system / developer 注入内容
69
- case 'user':
70
- if (isInjectedLabel(node.label)) continue; // 模型切换、运行时上下文、后台任务通知
71
- break;
72
71
  case 'assistant':
72
+ if (isUserOnly) continue; // 「输入」档完全不要助手文本
73
73
  if (!(lastOfTurn.has(node.id) || charLength(node.label) >= minAssistant)) continue;
74
74
  break;
75
+ case 'user':
76
+ if (isInjectedLabel(node.label)) continue; // 模型切换、运行时上下文、后台任务通知
77
+ break;
75
78
  case 'tool': {
79
+ if (isUserOnly) continue; // 「输入」档不要工具行(等同于"需求清单")
76
80
  if (!isCompact) break; // standard 保留工具明细行
77
81
  const key = node.turn ?? -1;
78
82
  if (!groupBuckets.has(key)) groupBuckets.set(key, []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-mind-tree",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "遍历对话上下文,识别分支节点并渲染思维树/会话树的 DeepSeek Harness 插件(右侧栏 GUI + mind_tree 工具 + Obsidian 导出)。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -17,9 +17,15 @@
17
17
  "./typert": "./lib/typert.host.js",
18
18
  "./remote": "./lib/remote.js",
19
19
  "./cordis.patch.yml": "./cordis.patch.yml",
20
- "./package.json": "./package.json"
20
+ "./package.json": "./package.json",
21
+ "./pricing": "./lib/pricing.js"
21
22
  },
22
- "files": ["lib", "cordis.patch.yml", "README.md", "LICENSE"],
23
+ "files": [
24
+ "lib",
25
+ "cordis.patch.yml",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
23
29
  "repository": {
24
30
  "type": "git",
25
31
  "url": "git+https://github.com/ShOpenMind/dsh-plugin-mind-tree.git"
@@ -28,7 +34,17 @@
28
34
  "bugs": {
29
35
  "url": "https://github.com/ShOpenMind/dsh-plugin-mind-tree/issues"
30
36
  },
31
- "keywords": ["dsh", "deepseek-harness", "plugin", "mindmap", "session-tree", "observability", "dsh-plugin", "dsh-bundle", "dsh-market"],
37
+ "keywords": [
38
+ "dsh",
39
+ "deepseek-harness",
40
+ "plugin",
41
+ "mindmap",
42
+ "session-tree",
43
+ "observability",
44
+ "dsh-plugin",
45
+ "dsh-bundle",
46
+ "dsh-market"
47
+ ],
32
48
  "dependencies": {
33
49
  "zod": "^4.4.0"
34
50
  },
@@ -39,17 +55,6 @@
39
55
  "react": "^18.3.1",
40
56
  "react-dom": "^18.3.1"
41
57
  },
42
- "scripts": {
43
- "build": "node tools/build.mjs",
44
- "test": "node tools/run-all.mjs",
45
- "test:host": "node tools/host-test.mjs",
46
- "test:reduce": "node tools/reduce-test.mjs",
47
- "test:client": "node tools/client-test.mjs",
48
- "test:mount": "node tools/mount-test.mjs",
49
- "test:render": "node tools/render-test.mjs",
50
- "test:mermaid": "node tools/mermaid-check.mjs",
51
- "test:integration": "node tools/integration-test.mjs"
52
- },
53
58
  "peerDependencies": {
54
59
  "@deepseek-ai/dsh": ">=0.2.0-rc.2 <0.3.0"
55
60
  },
@@ -58,9 +63,13 @@
58
63
  "optional": true
59
64
  }
60
65
  },
61
- "engines": { "node": ">=22" },
66
+ "engines": {
67
+ "node": ">=22"
68
+ },
62
69
  "dsh": {
63
- "bundle": { "patch": "./cordis.patch.yml" },
70
+ "bundle": {
71
+ "patch": "./cordis.patch.yml"
72
+ },
64
73
  "client": {
65
74
  "platform": "web",
66
75
  "inject": [
@@ -88,5 +97,16 @@
88
97
  "@deepseek-ai/dsh-client-ui-workspace"
89
98
  ]
90
99
  }
100
+ },
101
+ "scripts": {
102
+ "build": "node tools/build.mjs",
103
+ "test": "node tools/run-all.mjs",
104
+ "test:host": "node tools/host-test.mjs",
105
+ "test:reduce": "node tools/reduce-test.mjs",
106
+ "test:client": "node tools/client-test.mjs",
107
+ "test:mount": "node tools/mount-test.mjs",
108
+ "test:render": "node tools/render-test.mjs",
109
+ "test:mermaid": "node tools/mermaid-check.mjs",
110
+ "test:integration": "node tools/integration-test.mjs"
91
111
  }
92
- }
112
+ }