dsh-router-core 0.3.19 → 0.3.21

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/lib/index.js CHANGED
@@ -1573,7 +1573,16 @@ function normalizeSSEStream(stream) {
1573
1573
  function isDoneFrame(frame) {
1574
1574
  return /(^|\n)data:[ \t]*\[DONE\][ \t]*(\n|$)/.test(frame);
1575
1575
  }
1576
- /** 修一帧 SSE:只剥空的 name/id,出任何问题都原样返回。 */
1576
+ /**
1577
+ * 修一帧 SSE:剥掉**空的**(`''` 或 `null`)name/id,出任何问题都原样返回。
1578
+ *
1579
+ * 为什么 `null` 也算空:上游(OpenCode/Zen)在后续 delta 里会把首帧已给过的
1580
+ * id/name 显式重发成 `null`——`{"id":null,"function":{"name":null,"arguments":"{"}}`。
1581
+ * 下游判「有没有新值」用的是 `name !== undefined`(dsh 的 BlockAssembler 就是),
1582
+ * null 过不了这个判断、却也不该覆盖首帧:客户端会把已拿到的 "bash" 冲成 null,
1583
+ * 工具名丢失(表现为 `unknown tool ""`)。空串早就剥了,null 语义上同样是
1584
+ * 「本帧没给新值」,一并剥掉。
1585
+ */
1577
1586
  function fixFrame(frame) {
1578
1587
  if (!frame.includes("tool_calls")) return frame;
1579
1588
  return frame.split("\n").map((line) => {
@@ -1589,12 +1598,12 @@ function fixFrame(frame) {
1589
1598
  let changed = false;
1590
1599
  const tcs = (obj.choices?.[0]?.delta)?.tool_calls;
1591
1600
  if (Array.isArray(tcs)) for (const tc of tcs) {
1592
- if (tc.id === "") {
1601
+ if (tc.id === "" || tc.id === null) {
1593
1602
  delete tc.id;
1594
1603
  changed = true;
1595
1604
  }
1596
1605
  const fn = tc.function;
1597
- if (fn !== null && typeof fn === "object" && fn.name === "") {
1606
+ if (fn !== null && typeof fn === "object" && (fn.name === "" || fn.name === null)) {
1598
1607
  delete fn.name;
1599
1608
  changed = true;
1600
1609
  }
@@ -2733,9 +2742,9 @@ async function* translateSse(payloads) {
2733
2742
  blockType: "tool-call"
2734
2743
  };
2735
2744
  }
2736
- if (call.id !== void 0) block.callId = call.id;
2737
- if (call.function?.name !== void 0) block.name = call.function.name;
2738
- const fragment = call.function?.arguments ?? "";
2745
+ if (typeof call.id === "string" && call.id !== "") block.callId = call.id;
2746
+ if (typeof call.function?.name === "string" && call.function.name !== "") block.name = call.function.name;
2747
+ const fragment = typeof call.function?.arguments === "string" ? call.function.arguments : "";
2739
2748
  block.text += fragment;
2740
2749
  yield {
2741
2750
  type: "tool-call-delta",
@@ -4324,6 +4333,7 @@ function apply(rawContext) {
4324
4333
  id: e.id,
4325
4334
  name: e.name ?? e.id,
4326
4335
  ...e.description !== void 0 ? { description: e.description } : {},
4336
+ ...e.icon !== void 0 ? { icon: e.icon } : {},
4327
4337
  enabled: extStore.isEnabled(e.id),
4328
4338
  ready: st?.ready === true,
4329
4339
  ...st?.detail !== void 0 ? { detail: st.detail } : {}
@@ -4331,6 +4341,32 @@ function apply(rawContext) {
4331
4341
  }).filter((e) => e !== void 0)
4332
4342
  });
4333
4343
  });
4344
+ const extDispatch = ctx.webServer.register({
4345
+ kind: "prefix",
4346
+ path: `${ROUTER_API_BASE}/ext/`,
4347
+ handler: async (req, res) => {
4348
+ const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
4349
+ const mount = `${ROUTER_API_BASE}/ext/`;
4350
+ const rest = pathname.startsWith(mount) ? pathname.slice(mount.length) : "";
4351
+ const slash = rest.indexOf("/");
4352
+ const extId = slash < 0 ? rest : rest.slice(0, slash);
4353
+ const subpath = slash < 0 ? "" : rest.slice(slash + 1);
4354
+ if (extId === "") {
4355
+ writeJson(res, 404, {
4356
+ ok: false,
4357
+ error: "extension not found"
4358
+ });
4359
+ return;
4360
+ }
4361
+ const ext = exts[extId];
4362
+ if (ext !== void 0 && typeof ext.api === "function" && await ext.api(subpath, req, res)) return;
4363
+ writeJson(res, 404, {
4364
+ ok: false,
4365
+ error: "extension api not found"
4366
+ });
4367
+ }
4368
+ });
4369
+ disposers.push(extDispatch);
4334
4370
  /** 解析并校验周期参数(不接受任意字符串)。 */
4335
4371
  const periodOf = (raw) => {
4336
4372
  return raw === "24h" || raw === "7d" || raw === "30d" ? raw : "today";
@@ -1,16 +1,110 @@
1
1
  import { t as stateFromHttpStatus } from "../http-state-CGDexmJf.js";
2
+ import { randomBytes } from "node:crypto";
2
3
  //#region src/suppliers/opencode/plugin.ts
4
+ /**
5
+ * opencode 供应商插件 —— 参考 9Router(open-sse) 的 opencode free 实现。
6
+ *
7
+ * 上游:https://opencode.ai/zen/v1(OpenAI 兼容,免费档)
8
+ * - chat: POST /zen/v1/chat/completions(**只能流式**)
9
+ * - models: GET /zen/v1/models(透传,只取免费模型)
10
+ *
11
+ * 「免费」不等于「无鉴权」:Zen 免费档仍要求**模拟真 CLI 的握手**,否则一律
12
+ * 403 `FreeTierError: OpenCode's free tier can only be used from within OpenCode`
13
+ * (2026-09-22 实测)。三个条件缺一即 403,见下方 `UA` / `sessionId` / `decoyTools`。
14
+ * 免费档也没有账号可填 —— 所以无凭证/连接池/签到,面板只显示模型卡片
15
+ * (连接池、签到由能力检测自动隐藏)。
16
+ */
3
17
  const id = "opencode";
4
- const name = "OpenCode Free";
18
+ const name = "OpenCode";
5
19
  const priority = 0;
6
20
  /** 面板图标。 */
7
21
  const icon = "data:image/png;base64,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";
8
22
  const BASE = "https://opencode.ai/zen/v1";
9
23
  const MODELS_URL = `${BASE}/models`;
10
24
  const CHAT_URL = `${BASE}/chat/completions`;
11
- const UA = "opencode";
25
+ /**
26
+ * User-Agent 必须带**版本号**且 ≥ 1.18。
27
+ *
28
+ * 裸 `opencode` → 403 FreeTierError;`opencode/1.17.x` → **426 UpgradeRequired**
29
+ * (上游明确要求升级客户端);`opencode/1.18.0` 起通过。别去掉版本号,
30
+ * 也别写死成能过的最高版本——取一个已实测通过的稳定值即可。
31
+ */
32
+ const UA = "opencode/1.18.31";
33
+ /**
34
+ * 免费档按 session 记账(每次生成一个新的即可——见 `chatOnce` 的实测说明),
35
+ * `x-opencode-session` / `x-opencode-request` **必须**是上游认的形状,格式不对
36
+ * 同样 403(不是忽略,是拒绝):
37
+ * ses_<12 位小写 hex><14 位 base62>、msg_<同形状>
38
+ */
39
+ function newSessionId(prefix) {
40
+ const B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
41
+ const bytes = randomBytes(20);
42
+ let time = "";
43
+ for (let i = 0; i < 6; i++) time += bytes[i].toString(16).padStart(2, "0");
44
+ let rand = "";
45
+ for (let i = 6; i < 20; i++) rand += B62[bytes[i] % 62] ?? "";
46
+ return `${prefix}${time}${rand}`;
47
+ }
48
+ /**
49
+ * 每次请求一对新的 session/request id。
50
+ *
51
+ * 不做会话池的实测依据(2026-09-22):每次新 session 与固定 session 各打 40 次
52
+ * **全部 200**,且带大前缀时上游缓存命中数完全相同(都 2560 cached)——
53
+ * 9router 的 `stableSessionId` 在这里没有可测收益,属未经验证的复杂度。
54
+ */
55
+ function sessionHeaders() {
56
+ return {
57
+ "x-opencode-session": newSessionId("ses_"),
58
+ "x-opencode-request": newSessionId("msg_")
59
+ };
60
+ }
61
+ /**
62
+ * 免费档要求 tools 里**同时**存在 `bash` 和 `read`,否则 403。
63
+ *
64
+ * 为什么是「注入」而不是「覆盖」:上游只是**校验这两个名字在不在**,真正的
65
+ * 工具照常工作(实测真实工具在前、占位在后,混合下发 200)。所以调用方已经
66
+ * 带了同名工具时保持原样不动,缺哪个补哪个——绝不覆盖调用方自己的定义,
67
+ * 否则会把真实工具签名糊掉。
68
+ */
69
+ const DECOY_TOOLS = [{
70
+ name: "bash",
71
+ description: "This tool is currently unavailable and must not be used."
72
+ }, {
73
+ name: "read",
74
+ description: "This tool is currently unavailable and must not be used."
75
+ }];
12
76
  /** 免费模型识别:id 以 -free 结尾,或已知不带后缀的免费模型。 */
13
77
  const KNOWN_FREE = /* @__PURE__ */ new Set(["big-pickle"]);
78
+ /**
79
+ * 补上上游免费档要求的占位工具(幂等:已存在同名工具就跳过)。
80
+ *
81
+ * 只认 `function.name`(OpenAI chat 形态)——Zen 免费档是 chat/completions,
82
+ * 不走 responses 的扁平 `name` 形态。
83
+ */
84
+ function ensureDecoyTools(obj) {
85
+ const tools = Array.isArray(obj.tools) ? obj.tools : [];
86
+ const names = /* @__PURE__ */ new Set();
87
+ for (const t of tools) {
88
+ const fn = t?.function;
89
+ const n = typeof fn?.name === "string" ? fn.name : typeof t?.name === "string" ? t.name : "";
90
+ if (n !== "") names.add(n);
91
+ }
92
+ for (const d of DECOY_TOOLS) {
93
+ if (names.has(d.name)) continue;
94
+ tools.push({
95
+ type: "function",
96
+ function: {
97
+ name: d.name,
98
+ description: d.description,
99
+ parameters: {
100
+ type: "object",
101
+ properties: {}
102
+ }
103
+ }
104
+ });
105
+ }
106
+ obj.tools = tools;
107
+ }
14
108
  function factory(env) {
15
109
  let modelsCache;
16
110
  /** 上次 chatOnce 失败原因(供核心测试模型汇总诊断)。 */
@@ -75,6 +169,8 @@ function factory(env) {
75
169
  try {
76
170
  const obj = JSON.parse(body);
77
171
  obj.model = stripAlias(String(obj.model ?? req.model), currentAlias());
172
+ obj.stream = true;
173
+ ensureDecoyTools(obj);
78
174
  if (lv !== "auto" && lv !== "" && lv !== "none" && lv !== "off") obj.reasoning_effort = lv;
79
175
  else if (lv === "none" || lv === "off") {
80
176
  delete obj.reasoning_effort;
@@ -90,7 +186,8 @@ function factory(env) {
90
186
  "Content-Type": "application/json",
91
187
  Authorization: "Bearer public",
92
188
  "User-Agent": UA,
93
- "x-opencode-client": "desktop"
189
+ "x-opencode-client": "desktop",
190
+ ...sessionHeaders()
94
191
  },
95
192
  body,
96
193
  signal: AbortSignal.timeout(12e4)
@@ -111,27 +208,14 @@ function factory(env) {
111
208
  message: msg
112
209
  };
113
210
  }
114
- if (req.stream) {
115
- if (!upstream.body) return {
116
- ok: false,
117
- state: "transport",
118
- message: "opencode upstream: empty stream body"
119
- };
120
- return {
121
- ok: true,
122
- stream: upstream.body
123
- };
124
- }
125
- const text = await upstream.text().catch(() => "");
126
- if (text === "") return {
211
+ if (!upstream.body) return {
127
212
  ok: false,
128
213
  state: "transport",
129
- message: "opencode upstream: empty body"
214
+ message: "opencode upstream: empty stream body"
130
215
  };
131
216
  return {
132
217
  ok: true,
133
- status: upstream.status,
134
- body: text
218
+ stream: upstream.body
135
219
  };
136
220
  },
137
221
  dispose: () => {
@@ -25,6 +25,7 @@
25
25
  * file IO —— 落盘位置由核心锚定(不跟 cwd 跑),读写经核心,插件无状态。
26
26
  */
27
27
  import type { Context } from '@deepseek-ai/cordis';
28
+ import type { IncomingMessage, ServerResponse } from 'node:http';
28
29
  /** 扩展器唯一 id(注册键)。 */
29
30
  export type ExtId = string;
30
31
  /**
@@ -50,8 +51,16 @@ export interface RouterExt {
50
51
  readonly name: string;
51
52
  /** 面板副标题说明。 */
52
53
  readonly description?: string;
54
+ /** 卡片/详情图标的 logo URL(可选;缺省用默认图标)。 */
55
+ readonly icon?: string;
53
56
  /** 当前运行时状态(ready + 不就绪时的说明)。 */
54
57
  getState(): ExtState;
58
+ /**
59
+ * 通用扩展 API 点(可选):核心把 `/router/api/ext/{id}/{subpath}` 兜底路由派发进来,
60
+ * 由扩展器自己裁决/处理(如 watch 的 `processes`/`kill`)。返回 true 表示已处理;
61
+ * false / 未实现 → 核心回 404。这样扩展的执行面逻辑进插件,核心只加一条通用转发。
62
+ */
63
+ api?(subpath: string, req: IncomingMessage, res: ServerResponse): boolean | Promise<boolean>;
55
64
  /** 卸载清理(表删除时由 dsh-router 调用,可选)。 */
56
65
  dispose?(): void;
57
66
  }
@@ -60,6 +69,8 @@ export interface ExtInfo {
60
69
  id: string;
61
70
  name: string;
62
71
  description?: string;
72
+ /** 卡片/详情图标 logo URL(可选)。 */
73
+ icon?: string;
63
74
  /** 核心持久化的开关(默认关)。 */
64
75
  enabled: boolean;
65
76
  /** 插件报的运行时就绪状态。 */
@@ -136,6 +136,8 @@ export interface RouterExtItem {
136
136
  id: string;
137
137
  name: string;
138
138
  description?: string;
139
+ /** 卡片/详情图标 logo URL(可选)。 */
140
+ icon?: string;
139
141
  /** 是否启用改写(面板开关)。 */
140
142
  enabled?: boolean;
141
143
  /** 运行时是否就绪(如 RTK 二进制是否可用)。 */
@@ -149,6 +151,23 @@ export interface RouterExtResponse {
149
151
  error?: string;
150
152
  enhancers?: RouterExtItem[];
151
153
  }
154
+ /** 一行 watch 进程(`/router/api/ext/watch/processes`)。 */
155
+ export interface RouterWatchProcess {
156
+ pid: number;
157
+ ppid: number;
158
+ /** %cpu 快照 */ cpu: number;
159
+ /** %mem 快照 */ mem: number;
160
+ command: string;
161
+ /** 命中的已记录 agent 命令。 */
162
+ match: string;
163
+ recordedAt: number;
164
+ }
165
+ /** `/router/api/ext/{id}/processes` response。 */
166
+ export interface RouterWatchResponse {
167
+ ok: boolean;
168
+ error?: string;
169
+ processes?: RouterWatchProcess[];
170
+ }
152
171
  /** 供应商模型(含启用状态)。 */
153
172
  export interface RouterSupplierModel {
154
173
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.3.19",
3
+ "version": "0.3.21",
4
4
  "description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,257 @@
1
+ /**
2
+ * 扩展详情页 —— 从设置 → 路由 → 扩展 的卡片点进来。
3
+ *
4
+ * 通用:返回键 + 扩展名/说明/就绪态。`watch` 扩展再渲染进程监控面板:
5
+ * 实时采样「agent 启动的进程」,每行 PID / 命令 / CPU% / 内存%,**只读**——不提供
6
+ * kill(杀进程是大事,交由用户在 CLI 自己操作)。面板提供「复制 PID」和
7
+ * 「复制 kill 命令」两个剪贴板动作方便用户去终端处理。
8
+ * 进程数据走后端 `/router/api/ext/:id/processes`;`ps` 瞬时失败时保留上一帧,别刷成
9
+ * 整屏错误。
10
+ */
11
+ import { useEffect, useRef, useState } from 'react'
12
+ import {
13
+ ROUTER_API_BASE,
14
+ type RouterExtItem,
15
+ type RouterWatchProcess,
16
+ type RouterWatchResponse,
17
+ } from '../shared.ts'
18
+
19
+ function Icon({ d, size = 18 }: { d: string; size?: number }): JSX.Element {
20
+ return (
21
+ <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
22
+ <path d={d} />
23
+ </svg>
24
+ )
25
+ }
26
+
27
+ const I = {
28
+ back: 'M19 12H5M12 19l-7-7 7-7',
29
+ refresh: 'M20.5 12a8.5 8.5 0 1 1-2.6-6.1M20.5 4v5h-5',
30
+ node: 'M8 5.5L12 3l4 2.5v5L12 13l-4-2.5v-5zM4 9v5l4 2.5M12 13v5l4-2.5M20 9v5l-4 2.5',
31
+ bolt: 'M13 2L4 14h6l-1 8 9-12h-6z',
32
+ copy: 'M8 8h11v11H8zM5 14H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1',
33
+ check: 'M4 12.5l5 5L20 6.5',
34
+ }
35
+
36
+ /** 刷新按钮(带禁用中旋转)。 */
37
+ function RefreshBtn({ onClick, busy }: { onClick: () => void; busy: boolean }): JSX.Element {
38
+ return (
39
+ <button
40
+ type="button"
41
+ className="dshr-iconButton"
42
+ onClick={onClick}
43
+ disabled={busy}
44
+ title="刷新进程"
45
+ aria-label="刷新进程"
46
+ >
47
+ <span className={busy ? 'dshr-spin' : undefined}><Icon d={I.refresh} size={16} /></span>
48
+ </button>
49
+ )
50
+ }
51
+
52
+ function fmtPct(v: number | undefined): string {
53
+ const n = v ?? 0
54
+ return `${n.toFixed(1)}%`
55
+ }
56
+
57
+ interface ExtDetailProps {
58
+ item: RouterExtItem
59
+ onBack: () => void
60
+ /** 开关切换(由列表页持有状态与 PATCH;这里只做触发)。 */
61
+ onToggle: (item: RouterExtItem, value: boolean) => void
62
+ }
63
+
64
+ export function ExtDetail({ item, onBack, onToggle }: ExtDetailProps): JSX.Element {
65
+ return (
66
+ <div className="dshr-tabBody">
67
+ {/* Header(同供应商详情:返回链接 + 图标 + 名称/副行) */}
68
+ <div className="dshr-providerHead">
69
+ <button type="button" className="dshr-backLink" onClick={onBack}>
70
+ <Icon d={I.back} size={16} />
71
+ 返回
72
+ </button>
73
+ <div className="dshr-providerTitleRow">
74
+ <div className="dshr-providerIcon" style={{ color: 'var(--rs-faint)', background: 'var(--rs-layer-2)' }}>
75
+ <Icon d={I.bolt} size={22} />
76
+ </div>
77
+ <div className="dshr-providerMeta">
78
+ <h1 className="dshr-providerName">{item.name}</h1>
79
+ <p className="dshr-providerCount">
80
+ <span className="dshr-mono">{item.id}</span>
81
+ {item.enabled ? ' · 已启用' : ' · 未启用'}
82
+ {item.ready === false ? ' · 未就绪' : ''}
83
+ </p>
84
+ </div>
85
+ {/* 开关放在标题行右侧(同供应商详情的编辑按钮位) */}
86
+ <button
87
+ type="button"
88
+ className={`dshr-toggle dshr-extDetailToggle ${item.enabled ? 'dshr-toggle-on' : ''}`}
89
+ role="switch"
90
+ aria-checked={!!item.enabled}
91
+ disabled={item.ready === false && !item.enabled}
92
+ onClick={() => void onToggle(item, !item.enabled)}
93
+ title={item.ready === false && !item.enabled
94
+ ? '当前不可用,无法开启'
95
+ : item.enabled ? `关闭 ${item.name}` : `开启 ${item.name}`}
96
+ >
97
+ <span className="dshr-toggleKnob" />
98
+ </button>
99
+ </div>
100
+ </div>
101
+
102
+ {/* 扩展说明 */}
103
+ {item.description !== undefined && item.description !== '' && (
104
+ <section className="dshr-card">
105
+ <div className="dshr-muted" style={{ padding: '12px 14px' }}>{item.description}</div>
106
+ </section>
107
+ )}
108
+
109
+ {/* 未就绪警示(同供应商详情的出错横幅) */}
110
+ {item.ready === false && (
111
+ <div className="dshr-alert">
112
+ <strong>未就绪</strong>
113
+ <span>{item.detail !== undefined && item.detail !== '' ? item.detail : '当前不可用'}</span>
114
+ </div>
115
+ )}
116
+
117
+ {item.id === 'watch' ? <WatchPanel /> : null}
118
+ </div>
119
+ )
120
+ }
121
+
122
+ /** watch 的进程监控面板:轮询采样(只读)+ 复制 PID / 复制 kill 命令。 */
123
+ function WatchPanel(): JSX.Element {
124
+ const [processes, setProcesses] = useState<RouterWatchProcess[]>([])
125
+ const [error, setError] = useState('')
126
+ const [loading, setLoading] = useState(true)
127
+ // 刚复制成的那条(pid + 文本),显示「已复制」提示,1.2s 后清除。
128
+ const [copied, setCopied] = useState<{ pid: number; text: string } | null>(null)
129
+ const alive = useRef(true)
130
+ const now = useRef(Date.now())
131
+
132
+ useEffect(() => {
133
+ alive.current = true
134
+ return () => { alive.current = false }
135
+ }, [])
136
+
137
+ const load = async (): Promise<void> => {
138
+ try {
139
+ const res = await fetch(`${ROUTER_API_BASE}/ext/watch/processes`, { cache: 'no-store' })
140
+ if (!res.ok) {
141
+ // 尽量把后端的原因露出来(如「extension not found」/「extension api not found」),
142
+ // 别只剩 `HTTP 404`——它分不清是扩展没装、还是 api 没命中。
143
+ let reason = ''
144
+ try {
145
+ const err = await res.json() as { error?: string }
146
+ if (err.error) reason = ` — ${err.error}`
147
+ } catch { /* 非 JSON 响应体,忽略 */ }
148
+ if (alive.current) setError(`HTTP ${res.status}${reason}`)
149
+ return
150
+ }
151
+ // 响应体非 JSON(历史上 404 空 body 会在这里抛 Unexpected end of JSON input)。
152
+ // 分开 try,别把"非 JSON"混进网络错误的文案里。
153
+ let data: RouterWatchResponse
154
+ try {
155
+ data = await res.json() as RouterWatchResponse
156
+ } catch {
157
+ if (alive.current) setError('响应不是 JSON')
158
+ return
159
+ }
160
+ if (!alive.current) return
161
+ setError(data.ok ? '' : (data.error ?? '加载进程失败'))
162
+ if (data.ok && data.processes !== undefined) setProcesses(data.processes) // ?: 失败保留上一帧
163
+ } catch (err) {
164
+ if (alive.current) setError((err as Error).message) // 失败保留上一帧
165
+ } finally {
166
+ if (alive.current) setLoading(false)
167
+ }
168
+ }
169
+
170
+ // 进入面板后轮询采样(3s;离开卸载)。
171
+ useEffect(() => {
172
+ void load()
173
+ const timer = setInterval(() => { void load() }, 3000)
174
+ return () => clearInterval(timer)
175
+ // eslint-disable-next-line react-hooks/exhaustive-deps
176
+ }, [])
177
+
178
+ const copy = (pid: number, text: string): void => {
179
+ const stamp = Date.now()
180
+ now.current = stamp
181
+ void navigator.clipboard.writeText(text)
182
+ .then(() => {
183
+ if (alive.current && now.current === stamp) {
184
+ setCopied({ pid, text })
185
+ setTimeout(() => { if (alive.current) setCopied((c) => c?.pid === pid ? null : c) }, 1200)
186
+ }
187
+ })
188
+ .catch(() => { if (alive.current) setError('复制失败') })
189
+ }
190
+
191
+ return (
192
+ <section className="dshr-card">
193
+ <div className="dshr-cardHead">
194
+ <span className="dshr-cardIcon"><Icon d={I.node} /></span>
195
+ <div className="dshr-cardTitle">运行进程</div>
196
+ <span className="dshr-cardMeta">agent 启动 · 只读 · 3s 采样</span>
197
+ <RefreshBtn onClick={() => void load()} busy={loading} />
198
+ </div>
199
+
200
+ {error !== '' && (
201
+ <div className="dshr-alert"><strong>采样失败</strong><span>{error}(保留上一帧)</span></div>
202
+ )}
203
+
204
+ {!loading && processes.length === 0 ? (
205
+ <div className="dshr-empty">
206
+ 暂无会话中 agent 启动的进程。运行诸如 <code>node server.js</code> 的长驻命令后
207
+ 会出现在这里。
208
+ </div>
209
+ ) : (
210
+ <div className="dshr-rankList">
211
+ <div className="dshr-rankRow dshr-rankHead">
212
+ <span className="dshr-rankName">PID / 命令</span>
213
+ <span className="dshr-rankNum">CPU</span>
214
+ <span className="dshr-rankNum">内存</span>
215
+ <span className="dshr-rankNum" />
216
+ </div>
217
+ {processes.map((p) => {
218
+ const done = copied?.pid === p.pid
219
+ return (
220
+ <div key={p.pid} className="dshr-rankRow">
221
+ <span className="dshr-rankName dshr-mono" title={p.command}>
222
+ <span className="dshr-muted">{p.pid}</span>&ensp;{p.command}
223
+ </span>
224
+ <span className="dshr-rankNum">{fmtPct(p.cpu)}</span>
225
+ <span className="dshr-rankNum">{fmtPct(p.mem)}</span>
226
+ <span className="dshr-rankNum dshr-procOps">
227
+ {done
228
+ ? <span className="dshr-muted"><Icon d={I.check} size={13} /> 已复制</span>
229
+ : (
230
+ <>
231
+ <button
232
+ type="button"
233
+ className="dshr-miniButton"
234
+ onClick={() => copy(p.pid, String(p.pid))}
235
+ title={`复制 PID ${p.pid}`}
236
+ >
237
+ <Icon d={I.copy} size={13} /> PID
238
+ </button>
239
+ <button
240
+ type="button"
241
+ className="dshr-miniButton"
242
+ onClick={() => copy(p.pid, `kill -TERM ${p.pid}`)}
243
+ title={`复制 kill -TERM ${p.pid}`}
244
+ >
245
+ <Icon d={I.copy} size={13} /> kill
246
+ </button>
247
+ </>
248
+ )}
249
+ </span>
250
+ </div>
251
+ )
252
+ })}
253
+ </div>
254
+ )}
255
+ </section>
256
+ )
257
+ }