dsh-dock 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +12 -15
- package/README.md +170 -37
- package/client.js +2136 -97
- package/features/balance/host.js +494 -0
- package/features/balance/view.js +303 -0
- package/features/heartbeat/view.js +34 -0
- package/features/modelconfig/host.js +403 -0
- package/features/modelconfig/view.js +446 -0
- package/features/theme/view.js +35 -0
- package/features/tokenlog/host.js +678 -0
- package/features/tokenlog/view.jsx +568 -0
- package/features/visionproxy/host.js +301 -0
- package/index.js +50 -36
- package/package.json +19 -5
- package/src/client.jsx +549 -0
- package/src/host-core.js +54 -0
- package/src/shared.js +108 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
// dsh-dock · 功能模块【用量记录】· 客户端视图(v0.4.0,移植自 @wycto/dsh-token-usage client v6,适配 dock 嵌入)
|
|
2
|
+
//
|
|
3
|
+
// 嵌入功能坞面板的统计视图(无独立 overlay 外壳;全屏用 dock 弹窗自带的「最大化」):
|
|
4
|
+
// - 秒级时间范围查询 + 会话ID/提供商/模型(联动)/状态/推理强度 筛选,条件本地暂存
|
|
5
|
+
// - 9 张 KPI 卡 + 分组统计表 + 明细表(点击表头排序、会话ID点击即筛选、100 行/页上下双分页)
|
|
6
|
+
// - 状态列显示 HTTP 状态码徽章,行内【查看详情】弹窗展示完整信息;CSV 导出
|
|
7
|
+
// - 挂载即扫描历史+按暂存条件查询;挂载期间每 5s 静默自动刷新
|
|
8
|
+
// Host 通信:fetch('/dsh-dock/tokenlog/<method>')(见 features/tokenlog/host.js)。
|
|
9
|
+
import { useState, useEffect, useCallback, useMemo } from "react";
|
|
10
|
+
import { openPanel } from "../../src/shared.js";
|
|
11
|
+
|
|
12
|
+
// ---------- Host RPC 桥接 ----------
|
|
13
|
+
function rpcCall(method, args) {
|
|
14
|
+
return fetch("/dsh-dock/tokenlog/" + method, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "content-type": "application/json" },
|
|
17
|
+
body: JSON.stringify(args === undefined ? {} : args),
|
|
18
|
+
})
|
|
19
|
+
.then(async (res) => {
|
|
20
|
+
const data = await res.json().catch(() => ({}));
|
|
21
|
+
if (res.ok && data && data.ok === true) return data.data;
|
|
22
|
+
// 旧宿主进程没有用量路由(405/404):给出可操作提示而非裸状态码
|
|
23
|
+
if (res.status === 405 || res.status === 404) {
|
|
24
|
+
throw new Error("宿主进程是旧版本(没有用量记录路由),重启 dsh web 后重试");
|
|
25
|
+
}
|
|
26
|
+
if (data && data.ok === false) throw new Error((data.error && data.error.message) || ("HTTP " + res.status));
|
|
27
|
+
throw new Error("HTTP " + res.status + (data && data.error && data.error.message ? ": " + data.error.message : ""));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------- 格式化 ----------
|
|
32
|
+
function fmtNum(n) { return (Number(n) || 0).toLocaleString("en-US"); }
|
|
33
|
+
function fmtCompact(n) {
|
|
34
|
+
n = Number(n) || 0;
|
|
35
|
+
if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
|
|
36
|
+
if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
|
|
37
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
|
|
38
|
+
return String(Math.round(n));
|
|
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 可覆盖)
|
|
47
|
+
function fmtCostCny(usd, rate) {
|
|
48
|
+
const v = (Number(usd) || 0) * (Number(rate) > 0 ? Number(rate) : 7.2);
|
|
49
|
+
if (v >= 10000) return "¥" + (v / 10000).toFixed(2) + "万";
|
|
50
|
+
if (v >= 100) return "¥" + v.toFixed(0);
|
|
51
|
+
return "¥" + v.toFixed(2);
|
|
52
|
+
}
|
|
53
|
+
function fmtDuration(ms) {
|
|
54
|
+
if (ms === null || ms === undefined || isNaN(ms)) return "—";
|
|
55
|
+
if (ms < 1000) return Math.round(ms) + "ms";
|
|
56
|
+
if (ms < 60000) return (ms / 1000).toFixed(1) + "s";
|
|
57
|
+
return Math.floor(ms / 60000) + "m" + Math.round((ms % 60000) / 1000) + "s";
|
|
58
|
+
}
|
|
59
|
+
function fmtTime(ts) {
|
|
60
|
+
if (!ts) return "";
|
|
61
|
+
const d = new Date(ts);
|
|
62
|
+
const p = (x) => String(x).padStart(2, "0");
|
|
63
|
+
return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate()) + " " + p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------- 筛选条件暂存(本地) ----------
|
|
67
|
+
// 打开视图时恢复上次保存的条件; 从未保存过则时间范围不选(显示全部记录)。
|
|
68
|
+
const STORE_KEY = "dsh-dock/tokenlog/filters/v1";
|
|
69
|
+
const FILTER_FIELDS = ["fromStr", "toStr", "provider", "model", "status", "effort", "sessionId", "dim"];
|
|
70
|
+
function loadSavedFilters() {
|
|
71
|
+
try {
|
|
72
|
+
if (typeof localStorage === "undefined") return null;
|
|
73
|
+
const raw = localStorage.getItem(STORE_KEY);
|
|
74
|
+
if (!raw) return null;
|
|
75
|
+
const obj = JSON.parse(raw);
|
|
76
|
+
if (!obj || typeof obj !== "object") return null;
|
|
77
|
+
const out = {};
|
|
78
|
+
for (const k of FILTER_FIELDS) out[k] = typeof obj[k] === "string" ? obj[k] : "";
|
|
79
|
+
return out;
|
|
80
|
+
} catch (e) { return null; }
|
|
81
|
+
}
|
|
82
|
+
function saveFilters(f) {
|
|
83
|
+
try {
|
|
84
|
+
if (typeof localStorage === "undefined") return;
|
|
85
|
+
localStorage.setItem(STORE_KEY, JSON.stringify(f));
|
|
86
|
+
} catch (e) { /* localStorage 不可用时静默 */ }
|
|
87
|
+
}
|
|
88
|
+
function shortId(sid) {
|
|
89
|
+
if (!sid) return "";
|
|
90
|
+
if (sid.length <= 16) return sid;
|
|
91
|
+
return sid.slice(0, 8) + "…" + sid.slice(-6);
|
|
92
|
+
}
|
|
93
|
+
function statusInfo(r) {
|
|
94
|
+
if (r.status === "completed") return { code: 200, label: "200", cls: "ok", title: "成功" };
|
|
95
|
+
if (r.status === "max-tokens") return { code: 200, label: "200", cls: "warn", title: "完成(达到输出上限)" };
|
|
96
|
+
if (r.status === "error") return { code: r.statusCode || 500, label: String(r.statusCode || 500), cls: "err", title: r.errorMsg || r.errorCode || "调用失败" };
|
|
97
|
+
if (r.status === "aborted") return { code: 499, label: "499", cls: "warn", title: "已取消" };
|
|
98
|
+
if (r.status === "blocked") return { code: 403, label: "403", cls: "warn", title: "已阻止" };
|
|
99
|
+
if (r.status === "interrupted") return { code: 500, label: "500", cls: "warn", title: "中断" };
|
|
100
|
+
return { code: 0, label: "…", cls: "pend", title: "进行中" };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------- 样式(dtok- 前缀,dock 面板内自适应:宽度铺满、表格横向滚动) ----------
|
|
104
|
+
const css = `
|
|
105
|
+
.dtok-root{display:flex;flex-direction:column;gap:8px;width:100%;min-width:0;}
|
|
106
|
+
.dtok-status{display:flex;align-items:center;gap:10px;font-size:12px;color:var(--dsw-alias-label-secondary);flex-wrap:wrap;}
|
|
107
|
+
.dtok-status .count{color:var(--dsw-alias-label-primary);}
|
|
108
|
+
.dtok-filter{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:8px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);}
|
|
109
|
+
.dtok-filter label{font-size:12px;color:var(--dsw-alias-label-secondary);white-space:nowrap;flex:none;}
|
|
110
|
+
.dtok-input,.dtok-select{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 8px;font-size:12px;font-family:inherit;max-width:170px;}
|
|
111
|
+
.dtok-input[type="datetime-local"]{width:158px;}
|
|
112
|
+
.dtok-btn{cursor:pointer;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);padding:4px 12px;font-size:12px;font-family:inherit;flex:none;}
|
|
113
|
+
.dtok-btn:hover{background:var(--dsw-alias-interactive-bg-hover);}
|
|
114
|
+
.dtok-btn[disabled]{opacity:.5;cursor:default;}
|
|
115
|
+
.dtok-btn.primary{background:var(--dsw-alias-accent,#2f6fed);border-color:var(--dsw-alias-accent,#2f6fed);color:#fff;}
|
|
116
|
+
.dtok-btn.primary:hover{filter:brightness(1.1);}
|
|
117
|
+
.dtok-body{display:flex;flex-direction:column;gap:8px;min-width:0;}
|
|
118
|
+
.dtok-cards{display:flex;gap:8px;flex-wrap:wrap;}
|
|
119
|
+
.dtok-card{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:8px 14px;min-width:112px;}
|
|
120
|
+
.dtok-card .v{font-size:18px;font-weight:700;color:var(--dsw-alias-label-primary);}
|
|
121
|
+
.dtok-card .l{font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:2px;}
|
|
122
|
+
.dtok-section-title{font-size:13px;font-weight:600;margin:6px 0 0;color:var(--dsw-alias-label-primary);}
|
|
123
|
+
.dtok-table-wrap{overflow:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;max-width:100%;}
|
|
124
|
+
.dtok-table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap;color:var(--dsw-alias-label-primary);}
|
|
125
|
+
.dtok-table th{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);text-align:left;padding:7px 10px;position:sticky;top:0;z-index:1;border-bottom:1px solid var(--dsw-alias-border-l1);font-weight:600;user-select:none;cursor:pointer;}
|
|
126
|
+
.dtok-table th:hover{color:var(--dsw-alias-label-primary);}
|
|
127
|
+
.dtok-table td{padding:6px 10px;border-bottom:1px solid rgba(122,132,152,0.12);}
|
|
128
|
+
.dtok-table tr:hover td{background:rgba(80,110,180,0.10);}
|
|
129
|
+
.dtok-empty{text-align:center;color:var(--dsw-alias-label-secondary);padding:32px 0;font-size:13px;}
|
|
130
|
+
.dtok-sid{color:var(--dsw-alias-accent,#7ab8ff);cursor:pointer;text-decoration:underline dotted;}
|
|
131
|
+
.dtok-sid:hover{filter:brightness(1.25);}
|
|
132
|
+
.dtok-code{font-weight:700;font-family:ui-monospace,monospace;}
|
|
133
|
+
.dtok-code.ok{color:var(--dsw-alias-state-success-primary,#4ade80);}
|
|
134
|
+
.dtok-code.warn{color:var(--dsw-alias-state-warning-primary,#fbbf24);}
|
|
135
|
+
.dtok-code.err{color:var(--dsw-alias-state-error-primary,#f87171);}
|
|
136
|
+
.dtok-code.pend{color:var(--dsw-alias-label-tertiary,#94a3b8);}
|
|
137
|
+
.dtok-detail-link{color:var(--dsw-alias-accent,#7ab8ff);cursor:pointer;font-size:11px;}
|
|
138
|
+
.dtok-detail-link:hover{text-decoration:underline;}
|
|
139
|
+
.dtok-detail{position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;}
|
|
140
|
+
.dtok-detail-card{background:var(--dsw-alias-bg-layer-2,#1c212b);border:1px solid var(--dsw-alias-border-l2,#3a4150);border-radius:12px;padding:20px 24px;max-width:640px;width:92%;max-height:80vh;overflow:auto;color:var(--dsw-alias-label-primary,#e8eaf0);}
|
|
141
|
+
.dtok-detail-card h3{margin:0 0 12px;font-size:15px;}
|
|
142
|
+
.dtok-detail-row{display:flex;gap:8px;padding:4px 0;font-size:12px;border-bottom:1px solid rgba(122,132,152,0.1);}
|
|
143
|
+
.dtok-detail-row .k{color:var(--dsw-alias-label-secondary,#9aa3b5);min-width:110px;flex-shrink:0;}
|
|
144
|
+
.dtok-detail-row .v{word-break:break-all;}
|
|
145
|
+
.dtok-sort-mark{opacity:0.6;margin-left:3px;}
|
|
146
|
+
.dtok-pager{display:flex;gap:8px;align-items:center;font-size:12px;color:var(--dsw-alias-label-secondary);}
|
|
147
|
+
.dtok-pager.top{margin:0;}
|
|
148
|
+
.dtok-pager.bottom{margin:0;}
|
|
149
|
+
.dtok-err{color:var(--dsw-alias-state-error-primary,#ff7a7a);font-size:12px;}
|
|
150
|
+
`;
|
|
151
|
+
|
|
152
|
+
// ---------- 详情弹窗 ----------
|
|
153
|
+
function Detail({ rec, onClose, rate }) {
|
|
154
|
+
const st = statusInfo(rec);
|
|
155
|
+
const rateCny = Number(rate) > 0 ? Number(rate) : 7.2;
|
|
156
|
+
const rows = [
|
|
157
|
+
["会话 ID", rec.sessionId],
|
|
158
|
+
["时间", fmtTime(rec.time)],
|
|
159
|
+
["提供商", rec.provider],
|
|
160
|
+
["模型", rec.model],
|
|
161
|
+
["状态码", st.label + " (" + st.title + ")"],
|
|
162
|
+
["错误信息", rec.errorMsg || "—"],
|
|
163
|
+
["错误码", rec.errorCode || "—"],
|
|
164
|
+
["Request ID", rec.requestId || "—"],
|
|
165
|
+
["输入 Token(未缓存)", fmtNum(rec.inputTokens)],
|
|
166
|
+
["输出 Token", fmtNum(rec.outputTokens)],
|
|
167
|
+
["缓存命中 Token", fmtNum(rec.cacheReadTokens)],
|
|
168
|
+
["缓存写入 Token", fmtNum(rec.cacheWriteTokens)],
|
|
169
|
+
["推理 Token", fmtNum(rec.reasoningTokens)],
|
|
170
|
+
["计费输入", fmtNum(rec.billedInput)],
|
|
171
|
+
["缓存命中率", rec.cacheHitPercent + "%"],
|
|
172
|
+
["总 Token", fmtNum(rec.totalTokens)],
|
|
173
|
+
["消耗金额(估算)", fmtCost(rec.cost) + " ≈ " + fmtCostCny(rec.cost, rateCny)],
|
|
174
|
+
["推理强度", rec.effort || "—"],
|
|
175
|
+
["耗时", fmtDuration(rec.llmMs)],
|
|
176
|
+
["Turn / Step", rec.turn + " / " + rec.step],
|
|
177
|
+
];
|
|
178
|
+
return (
|
|
179
|
+
<div className="dtok-detail" onClick={onClose}>
|
|
180
|
+
<div className="dtok-detail-card" onClick={(e) => e.stopPropagation()}>
|
|
181
|
+
<h3>调用详情</h3>
|
|
182
|
+
{rows.map(([k, v]) => (
|
|
183
|
+
<div className="dtok-detail-row" key={k}>
|
|
184
|
+
<span className="k">{k}</span>
|
|
185
|
+
<span className="v">{v}</span>
|
|
186
|
+
</div>
|
|
187
|
+
))}
|
|
188
|
+
<button className="dtok-btn" style={{ marginTop: 12 }} onClick={onClose}>关闭</button>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------- 主视图(嵌入 dock 面板内容区) ----------
|
|
195
|
+
export function TokenLogView(props) {
|
|
196
|
+
// props.params.sessionId(chips 点击带入):立即按该会话筛选并查询
|
|
197
|
+
const navSession = props && props.params && props.params.sessionId ? props.params.sessionId : null;
|
|
198
|
+
// 挂载即视为「打开」:恢复上次暂存的条件(时间不选=显示全部记录); savedFilters 稳定快照, 仅初始化时读取一次
|
|
199
|
+
const [savedFilters] = useState(loadSavedFilters);
|
|
200
|
+
const [fromStr, setFromStr] = useState(() => (savedFilters && savedFilters.fromStr) || "");
|
|
201
|
+
const [toStr, setToStr] = useState(() => (savedFilters && savedFilters.toStr) || "");
|
|
202
|
+
const [provider, setProvider] = useState(() => (savedFilters && savedFilters.provider) || "");
|
|
203
|
+
const [model, setModel] = useState(() => (savedFilters && savedFilters.model) || "");
|
|
204
|
+
const [status, setStatus] = useState(() => (savedFilters && savedFilters.status) || "");
|
|
205
|
+
const [effort, setEffort] = useState(() => (savedFilters && savedFilters.effort) || "");
|
|
206
|
+
const [sessionId, setSessionId] = useState(() => (savedFilters && savedFilters.sessionId) || "");
|
|
207
|
+
const [dim, setDim] = useState(() => (savedFilters && savedFilters.dim) || "");
|
|
208
|
+
const [sortKey, setSortKey] = useState("time");
|
|
209
|
+
const [sortDir, setSortDir] = useState("desc");
|
|
210
|
+
const [data, setData] = useState(null);
|
|
211
|
+
const [loading, setLoading] = useState(false);
|
|
212
|
+
const [err, setErr] = useState("");
|
|
213
|
+
const [page, setPage] = useState(0);
|
|
214
|
+
const [detailRec, setDetailRec] = useState(null);
|
|
215
|
+
const pageSize = 100;
|
|
216
|
+
|
|
217
|
+
// 暂存筛选条件: 任一筛选变化即写入 localStorage, 下次打开恢复同样条件
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
saveFilters({ fromStr, toStr, provider, model, status, effort, sessionId, dim });
|
|
220
|
+
}, [fromStr, toStr, provider, model, status, effort, sessionId, dim]);
|
|
221
|
+
|
|
222
|
+
// 挂载时: 扫描历史后按暂存条件查询(未设置时间则不限制, 显示全部记录)
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
let cancel = false;
|
|
225
|
+
setLoading(true); setErr("");
|
|
226
|
+
const q = {};
|
|
227
|
+
if (savedFilters) {
|
|
228
|
+
if (savedFilters.fromStr) q.from = new Date(savedFilters.fromStr).getTime();
|
|
229
|
+
if (savedFilters.toStr) q.to = new Date(savedFilters.toStr).getTime();
|
|
230
|
+
if (savedFilters.provider) q.provider = savedFilters.provider;
|
|
231
|
+
if (savedFilters.model) q.model = savedFilters.model;
|
|
232
|
+
if (savedFilters.status) q.status = savedFilters.status;
|
|
233
|
+
if (savedFilters.effort) q.effort = savedFilters.effort;
|
|
234
|
+
if (savedFilters.sessionId) q.sessionId = savedFilters.sessionId;
|
|
235
|
+
if (savedFilters.dim) q.dim = savedFilters.dim;
|
|
236
|
+
}
|
|
237
|
+
rpcCall("scan", {})
|
|
238
|
+
.then(() => (cancel ? null : rpcCall("query", q)))
|
|
239
|
+
.then((d) => {
|
|
240
|
+
if (cancel) return;
|
|
241
|
+
setData(d);
|
|
242
|
+
})
|
|
243
|
+
.catch((e) => { if (!cancel) setErr(String((e && e.message) || e)); })
|
|
244
|
+
.finally(() => { if (!cancel) setLoading(false); });
|
|
245
|
+
return () => { cancel = true; };
|
|
246
|
+
}, [savedFilters]);
|
|
247
|
+
|
|
248
|
+
const buildQ = useCallback((withDim, dimOverride) => {
|
|
249
|
+
const q = {};
|
|
250
|
+
if (fromStr) q.from = new Date(fromStr).getTime();
|
|
251
|
+
if (toStr) q.to = new Date(toStr).getTime();
|
|
252
|
+
if (provider) q.provider = provider;
|
|
253
|
+
if (model) q.model = model;
|
|
254
|
+
if (status) q.status = status;
|
|
255
|
+
if (effort) q.effort = effort;
|
|
256
|
+
if (sessionId) q.sessionId = sessionId;
|
|
257
|
+
const dimNow = dimOverride !== undefined ? dimOverride : dim;
|
|
258
|
+
if (withDim && dimNow) q.dim = dimNow;
|
|
259
|
+
return q;
|
|
260
|
+
}, [fromStr, toStr, provider, model, status, effort, sessionId, dim]);
|
|
261
|
+
|
|
262
|
+
// dimOverride:分组按钮点击时按「即将切换到」的维度立即查询(闭包里的 dim 还是旧值)
|
|
263
|
+
const runQuery = useCallback((dimOverride) => {
|
|
264
|
+
setLoading(true); setErr("");
|
|
265
|
+
rpcCall("query", buildQ(true, dimOverride))
|
|
266
|
+
.then((d) => { setData(d); setPage(0); })
|
|
267
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
268
|
+
.finally(() => setLoading(false));
|
|
269
|
+
}, [buildQ]);
|
|
270
|
+
|
|
271
|
+
// chips 定位:按带入的会话立即筛选查询(覆盖暂存条件里的会话项)
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
if (!navSession) return;
|
|
274
|
+
setSessionId(navSession);
|
|
275
|
+
setLoading(true); setErr("");
|
|
276
|
+
rpcCall("query", Object.assign({}, buildQ(true), { sessionId: navSession }))
|
|
277
|
+
.then((d) => { setData(d); setPage(0); })
|
|
278
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
279
|
+
.finally(() => setLoading(false));
|
|
280
|
+
}, [navSession]);
|
|
281
|
+
|
|
282
|
+
// 重置: 清空所有筛选(时间不选=显示全部记录), 并立即查询
|
|
283
|
+
const resetFilters = useCallback(() => { setFromStr(""); setToStr("");
|
|
284
|
+
setProvider(""); setModel(""); setStatus(""); setEffort(""); setSessionId(""); setDim("");
|
|
285
|
+
setLoading(true); setErr("");
|
|
286
|
+
rpcCall("query", {})
|
|
287
|
+
.then((d) => { setData(d); setPage(0); })
|
|
288
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
289
|
+
.finally(() => setLoading(false));
|
|
290
|
+
}, []);
|
|
291
|
+
|
|
292
|
+
const exportCsv = useCallback(() => {
|
|
293
|
+
rpcCall("export", buildQ(false))
|
|
294
|
+
.then((d) => {
|
|
295
|
+
const blob = new Blob([d.csv], { type: "text/csv;charset=utf-8" });
|
|
296
|
+
const url = URL.createObjectURL(blob);
|
|
297
|
+
const a = document.createElement("a");
|
|
298
|
+
a.href = url;
|
|
299
|
+
a.download = "dsh-dock-tokenlog-" + Date.now() + ".csv";
|
|
300
|
+
a.click();
|
|
301
|
+
URL.revokeObjectURL(url);
|
|
302
|
+
})
|
|
303
|
+
.catch((e) => setErr(String((e && e.message) || e)));
|
|
304
|
+
}, [buildQ]);
|
|
305
|
+
|
|
306
|
+
// 自动刷新: 挂载期间每 5s 按当前筛选静默刷新(不闪烁 loading)。
|
|
307
|
+
// 注意: 依赖数组 [buildQ] 在声明时即求值, 必须放在 buildQ 定义之后(否则 TDZ 崩溃)。
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
const timer = setInterval(() => {
|
|
310
|
+
rpcCall("query", buildQ(true))
|
|
311
|
+
.then((d) => { setData(d); setErr(""); })
|
|
312
|
+
.catch(() => {});
|
|
313
|
+
}, 5000);
|
|
314
|
+
return () => clearInterval(timer);
|
|
315
|
+
}, [buildQ]);
|
|
316
|
+
|
|
317
|
+
const toggleSort = useCallback((key) => {
|
|
318
|
+
if (sortKey === key) setSortDir(sortDir === "asc" ? "desc" : "asc");
|
|
319
|
+
else { setSortKey(key); setSortDir("asc"); }
|
|
320
|
+
}, [sortKey, sortDir]);
|
|
321
|
+
|
|
322
|
+
const records = (data && data.records) || [];
|
|
323
|
+
const totals = data && data.totals;
|
|
324
|
+
const sessionIds = (data && data.sessionIds) || [];
|
|
325
|
+
const sorted = useMemo(() => {
|
|
326
|
+
const arr = records.slice();
|
|
327
|
+
const key = sortKey;
|
|
328
|
+
const dir = sortDir === "asc" ? 1 : -1;
|
|
329
|
+
arr.sort((a, b) => {
|
|
330
|
+
const av = a[key]; const bv = b[key];
|
|
331
|
+
if (av === null || av === undefined || av === "") return 1;
|
|
332
|
+
if (bv === null || bv === undefined || bv === "") return -1;
|
|
333
|
+
if (typeof av === "string") return av.localeCompare(String(bv)) * dir;
|
|
334
|
+
return (Number(av) - Number(bv)) * dir;
|
|
335
|
+
});
|
|
336
|
+
return arr;
|
|
337
|
+
}, [records, sortKey, sortDir]);
|
|
338
|
+
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
|
|
339
|
+
const pageSafe = Math.min(page, pageCount - 1);
|
|
340
|
+
const pageRows = sorted.slice(pageSafe * pageSize, (pageSafe + 1) * pageSize);
|
|
341
|
+
|
|
342
|
+
// USD→CNY 汇率: host 返回(默认 7.2, 可被 settings dsh-dock-tokenlog.usdCnyRate 覆盖)。
|
|
343
|
+
// 注意: 必须在本文件所有引用它的表达式(cards/summaryRows/detailRows/Detail)之前声明。
|
|
344
|
+
const rateCny = (data && data.rateUsdCny) || 7.2;
|
|
345
|
+
|
|
346
|
+
const cards = totals ? [
|
|
347
|
+
{ v: fmtNum(totals.calls), l: "调用次数" },
|
|
348
|
+
{ v: fmtCompact(totals.totalTokens), l: "总 Token" },
|
|
349
|
+
{ v: fmtCompact(totals.inputTokens), l: "输入(未缓存)" },
|
|
350
|
+
{ v: fmtCompact(totals.cacheReadTokens), l: "缓存命中" },
|
|
351
|
+
{ v: totals.cacheHitPct + "%", l: "缓存命中率" },
|
|
352
|
+
{ v: fmtCompact(totals.outputTokens), l: "输出" },
|
|
353
|
+
{ v: fmtCost(totals.cost), l: "消耗金额(估算)" },
|
|
354
|
+
{ v: fmtCostCny(totals.cost, rateCny), l: "消耗金额(¥)" },
|
|
355
|
+
{ v: fmtDuration(totals.llmMs), l: "累计耗时" + (totals.timed ? " (" + totals.timed + "步)" : "") },
|
|
356
|
+
] : [];
|
|
357
|
+
|
|
358
|
+
const opts = (arr) => (arr || []).map((x) => <option key={x || "(none)"} value={x}>{x || "(空)"}</option>);
|
|
359
|
+
|
|
360
|
+
// 模型下拉联动: 选中提供商后只显示该提供商下的模型; 未选提供商显示全部模型。
|
|
361
|
+
// modelsByProvider 由 host 返回({provider: [models]})。
|
|
362
|
+
const modelChoices = provider && data && data.modelsByProvider
|
|
363
|
+
? (data.modelsByProvider[provider] || [])
|
|
364
|
+
: (data && data.models) || [];
|
|
365
|
+
|
|
366
|
+
const summaryRows = (data && data.summary || []).map((r) => (
|
|
367
|
+
<tr key={r.key}>
|
|
368
|
+
<td>{r.key}</td><td>{fmtNum(r.calls)}</td><td>{fmtCompact(r.inputTokens)}</td>
|
|
369
|
+
<td>{fmtCompact(r.cacheReadTokens)}</td><td>{r.cacheHitPct + "%"}</td><td>{fmtCompact(r.outputTokens)}</td>
|
|
370
|
+
<td>{fmtCompact(r.totalTokens)}</td>
|
|
371
|
+
<td>{fmtCost(r.cost)}</td>
|
|
372
|
+
<td>{fmtCostCny(r.cost, rateCny)}</td>
|
|
373
|
+
<td>{fmtDuration(r.llmMs)}</td>
|
|
374
|
+
</tr>
|
|
375
|
+
));
|
|
376
|
+
|
|
377
|
+
const sortTh = (label, key) => (
|
|
378
|
+
<th onClick={() => toggleSort(key)} title="点击排序">
|
|
379
|
+
{label}{sortKey === key ? <span className="dtok-sort-mark">{sortDir === "asc" ? "▲" : "▼"}</span> : null}
|
|
380
|
+
</th>
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
const detailRows = pageRows.map((r) => {
|
|
384
|
+
const st = statusInfo(r);
|
|
385
|
+
return (
|
|
386
|
+
<tr key={r.id}>
|
|
387
|
+
<td>{fmtTime(r.time)}</td>
|
|
388
|
+
<td><span className="dtok-sid" title="点击按此会话筛选" onClick={() => { setSessionId(r.sessionId); runQuery(); }}>{shortId(r.sessionId)}</span></td>
|
|
389
|
+
<td>{r.provider || "—"}</td>
|
|
390
|
+
<td>{r.model || "—"}</td>
|
|
391
|
+
<td>{fmtNum(r.inputTokens)}</td>
|
|
392
|
+
<td>{fmtNum(r.cacheReadTokens)}</td>
|
|
393
|
+
<td>{r.cacheHitPercent + "%"}</td>
|
|
394
|
+
<td>{fmtNum(r.outputTokens)}</td>
|
|
395
|
+
<td>{fmtNum(r.reasoningTokens)}</td>
|
|
396
|
+
<td>{fmtNum(r.totalTokens)}</td>
|
|
397
|
+
<td>{fmtCost(r.cost)}</td>
|
|
398
|
+
<td>{fmtCostCny(r.cost, rateCny)}</td>
|
|
399
|
+
<td>{r.effort || "—"}</td>
|
|
400
|
+
<td>
|
|
401
|
+
<div><span className={"dtok-code " + st.cls}>{st.label}</span></div>
|
|
402
|
+
<div><a className="dtok-detail-link" onClick={() => setDetailRec(r)}>查看详情</a></div>
|
|
403
|
+
</td>
|
|
404
|
+
<td>{fmtDuration(r.llmMs)}</td>
|
|
405
|
+
</tr>
|
|
406
|
+
);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const bodyNodes = [];
|
|
410
|
+
if (err) bodyNodes.push(<div key="err" className="dtok-err">错误: {err}</div>);
|
|
411
|
+
if (cards.length) bodyNodes.push(
|
|
412
|
+
<div className="dtok-cards" key="cards">
|
|
413
|
+
{cards.map((c) => <div className="dtok-card" key={c.l}><div className="v">{c.v}</div><div className="l">{c.l}</div></div>)}
|
|
414
|
+
</div>
|
|
415
|
+
);
|
|
416
|
+
if (summaryRows.length) bodyNodes.push(
|
|
417
|
+
<div key="sum" className="dtok-section-title">统计分组: {dim || "无"} ({summaryRows.length} 组)</div>,
|
|
418
|
+
<div key="sumtab" className="dtok-table-wrap">
|
|
419
|
+
<table className="dtok-table">
|
|
420
|
+
<thead><tr>
|
|
421
|
+
<th>维度</th><th>调用</th><th>输入</th><th>缓存</th><th>命中率</th><th>输出</th><th>总Token</th><th>金额</th><th>金额(¥)</th><th>耗时</th>
|
|
422
|
+
</tr></thead>
|
|
423
|
+
<tbody>{summaryRows}</tbody>
|
|
424
|
+
</table>
|
|
425
|
+
</div>
|
|
426
|
+
);
|
|
427
|
+
bodyNodes.push(<div key="dimtitle" className="dtok-section-title">按维度统计</div>);
|
|
428
|
+
bodyNodes.push(
|
|
429
|
+
<div key="dimrow" className="dtok-filter" style={{ padding: "4px 0 0", border: "none", background: "transparent" }}>
|
|
430
|
+
{["", "provider", "model", "status", "effort"].map((d) => (
|
|
431
|
+
<button key={d} className={"dtok-btn" + (dim === d ? " primary" : "")} onClick={() => { setDim(d); runQuery(d); }}>{d === "" ? "无分组" : d}</button>
|
|
432
|
+
))}
|
|
433
|
+
</div>
|
|
434
|
+
);
|
|
435
|
+
bodyNodes.push(<div key="dettitle" className="dtok-section-title">调用明细</div>);
|
|
436
|
+
if (sorted.length === 0) {
|
|
437
|
+
bodyNodes.push(<div key="empty" className="dtok-empty">{loading ? "加载中…" : "无匹配记录"}</div>);
|
|
438
|
+
} else {
|
|
439
|
+
// 分页控件: 明细表上方+下方各一份, 免去翻页时滑到底部
|
|
440
|
+
const pager = (key, cls) => (
|
|
441
|
+
<div key={key} className={"dtok-pager " + cls}>
|
|
442
|
+
<button className="dtok-btn" disabled={pageSafe <= 0} onClick={() => setPage(pageSafe - 1)}>上一页</button>
|
|
443
|
+
<span>第 {pageSafe + 1} / {pageCount} 页 · 共 {sorted.length} 条</span>
|
|
444
|
+
<button className="dtok-btn" disabled={pageSafe >= pageCount - 1} onClick={() => setPage(pageSafe + 1)}>下一页</button>
|
|
445
|
+
</div>
|
|
446
|
+
);
|
|
447
|
+
bodyNodes.push(pager("pager-top", "top"));
|
|
448
|
+
bodyNodes.push(
|
|
449
|
+
<div key="detail" className="dtok-table-wrap">
|
|
450
|
+
<table className="dtok-table">
|
|
451
|
+
<thead><tr>
|
|
452
|
+
{sortTh("时间", "time")}{sortTh("会话ID", "sessionId")}{sortTh("提供商", "provider")}{sortTh("模型", "model")}
|
|
453
|
+
{sortTh("输入", "inputTokens")}{sortTh("缓存", "cacheReadTokens")}{sortTh("命中%", "cacheHitPercent")}{sortTh("输出", "outputTokens")}
|
|
454
|
+
{sortTh("推理", "reasoningTokens")}{sortTh("总额", "totalTokens")}{sortTh("金额", "cost")}{sortTh("金额(¥)", "cost")}{sortTh("强度", "effort")}
|
|
455
|
+
{sortTh("状态", "status")}{sortTh("耗时", "llmMs")}
|
|
456
|
+
</tr></thead>
|
|
457
|
+
<tbody>{detailRows}</tbody>
|
|
458
|
+
</table>
|
|
459
|
+
</div>
|
|
460
|
+
);
|
|
461
|
+
bodyNodes.push(pager("pager-bottom", "bottom"));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return (
|
|
465
|
+
<div className="dtok-root" onClick={(e) => e.stopPropagation()}>
|
|
466
|
+
<div className="dtok-status">
|
|
467
|
+
<span className="count">{loading ? "加载中…" : (data ? data.counts.matching + " / " + data.counts.total + " 条" : "")}</span>
|
|
468
|
+
<span>全屏请用面板右上角「最大化」;面板打开期间每 5 秒自动刷新</span>
|
|
469
|
+
</div>
|
|
470
|
+
<div className="dtok-filter">
|
|
471
|
+
<label>起</label>
|
|
472
|
+
<input className="dtok-input" type="datetime-local" step="1" value={fromStr} onChange={(e) => setFromStr(e.target.value)} />
|
|
473
|
+
<label>止</label>
|
|
474
|
+
<input className="dtok-input" type="datetime-local" step="1" value={toStr} onChange={(e) => setToStr(e.target.value)} />
|
|
475
|
+
<label>会话</label>
|
|
476
|
+
<select className="dtok-select" value={sessionId} onChange={(e) => setSessionId(e.target.value)}>
|
|
477
|
+
<option value="">全部</option>{opts(sessionIds)}
|
|
478
|
+
</select>
|
|
479
|
+
<label>提供商</label>
|
|
480
|
+
<select className="dtok-select" value={provider} onChange={(e) => { setProvider(e.target.value); setModel("") }}>
|
|
481
|
+
<option value="">全部</option>{opts(data && data.providers)}
|
|
482
|
+
</select>
|
|
483
|
+
<label>模型</label>
|
|
484
|
+
<select className="dtok-select" value={model} onChange={(e) => setModel(e.target.value)}>
|
|
485
|
+
<option value="">全部</option>{opts(modelChoices)}
|
|
486
|
+
</select>
|
|
487
|
+
<label>状态</label>
|
|
488
|
+
<select className="dtok-select" value={status} onChange={(e) => setStatus(e.target.value)}>
|
|
489
|
+
<option value="">全部</option>{opts(data && data.statuses)}
|
|
490
|
+
</select>
|
|
491
|
+
<label>推理强度</label>
|
|
492
|
+
<select className="dtok-select" value={effort} onChange={(e) => setEffort(e.target.value)}>
|
|
493
|
+
<option value="">全部</option>{opts(data && data.efforts)}
|
|
494
|
+
</select>
|
|
495
|
+
<button className="dtok-btn primary" onClick={runQuery}>查询</button>
|
|
496
|
+
<button className="dtok-btn" onClick={resetFilters}>重置</button>
|
|
497
|
+
<button className="dtok-btn" onClick={exportCsv}>导出 CSV</button>
|
|
498
|
+
</div>
|
|
499
|
+
<div className="dtok-body">{bodyNodes}</div>
|
|
500
|
+
{detailRec ? <Detail rec={detailRec} onClose={() => setDetailRec(null)} rate={rateCny} /> : null}
|
|
501
|
+
</div>
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ---------- 首页总揽卡片:今日调用 / 今日 Token / 今日花费 ----------
|
|
506
|
+
function todayStart() {
|
|
507
|
+
const d = new Date();
|
|
508
|
+
d.setHours(0, 0, 0, 0);
|
|
509
|
+
return d.getTime();
|
|
510
|
+
}
|
|
511
|
+
function TokenLogHomeStat() {
|
|
512
|
+
const [snap, setSnap] = useState({ totals: null, rate: 7.2, err: "" });
|
|
513
|
+
useEffect(() => {
|
|
514
|
+
let cancel = false;
|
|
515
|
+
const load = () => rpcCall("query", { from: todayStart() })
|
|
516
|
+
.then((d) => { if (!cancel) setSnap({ totals: d && d.totals, rate: (d && d.rateUsdCny) || 7.2, err: "" }); })
|
|
517
|
+
.catch((e) => { if (!cancel) setSnap((s) => ({ totals: s.totals, rate: s.rate, err: String((e && e.message) || e) })); });
|
|
518
|
+
load();
|
|
519
|
+
const timer = setInterval(load, 60000);
|
|
520
|
+
return () => { cancel = true; clearInterval(timer); };
|
|
521
|
+
}, []);
|
|
522
|
+
if (!snap.totals) return <span>{snap.err ? "用量查询失败(点击进入查看)" : "正在拉取今日用量…"}</span>;
|
|
523
|
+
const t = snap.totals;
|
|
524
|
+
return <span>今日 {fmtNum(t.calls)} 次调用 · {fmtCompact(t.totalTokens)} Token · {fmtCost(t.cost)}({fmtCostCny(t.cost, snap.rate)})</span>;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ---------- 会话输入区 chip:显示当前会话总 Token(10s 静默刷新),点击打开功能坞并按该会话筛选 ----------
|
|
528
|
+
export function TokenLogChip(props) {
|
|
529
|
+
const sid = (props && props.sessionId) || (props && props.session && props.session.sessionId) || null;
|
|
530
|
+
const [snap, setSnap] = useState({ totals: null, err: "" });
|
|
531
|
+
useEffect(() => {
|
|
532
|
+
if (!sid) return;
|
|
533
|
+
let cancel = false;
|
|
534
|
+
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) })); });
|
|
537
|
+
load();
|
|
538
|
+
const timer = setInterval(load, 10000);
|
|
539
|
+
return () => { cancel = true; clearInterval(timer); };
|
|
540
|
+
}, [sid]);
|
|
541
|
+
if (!sid) return null;
|
|
542
|
+
const t = snap.totals;
|
|
543
|
+
const label = snap.err && !t
|
|
544
|
+
? (snap.err.includes("重启") ? "用量·需重启宿主" : "用量·失败")
|
|
545
|
+
: (t ? "⛁ " + fmtCompact(t.totalTokens) + (t.cost > 0 ? " · " + fmtCost(t.cost) : "") : "⛁ …");
|
|
546
|
+
const title = t
|
|
547
|
+
? "本会话 " + fmtNum(t.calls) + " 次调用 · " + fmtNum(t.totalTokens) + " Token · " + fmtCost(t.cost) + "(估算)\n点击在功能坞查看用量记录"
|
|
548
|
+
: (snap.err ? snap.err + "\n" : "") + "点击在功能坞查看用量记录";
|
|
549
|
+
return (
|
|
550
|
+
<button type="button" className={"dockchip" + (snap.err && !t ? " err" : "")} title={title} aria-label="会话用量"
|
|
551
|
+
onClick={() => openPanel("tokenlog", { sessionId: sid })}>
|
|
552
|
+
<span className="dockchip-dot" style={{ background: "#fbbf24" }} />
|
|
553
|
+
<span>{label}</span>
|
|
554
|
+
</button>
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export const feature = {
|
|
559
|
+
id: "tokenlog",
|
|
560
|
+
name: "用量记录",
|
|
561
|
+
order: 110,
|
|
562
|
+
accent: "#fbbf24",
|
|
563
|
+
description: "记录全部 LLM API 调用:秒级时间筛选、Token/费用统计(峰谷计价+官网价目自动同步)、分组汇总、明细检索与 CSV 导出",
|
|
564
|
+
css,
|
|
565
|
+
View: TokenLogView,
|
|
566
|
+
HomeStat: TokenLogHomeStat,
|
|
567
|
+
Chip: TokenLogChip,
|
|
568
|
+
};
|