dsh-dock 0.7.0 → 0.8.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.
@@ -0,0 +1,299 @@
1
+ /* 趣味游戏 · 象棋核心规则(纯函数,无 React)——供组件复用,也便于单测。 */
2
+ export const ROWS = 10, COLS = 9;
3
+ // 类型:g 将/帅 · a 仕/士 · e 相/象 · h 马 · k 车 · c 炮 · p 兵/卒
4
+ // 颜色:r 红(下)· b 黑(上)
5
+
6
+ export function pieceChar(p) {
7
+ const map = {
8
+ g: { r: "帥", b: "將" }, a: { r: "仕", b: "士" }, e: { r: "相", b: "象" },
9
+ h: { r: "馬", b: "馬" }, k: { r: "車", b: "車" }, c: { r: "炮", b: "炮" },
10
+ p: { r: "兵", b: "卒" },
11
+ };
12
+ return map[p.t] && map[p.t][p.c];
13
+ }
14
+ export const VALUE = { g: 100000, k: 900, c: 450, h: 400, e: 200, a: 200, p: 100 };
15
+
16
+ export function initialBoard() {
17
+ const b = Array.from({ length: ROWS }, () => Array(COLS).fill(null));
18
+ const back = ["k", "h", "e", "a", "g", "a", "e", "h", "k"];
19
+ back.forEach((t, c) => { b[0][c] = { t, c: "b" }; });
20
+ b[2][1] = { t: "c", c: "b" }; b[2][7] = { t: "c", c: "b" };
21
+ // 黑卒在己方第三横线(row 3,紧邻楚河汉界一侧留一行间隔)。
22
+ [0, 2, 4, 6, 8].forEach((c) => { b[3][c] = { t: "p", c: "b" }; });
23
+ back.forEach((t, c) => { b[9][c] = { t, c: "r" }; });
24
+ b[7][1] = { t: "c", c: "r" }; b[7][7] = { t: "c", c: "r" };
25
+ // 红兵与红炮和黑方对称(row 6 / row 7)。
26
+ [0, 2, 4, 6, 8].forEach((c) => { b[6][c] = { t: "p", c: "r" }; });
27
+ return b;
28
+ }
29
+ export function cloneBoard(b) {
30
+ return b.map((row) => row.slice());
31
+ }
32
+ // 局面指纹:用于识别重复局面(防止 AI 无限循环将军)。
33
+ export function posKey(board) {
34
+ let s = "";
35
+ for (const row of board) for (const p of row) s += p ? p.t + p.c : ".";
36
+ return s;
37
+ }
38
+ function inBounds(r, c) { return r >= 0 && r < ROWS && c >= 0 && c < COLS; }
39
+ function inPalace(r, c, color) {
40
+ if (c < 3 || c > 5) return false;
41
+ return color === "r" ? r >= 7 && r <= 9 : r >= 0 && r <= 2;
42
+ }
43
+ function ownSide(r, color) { return color === "r" ? r >= 5 : r <= 4; }
44
+ function crossedRiver(r, color) { return color === "r" ? r <= 4 : r >= 5; }
45
+ function countBetween(board, r, c, tr, tc, dr, dc) {
46
+ let n = 0, rr = r + dr, cc = c + dc;
47
+ while (!(rr === tr && cc === tc)) {
48
+ if (rr < 0 || rr >= ROWS || cc < 0 || cc >= COLS) return -1;
49
+ if (board[rr][cc]) n += 1;
50
+ rr += dr; cc += dc;
51
+ }
52
+ return n;
53
+ }
54
+
55
+ export function attacks(board, r, c, tr, tc) {
56
+ const p = board[r][c];
57
+ if (!p) return false;
58
+ const dr = Math.sign(tr - r), dc = Math.sign(tc - c);
59
+ const t = board[tr][tc];
60
+ if (p.t === "k") {
61
+ if (r !== tr && c !== tc) return false;
62
+ return countBetween(board, r, c, tr, tc, dr, dc) === 0;
63
+ }
64
+ if (p.t === "c") {
65
+ if (r !== tr && c !== tc) return false;
66
+ if (dr === 0 && dc === 0) return false;
67
+ return countBetween(board, r, c, tr, tc, dr, dc) === 1 && !!t;
68
+ }
69
+ if (p.t === "h") {
70
+ const adr = Math.abs(tr - r), adc = Math.abs(tc - c);
71
+ if (!((adr === 2 && adc === 1) || (adr === 1 && adc === 2))) return false;
72
+ const legR = adr === 2 ? r + dr : r;
73
+ const legC = adc === 2 ? c + dc : c;
74
+ return !board[legR][legC];
75
+ }
76
+ if (p.t === "g") {
77
+ return Math.abs(tr - r) + Math.abs(tc - c) === 1 && inPalace(tr, tc, p.c);
78
+ }
79
+ if (p.t === "a") {
80
+ return Math.abs(tr - r) === 1 && Math.abs(tc - c) === 1 && inPalace(tr, tc, p.c);
81
+ }
82
+ if (p.t === "e") {
83
+ const adr = Math.abs(tr - r), adc = Math.abs(tc - c);
84
+ if (!(adr === 2 && adc === 2)) return false;
85
+ if (!ownSide(tr, p.c)) return false;
86
+ const eyeR = r + dr, eyeC = c + dc;
87
+ return !board[eyeR][eyeC];
88
+ }
89
+ if (p.t === "p") {
90
+ const forward = p.c === "r" ? r - 1 : r + 1;
91
+ if (tr === forward && tc === c) return true;
92
+ if (crossedRiver(r, p.c) && tr === r && Math.abs(tc - c) === 1) return true;
93
+ return false;
94
+ }
95
+ return false;
96
+ }
97
+
98
+ export function findGeneral(board, color) {
99
+ for (let r = 0; r < ROWS; r += 1) {
100
+ for (let c = 0; c < COLS; c += 1) {
101
+ const p = board[r][c];
102
+ if (p && p.t === "g" && p.c === color) return { r, c };
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+ export function pseudoMoves(board, r, c) {
108
+ const p = board[r][c];
109
+ if (!p) return [];
110
+ const out = [];
111
+ const push = (nr, nc) => { if (inBounds(nr, nc)) out.push([nr, nc]); };
112
+ const clear = (nr, nc) => !board[nr][nc];
113
+ const enemy = (nr, nc) => { const q = board[nr][nc]; return q && q.c !== p.c; };
114
+ if (p.t === "g") {
115
+ [[-1, 0], [1, 0], [0, -1], [0, 1]].forEach(([dr, dc]) => {
116
+ const nr = r + dr, nc = c + dc;
117
+ if (inPalace(nr, nc, p.c) && (clear(nr, nc) || enemy(nr, nc))) push(nr, nc);
118
+ });
119
+ }
120
+ if (p.t === "a") {
121
+ [[-1, -1], [-1, 1], [1, -1], [1, 1]].forEach(([dr, dc]) => {
122
+ const nr = r + dr, nc = c + dc;
123
+ if (inPalace(nr, nc, p.c) && (clear(nr, nc) || enemy(nr, nc))) push(nr, nc);
124
+ });
125
+ }
126
+ if (p.t === "e") {
127
+ [[-2, -2], [-2, 2], [2, -2], [2, 2]].forEach(([dr, dc]) => {
128
+ const nr = r + dr, nc = c + dc, er = r + dr / 2, ec = c + dc / 2;
129
+ if (inBounds(nr, nc) && ownSide(nr, p.c) && !board[er][ec] && (clear(nr, nc) || enemy(nr, nc))) push(nr, nc);
130
+ });
131
+ }
132
+ if (p.t === "h") {
133
+ [[-2, -1], [-2, 1], [2, -1], [2, 1], [-1, -2], [-1, 2], [1, -2], [1, 2]].forEach(([dr, dc]) => {
134
+ const nr = r + dr, nc = c + dc;
135
+ const legR = Math.abs(dr) === 2 ? r + dr / 2 : r;
136
+ const legC = Math.abs(dc) === 2 ? c + dc / 2 : c;
137
+ if (inBounds(nr, nc) && !board[legR][legC] && (clear(nr, nc) || enemy(nr, nc))) push(nr, nc);
138
+ });
139
+ }
140
+ if (p.t === "k" || p.t === "c") {
141
+ [[-1, 0], [1, 0], [0, -1], [0, 1]].forEach(([dr, dc]) => {
142
+ let rr = r + dr, cc = c + dc, jumped = false;
143
+ while (inBounds(rr, cc)) {
144
+ const q = board[rr][cc];
145
+ if (p.t === "k") {
146
+ if (!q) push(rr, cc);
147
+ else { if (q.c !== p.c) push(rr, cc); break; }
148
+ } else {
149
+ if (!q && !jumped) push(rr, cc);
150
+ else if (q && !jumped) jumped = true;
151
+ else if (q && jumped) { if (q.c !== p.c) push(rr, cc); break; }
152
+ }
153
+ rr += dr; cc += dc;
154
+ }
155
+ });
156
+ }
157
+ if (p.t === "p") {
158
+ const forward = p.c === "r" ? -1 : 1;
159
+ [[forward, 0], [0, -1], [0, 1]].forEach(([dr, dc]) => {
160
+ const nr = r + dr, nc = c + dc;
161
+ if (!inBounds(nr, nc) || (dc !== 0 && !crossedRiver(r, p.c))) return;
162
+ if (clear(nr, nc) || enemy(nr, nc)) push(nr, nc);
163
+ });
164
+ }
165
+ return out;
166
+ }
167
+ export function applyMove(board, fr, fc, tr, tc) {
168
+ const nb = cloneBoard(board);
169
+ nb[tr][tc] = nb[fr][fc];
170
+ nb[fr][fc] = null;
171
+ return nb;
172
+ }
173
+ export function inCheck(board, color) {
174
+ const g = findGeneral(board, color);
175
+ if (!g) return true;
176
+ const enemy = color === "r" ? "b" : "r";
177
+ for (let r = 0; r < ROWS; r += 1) {
178
+ const p = board[r][g.c];
179
+ if (p && p.t === "g" && p.c === enemy) {
180
+ let clear = true;
181
+ const lo = Math.min(r, g.r), hi = Math.max(r, g.r);
182
+ for (let i = lo + 1; i < hi; i += 1) if (board[i][g.c]) { clear = false; break; }
183
+ if (clear) return true;
184
+ }
185
+ }
186
+ for (let r = 0; r < ROWS; r += 1) {
187
+ for (let c = 0; c < COLS; c += 1) {
188
+ const p = board[r][c];
189
+ if (p && p.c === enemy && attacks(board, r, c, g.r, g.c)) return true;
190
+ }
191
+ }
192
+ return false;
193
+ }
194
+ export function legalMoves(board, color) {
195
+ const out = [];
196
+ for (let r = 0; r < ROWS; r += 1) {
197
+ for (let c = 0; c < COLS; c += 1) {
198
+ const p = board[r][c];
199
+ if (!p || p.c !== color) continue;
200
+ for (const [nr, nc] of pseudoMoves(board, r, c)) {
201
+ const nb = applyMove(board, r, c, nr, nc);
202
+ if (!inCheck(nb, color)) out.push([r, c, nr, nc]);
203
+ }
204
+ }
205
+ }
206
+ return out;
207
+ }
208
+ export function hasLegalMoves(board, color) {
209
+ return legalMoves(board, color).length > 0;
210
+ }
211
+ function evaluatePosition(board) {
212
+ let score = 0;
213
+ for (let r = 0; r < ROWS; r += 1) {
214
+ for (let c = 0; c < COLS; c += 1) {
215
+ const p = board[r][c];
216
+ if (!p) continue;
217
+ let v = VALUE[p.t];
218
+ if (p.t === "p") v += crossedRiver(r, p.c) ? 50 : 0;
219
+ score += p.c === "b" ? v : -v;
220
+ }
221
+ }
222
+ return score;
223
+ }
224
+ function evalFrom(board, color) {
225
+ const e = evaluatePosition(board);
226
+ return color === "b" ? e : -e;
227
+ }
228
+ function negamax(board, color, depth, alpha, beta) {
229
+ const moves = legalMoves(board, color);
230
+ if (moves.length === 0) {
231
+ return color === "b" ? -1000000 + (10 - depth) : 1000000 - (10 - depth);
232
+ }
233
+ if (depth === 0) return evalFrom(board, color);
234
+ const enemy = color === "r" ? "b" : "r";
235
+ const ordered = orderMoves(board, moves);
236
+ let best = -Infinity;
237
+ for (const m of ordered) {
238
+ const nb = applyMove(board, m[0], m[1], m[2], m[3]);
239
+ const score = -negamax(nb, enemy, depth - 1, -beta, -alpha);
240
+ if (score > best) best = score;
241
+ if (best > alpha) alpha = best;
242
+ if (alpha >= beta) break;
243
+ }
244
+ return best;
245
+ }
246
+ function orderMoves(board, moves) {
247
+ const scored = moves.map((m) => {
248
+ let s = 0;
249
+ const target = board[m[2]][m[3]];
250
+ if (target) s += VALUE[target.t] * 10;
251
+ return { m, s };
252
+ });
253
+ scored.sort((a, b) => b.s - a.s);
254
+ return scored.map((o) => o.m);
255
+ }
256
+ // avoid:局面指纹 → 出现次数 的计数表。
257
+ // 优先只在"从未出现过的局面"里选优;若全部重复,给出现次数越多的走法越大的罚分,
258
+ // 避免来回倒子的死循环。根节点对评分相近(±TIE_EPSILON)的走法随机挑一个,
259
+ // 防止每局都走出完全相同的呆板着法。
260
+ export function bestMove(board, color, depth, avoid) {
261
+ const moves = legalMoves(board, color);
262
+ if (!moves.length) return null;
263
+ const enemy = color === "r" ? "b" : "r";
264
+ const ordered = orderMoves(board, moves);
265
+ let pool;
266
+ if (avoid && avoid.size) {
267
+ const seenCount = (m) => avoid.get(posKey(applyMove(board, m[0], m[1], m[2], m[3]))) || 0;
268
+ const minSeen = Math.min(...ordered.map(seenCount));
269
+ pool = minSeen === 0
270
+ ? ordered.filter((m) => seenCount(m) === 0).map((m) => ({ m, pen: 0 }))
271
+ : ordered.map((m) => ({ m, pen: seenCount(m) * 60 })).sort((a, b) => a.pen - b.pen);
272
+ } else {
273
+ pool = ordered.map((m) => ({ m, pen: 0 }));
274
+ }
275
+ if (depth <= 1) {
276
+ return pickAmongBest(pool.map(({ m, pen }) => ({ m, s: evalFrom(applyMove(board, m[0], m[1], m[2], m[3]), color) - pen })));
277
+ }
278
+ let bestScore = -Infinity;
279
+ let alpha = -Infinity;
280
+ const scored = [];
281
+ for (const { m, pen } of pool) {
282
+ const nb = applyMove(board, m[0], m[1], m[2], m[3]);
283
+ // 窗口下界放宽 TIE_EPSILON:近似并列的走法仍能拿到精确评分,供随机挑选。
284
+ const s = -negamax(nb, enemy, depth - 1, -Infinity, -alpha) - pen;
285
+ scored.push({ m, s });
286
+ if (s > bestScore) {
287
+ bestScore = s;
288
+ alpha = Math.max(alpha, bestScore - TIE_EPSILON);
289
+ }
290
+ }
291
+ return pickAmongBest(scored);
292
+ }
293
+ const TIE_EPSILON = 12;
294
+ function pickAmongBest(scored) {
295
+ let bestScore = -Infinity;
296
+ for (const o of scored) if (o.s > bestScore) bestScore = o.s;
297
+ const ties = scored.filter((o) => o.s >= bestScore - TIE_EPSILON);
298
+ return ties[Math.floor(Math.random() * ties.length)].m;
299
+ }
@@ -52,7 +52,7 @@ export const feature = {
52
52
 
53
53
  // ===== 定价与金额估算 =====
54
54
  // 定价表以「人民币元 / 百万 tokens」为基准(DeepSeek/智谱官方刊例即人民币), 金额先算 CNY,
55
- // 对外 cost(USD) = CNY / usdCnyRate, 客户端/CSV 再按 cost × usdCnyRate 还原人民币。
55
+ // 对外记录的 cost 保持 USD,供旧版客户端兼容;当前客户端和 CSV 统一展示人民币。
56
56
  // usdCnyRate 与 pricing 均可被 settings.yaml 覆盖:
57
57
  // dsh-dock-tokenlog:
58
58
  // usdCnyRate: 7.2 # USD↔CNY 汇率
@@ -649,7 +649,7 @@ export const feature = {
649
649
  if (method === 'export') {
650
650
  loadPricingConfig()
651
651
  const list = buildQuery(payload || {})
652
- const header = ['time', 'provider', 'model', 'apiKey', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens', 'billedInput', 'cacheHitPercent', 'totalTokens', 'cost', 'costCny', 'effort', 'status', 'statusCode', 'errorCode', 'errorMsg', 'llmMs', 'sessionId', 'turn', 'step']
652
+ const header = ['time', 'provider', 'model', 'apiKey', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens', 'billedInput', 'cacheHitPercent', 'totalTokens', 'costCny', 'effort', 'status', 'statusCode', 'errorCode', 'errorMsg', 'llmMs', 'sessionId', 'turn', 'step']
653
653
  const esc = (v) => {
654
654
  const s = String(v === undefined || v === null ? '' : v)
655
655
  return /[,"\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s
@@ -37,13 +37,7 @@ function fmtCompact(n) {
37
37
  if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
38
38
  return String(Math.round(n));
39
39
  }
40
- function fmtCost(n) {
41
- const v = Number(n) || 0;
42
- if (v >= 10) return "$" + v.toFixed(2);
43
- if (v >= 0.01) return "$" + v.toFixed(4);
44
- return "$" + v.toFixed(6);
45
- }
46
- // 人民币金额: USD 按汇率换算, 默认 7.2(host 返回 rateUsdCny 可覆盖)
40
+ // 金额记录在 RPC 中以 USD 传输;界面统一按人民币显示。
47
41
  function fmtCostCny(usd, rate) {
48
42
  const v = (Number(usd) || 0) * (Number(rate) > 0 ? Number(rate) : 7.2);
49
43
  if (v >= 10000) return "¥" + (v / 10000).toFixed(2) + "万";
@@ -170,7 +164,7 @@ function Detail({ rec, onClose, rate }) {
170
164
  ["计费输入", fmtNum(rec.billedInput)],
171
165
  ["缓存命中率", rec.cacheHitPercent + "%"],
172
166
  ["总 Token", fmtNum(rec.totalTokens)],
173
- ["消耗金额(估算)", fmtCost(rec.cost) + " ≈ " + fmtCostCny(rec.cost, rateCny)],
167
+ ["消耗金额(估算)", fmtCostCny(rec.cost, rateCny)],
174
168
  ["推理强度", rec.effort || "—"],
175
169
  ["耗时", fmtDuration(rec.llmMs)],
176
170
  ["Turn / Step", rec.turn + " / " + rec.step],
@@ -350,8 +344,7 @@ export function TokenLogView(props) {
350
344
  { v: fmtCompact(totals.cacheReadTokens), l: "缓存命中" },
351
345
  { v: totals.cacheHitPct + "%", l: "缓存命中率" },
352
346
  { v: fmtCompact(totals.outputTokens), l: "输出" },
353
- { v: fmtCost(totals.cost), l: "消耗金额(估算)" },
354
- { v: fmtCostCny(totals.cost, rateCny), l: "消耗金额(¥)" },
347
+ { v: fmtCostCny(totals.cost, rateCny), l: "消耗金额(估算·人民币)" },
355
348
  { v: fmtDuration(totals.llmMs), l: "累计耗时" + (totals.timed ? " (" + totals.timed + "步)" : "") },
356
349
  ] : [];
357
350
 
@@ -368,7 +361,6 @@ export function TokenLogView(props) {
368
361
  <td>{r.key}</td><td>{fmtNum(r.calls)}</td><td>{fmtCompact(r.inputTokens)}</td>
369
362
  <td>{fmtCompact(r.cacheReadTokens)}</td><td>{r.cacheHitPct + "%"}</td><td>{fmtCompact(r.outputTokens)}</td>
370
363
  <td>{fmtCompact(r.totalTokens)}</td>
371
- <td>{fmtCost(r.cost)}</td>
372
364
  <td>{fmtCostCny(r.cost, rateCny)}</td>
373
365
  <td>{fmtDuration(r.llmMs)}</td>
374
366
  </tr>
@@ -394,7 +386,6 @@ export function TokenLogView(props) {
394
386
  <td>{fmtNum(r.outputTokens)}</td>
395
387
  <td>{fmtNum(r.reasoningTokens)}</td>
396
388
  <td>{fmtNum(r.totalTokens)}</td>
397
- <td>{fmtCost(r.cost)}</td>
398
389
  <td>{fmtCostCny(r.cost, rateCny)}</td>
399
390
  <td>{r.effort || "—"}</td>
400
391
  <td>
@@ -418,7 +409,7 @@ export function TokenLogView(props) {
418
409
  <div key="sumtab" className="dtok-table-wrap">
419
410
  <table className="dtok-table">
420
411
  <thead><tr>
421
- <th>维度</th><th>调用</th><th>输入</th><th>缓存</th><th>命中率</th><th>输出</th><th>总Token</th><th>金额</th><th>金额(¥)</th><th>耗时</th>
412
+ <th>维度</th><th>调用</th><th>输入</th><th>缓存</th><th>命中率</th><th>输出</th><th>总Token</th><th>金额(人民币)</th><th>耗时</th>
422
413
  </tr></thead>
423
414
  <tbody>{summaryRows}</tbody>
424
415
  </table>
@@ -451,7 +442,7 @@ export function TokenLogView(props) {
451
442
  <thead><tr>
452
443
  {sortTh("时间", "time")}{sortTh("会话ID", "sessionId")}{sortTh("提供商", "provider")}{sortTh("模型", "model")}
453
444
  {sortTh("输入", "inputTokens")}{sortTh("缓存", "cacheReadTokens")}{sortTh("命中%", "cacheHitPercent")}{sortTh("输出", "outputTokens")}
454
- {sortTh("推理", "reasoningTokens")}{sortTh("总额", "totalTokens")}{sortTh("金额", "cost")}{sortTh("金额(¥)", "cost")}{sortTh("强度", "effort")}
445
+ {sortTh("推理", "reasoningTokens")}{sortTh("总额", "totalTokens")}{sortTh("金额(人民币)", "cost")}{sortTh("强度", "effort")}
455
446
  {sortTh("状态", "status")}{sortTh("耗时", "llmMs")}
456
447
  </tr></thead>
457
448
  <tbody>{detailRows}</tbody>
@@ -521,32 +512,32 @@ function TokenLogHomeStat() {
521
512
  }, []);
522
513
  if (!snap.totals) return <span>{snap.err ? "用量查询失败(点击进入查看)" : "正在拉取今日用量…"}</span>;
523
514
  const t = snap.totals;
524
- return <span>今日 {fmtNum(t.calls)} 次调用 · {fmtCompact(t.totalTokens)} Token · {fmtCost(t.cost)}({fmtCostCny(t.cost, snap.rate)})</span>;
515
+ return <span>今日 {fmtNum(t.calls)} 次调用 · {fmtCompact(t.totalTokens)} Token · {fmtCostCny(t.cost, snap.rate)}</span>;
525
516
  }
526
517
 
527
518
  // ---------- 会话输入区 chip:显示当前会话总 Token(10s 静默刷新),点击打开功能坞并按该会话筛选 ----------
528
519
  export function TokenLogChip(props) {
529
520
  const sid = (props && props.sessionId) || (props && props.session && props.session.sessionId) || null;
530
- const [snap, setSnap] = useState({ totals: null, err: "" });
521
+ const [snap, setSnap] = useState({ totals: null, rate: 7.2, err: "" });
531
522
  useEffect(() => {
532
523
  if (!sid) return;
533
524
  let cancel = false;
534
525
  const load = () => rpcCall("query", { sessionId: sid })
535
- .then((d) => { if (!cancel) setSnap({ totals: d && d.totals, err: "" }); })
536
- .catch((e) => { if (!cancel) setSnap((s) => ({ totals: s.totals, err: String((e && e.message) || e) })); });
526
+ .then((d) => { if (!cancel) setSnap({ totals: d && d.totals, rate: (d && d.rateUsdCny) || 7.2, err: "" }); })
527
+ .catch((e) => { if (!cancel) setSnap((s) => ({ totals: s.totals, rate: s.rate, err: String((e && e.message) || e) })); });
537
528
  load();
538
529
  const timer = setInterval(load, 10000);
539
530
  return () => { cancel = true; clearInterval(timer); };
540
531
  }, [sid]);
541
532
  if (!sid) return null;
542
533
  const t = snap.totals;
543
- // chip 文案用紧凑金额(2 位小数):fmtCost 对小金额输出 6 位小数,chip 里太长会撑爆输入卡工具行;
534
+ // chip 文案用紧凑人民币金额(2 位小数),避免撑爆输入卡工具行;
544
535
  // 完整金额在 title 悬浮提示里。
545
536
  const label = snap.err && !t
546
537
  ? (snap.err.includes("重启") ? "用量·需重启宿主" : "用量·失败")
547
- : (t ? "⛁ " + fmtCompact(t.totalTokens) + (t.cost > 0 ? " · $" + t.cost.toFixed(2) : "") : "⛁ …");
538
+ : (t ? "⛁ " + fmtCompact(t.totalTokens) + (t.cost > 0 ? " · " + fmtCostCny(t.cost, snap.rate) : "") : "⛁ …");
548
539
  const title = t
549
- ? "本会话 " + fmtNum(t.calls) + " 次调用 · " + fmtNum(t.totalTokens) + " Token · " + fmtCost(t.cost) + "(估算)\n点击在功能坞查看用量记录"
540
+ ? "本会话 " + fmtNum(t.calls) + " 次调用 · " + fmtNum(t.totalTokens) + " Token · " + fmtCostCny(t.cost, snap.rate) + "(估算)\n点击在功能坞查看用量记录"
550
541
  : (snap.err ? snap.err + "\n" : "") + "点击在功能坞查看用量记录";
551
542
  return (
552
543
  <button type="button" className={"dockchip" + (snap.err && !t ? " err" : "")} title={title} aria-label="会话用量"
package/index.js CHANGED
@@ -19,7 +19,6 @@ import { feature as fVisionProxy } from './features/visionproxy/host.js'
19
19
  import { feature as fBalance } from './features/balance/host.js'
20
20
  import { feature as fTokenlog } from './features/tokenlog/host.js'
21
21
  import { feature as fAnimation } from './features/animation/host.js'
22
- import { feature as fMobileRelay } from './features/mobile-relay/host.js'
23
22
 
24
23
  export const name = 'dsh-dock'
25
24
 
@@ -39,7 +38,6 @@ export function apply(ctx) {
39
38
  fBalance,
40
39
  fTokenlog,
41
40
  fAnimation,
42
- fMobileRelay,
43
41
  ]
44
42
 
45
43
  const state = new Map()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dock",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计)、模型设置、图片理解代理、模型余额、任务动画(8 种氛围动效 + 桌面伙伴 3D 场景、完成通知与提示音、钉钉/飞书推送,开关独立、配置持久化);侧栏入口 + 可拖拽弹窗(最大化/最小化/缩放)。",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/src/client.jsx CHANGED
@@ -24,13 +24,13 @@ import { feature as fHeartbeat } from "../features/heartbeat/view.js";
24
24
  import { feature as fTheme } from "../features/theme/view.js";
25
25
  import { feature as fBalance } from "../features/balance/view.js";
26
26
  import { feature as fAnimation } from "../features/animation/view.jsx";
27
- import { feature as fMobileRelay } from "../features/mobile-relay/view.jsx";
27
+ import { feature as fGames } from "../features/games/view.jsx";
28
28
 
29
29
  const name = "dsh-dock";
30
- const DOCK_VERSION = "0.7.0";
30
+ const DOCK_VERSION = "0.8.0";
31
31
 
32
32
  // ---- 内置功能注册表:新功能 = features/<id>/ 加模块 + 这里 import 一行 ----
33
- const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fHeartbeat, fTheme, fBalance, fAnimation, fMobileRelay];
33
+ const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fHeartbeat, fTheme, fBalance, fAnimation, fGames];
34
34
  // 规划占位(路线图):接入后移除并建 features/<id>/ 模块
35
35
  const PLANNED_FEATURES = [];
36
36
  const PLANNED_NOTES = {};
@@ -186,11 +186,16 @@ const SHELL_CSS = [
186
186
  // 溢出兼容(两层防护,缺一不可):
187
187
  // 1) 宽度上限:宿主 .row 是 flex-wrap:wrap 且换行优先于收缩,chips 一长 tools 整行变宽,
188
188
  // 右侧模型选择器/发送键被挤到第二行(左右错位)。宿主 .row 带 container-type:inline-size,
189
- // 用容器单位 cqw 让上限随输入卡宽度自适应(窄卡少占、宽卡多占);不支持 cqw 的老内核退回固定 280px。
189
+ // 用容器单位 cqw 让上限随输入卡宽度自适应(窄卡少占、宽卡多占);不支持 cqw 的老内核退回固定值。
190
+ // 注意:旧上限 min(280px,36cqw) 仍偏宽——标准 780px 输入卡下(.row 内容约 762px),
191
+ // add 钮(28)+工作区选择器(~214)+chips(280) 已与右侧模型选择器+发送键(~345px)合计超宽,
192
+ // 即便 chips 未截断 .row 也会换行把模型选择器/发送键挤到第二行。实测把上限收到 189px
193
+ // (≈25cqw)并使 chips 内距收紧(padding 2px 6px、gap 2px)后,实测 4~6M 用量 + 余额
194
+ // 两 chip 可完整显示且不再换行;超限时仍被省略号截断,完整值在 title 悬浮提示里。
190
195
  // 2) 截断:超限部分用省略号截断(完整数值在 title 悬浮提示里),防 chips 凸出输入卡圆角(悬空)。
191
- ".dockchip-row{display:inline-flex;align-items:center;gap:4px;min-width:0;flex:0 1 auto;overflow:hidden;max-width:280px;}",
192
- "@supports (width:1cqw){.dockchip-row{max-width:min(280px,36cqw);}}",
193
- ".dockchip{display:inline-flex;align-items:center;gap:5px;cursor:pointer;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);border-radius:8px;padding:2px 8px;font-family:inherit;font-size:11px;line-height:18px;white-space:nowrap;min-width:0;overflow:hidden;transition:background .15s var(--ds-ease-in-out),color .15s var(--ds-ease-in-out);}",
196
+ ".dockchip-row{display:inline-flex;align-items:center;gap:2px;min-width:0;flex:0 1 auto;overflow:hidden;max-width:189px;}",
197
+ "@supports (width:1cqw){.dockchip-row{max-width:min(189px,25cqw);}}",
198
+ ".dockchip{display:inline-flex;align-items:center;gap:5px;cursor:pointer;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);border-radius:8px;padding:2px 6px;font-family:inherit;font-size:11px;line-height:18px;white-space:nowrap;min-width:0;overflow:hidden;transition:background .15s var(--ds-ease-in-out),color .15s var(--ds-ease-in-out);}",
194
199
  ".dockchip > span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;}",
195
200
  ".dockchip:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);}",
196
201
  ".dockchip .dockchip-dot{width:6px;height:6px;border-radius:50%;flex:none;}",
@@ -198,6 +203,13 @@ const SHELL_CSS = [
198
203
  ].join("\n");
199
204
 
200
205
  let dockCssTag = null;
206
+ let initialCssSchedule = null;
207
+ function cancelInitialCssSchedule() {
208
+ if (!initialCssSchedule || typeof window === "undefined") return;
209
+ if (initialCssSchedule.type === "idle" && typeof window.cancelIdleCallback === "function") window.cancelIdleCallback(initialCssSchedule.id);
210
+ if (initialCssSchedule.type === "timer") window.clearTimeout(initialCssSchedule.id);
211
+ initialCssSchedule = null;
212
+ }
201
213
  function fullDockCss() {
202
214
  const parts = [SHELL_CSS];
203
215
  for (const f of BUILTIN_FEATURES) if (f.css) parts.push(f.css);
@@ -209,6 +221,7 @@ function fullDockCss() {
209
221
  // 外部功能注册携带样式时可重算更新(外部样式后到)。
210
222
  function ensureCss() {
211
223
  if (typeof document === "undefined") return;
224
+ cancelInitialCssSchedule();
212
225
  try {
213
226
  if (!dockCssTag || !dockCssTag.isConnected) {
214
227
  if (document.querySelector('style[data-plugin-css="dsh-dock"]')) {
@@ -224,6 +237,20 @@ function ensureCss() {
224
237
  console.error("[dsh-dock] ensureCss failed:", e && e.message ? e.message : String(e));
225
238
  }
226
239
  }
240
+ // 刷新页时宿主内容优先绘制:功能坞样式很大,首帧同步解析会让整页短暂停顿。
241
+ // 首次注入延后到浏览器空闲期;之后的外部模块注册仍走 ensureCss() 立即更新。
242
+ function scheduleInitialCss() {
243
+ if (typeof document === "undefined" || initialCssSchedule || (dockCssTag && dockCssTag.isConnected)) return;
244
+ const run = () => {
245
+ initialCssSchedule = null;
246
+ ensureCss();
247
+ };
248
+ if (typeof window !== "undefined" && typeof window.requestIdleCallback === "function") {
249
+ initialCssSchedule = { type: "idle", id: window.requestIdleCallback(run, { timeout: 350 }) };
250
+ } else {
251
+ initialCssSchedule = { type: "timer", id: setTimeout(run, 48) };
252
+ }
253
+ }
227
254
 
228
255
  // ---- 外部功能变化订阅 hook(弹层/设置页重渲染用) ----
229
256
  function useExternalVersion() {
@@ -277,9 +304,14 @@ function DockModal() {
277
304
  // lastGeom 记住本页生命周期内最后一次几何,重开弹层时还原
278
305
  const [win, setWin] = react.useState(() => ({ mode: "normal", x: null, y: null, w: null, h: null }));
279
306
  const dlgRef = react.useRef(null);
307
+ const contentRef = react.useRef(null);
280
308
  react.useEffect(() => {
281
309
  if (lastGeom.w) setWin({ mode: "normal", x: lastGeom.x, y: lastGeom.y, w: lastGeom.w, h: lastGeom.h });
282
310
  }, []);
311
+ // 切换左侧模块时总是从新页面顶部开始,避免把上一页的滚动位置带进小游戏等短页面。
312
+ react.useEffect(() => {
313
+ if (open && contentRef.current) contentRef.current.scrollTop = 0;
314
+ }, [active, open]);
283
315
  function beginDrag(e, type) {
284
316
  if (win.mode !== "normal" || e.button !== 0) return;
285
317
  if (type === "move" && e.target && e.target.closest && e.target.closest("button,select,input")) return;
@@ -382,7 +414,7 @@ function DockModal() {
382
414
  react.createElement("span", null, m.name),
383
415
  m.planned ? react.createElement("span", { className: "dockm-badge" }, "规划中") : null,
384
416
  m.external ? react.createElement("span", { className: "dockm-badge" }, "外部") : null))),
385
- react.createElement("div", { className: "dockm-content" },
417
+ react.createElement("div", { className: "dockm-content", ref: contentRef },
386
418
  react.createElement("div", { className: "dockm-content-head" },
387
419
  react.createElement("div", { className: "dockm-name" },
388
420
  isHome
@@ -594,7 +626,7 @@ const ctxRef = { current: null };
594
626
 
595
627
  export function apply(ctx) {
596
628
  ctxRef.current = ctx;
597
- ensureCss();
629
+ scheduleInitialCss();
598
630
  initFeatureState(BUILTIN_FEATURES.concat(PLANNED_FEATURES));
599
631
  const slots = ctx.get("slots");
600
632
  if (slots === undefined) return;
@@ -616,21 +648,6 @@ export function apply(ctx) {
616
648
  slots.inject("conversation.input.left", () => slots.register(
617
649
  { name: "conversation.input.left", id: "dsh-dock-chips", order: 10, label: "功能坞" },
618
650
  (zone) => react.createElement(DockChips, Object.assign({}, zone, { ctx: ctxRef.current }))));
619
- // 手机接力链接使用 fragment,避免配对码进入服务器日志或 Referer。插件加载后立即消费并
620
- // 清掉 fragment,再自动打开对应功能;DSH 会话本身仍由宿主原生会话服务继续承接。
621
- try {
622
- if (typeof window !== "undefined") {
623
- const match = window.location.hash.match(/(?:^#|&)dsh-mobile-relay=([^&]+)/);
624
- if (match) {
625
- const launch = decodeURIComponent(match[1]);
626
- if (/^[A-Za-z0-9_-]+\.[A-F0-9]{10}$/i.test(launch)) {
627
- sessionStorage.setItem("dsh-dock/mobile-relay/launch/v1", launch);
628
- window.history.replaceState(null, "", window.location.pathname + window.location.search);
629
- setTimeout(() => openPanel("mobile-relay"), 0);
630
- }
631
- }
632
- }
633
- } catch { /* 私密模式禁用 storage/history 时保持手动入口可用 */ }
634
651
  }
635
652
 
636
653
  export const inject = ["timer"];
package/src/host-core.js CHANGED
@@ -39,7 +39,10 @@ export const DockConfig = z.object({
39
39
  })
40
40
 
41
41
  /** 任务动画 effectMode 合法值(客户端动画模式)。 */
42
- export const ANIMATION_MODES = ['flow', 'breathe', 'ring', 'orbit', 'robot', 'matrix', 'stars', 'aurora']
42
+ export const ANIMATION_MODES = [
43
+ 'flow', 'breathe', 'ring', 'orbit', 'robot', 'matrix', 'stars', 'aurora', 'space',
44
+ 'nebula', 'warp', 'radar', 'constellation', 'fireflies', 'ocean', 'prism', 'circuit', 'gravity', 'lantern',
45
+ ]
43
46
 
44
47
  /** 提示音 soundEffect 合法值(客户端音效库键名)。 */
45
48
  export const SOUND_EFFECTS = ['chime', 'ding', 'coin', 'bell', 'pulse', 'arp']