@ryubyte/dsh-a2a 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,613 @@
1
+ /**
2
+ * dsh-a2a — browser half.
3
+ *
4
+ * Contributes the "A2A 连接" settings section: a dashboard showing who is
5
+ * connected TO this DSH (inbound peers) and who this DSH is connected TO
6
+ * (outbound agents), with reconnect / close controls per connection.
7
+ *
8
+ * The data and the controls live behind the host's `/a2a/api` routes
9
+ * (served by the node half on the same webserver); the browser half is a
10
+ * thin read/write client with no protocol knowledge.
11
+ */
12
+
13
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
14
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client';
15
+ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
16
+ import { createElement, useEffect, useState, type ReactElement } from 'react';
17
+
18
+ /** Services this plugin needs from the client runtime. */
19
+ export const inject = ['slots'];
20
+
21
+ export function apply(ctx: ClientContext): void {
22
+ ctx.effect(() => {
23
+ try {
24
+ const off = ctx.slots.register(
25
+ {
26
+ name: 'settings.section',
27
+ id: 'a2a',
28
+ order: 90,
29
+ label: () => 'A2A 连接',
30
+ inject: () => ({}),
31
+ },
32
+ DashboardSection,
33
+ );
34
+ return () => off();
35
+ } catch (err) {
36
+ console.error('[dsh-a2a] failed to register settings section:', err);
37
+ return () => {};
38
+ }
39
+ }, 'dsh-a2a: settings section');
40
+ }
41
+
42
+ // ── wire types (mirror the host dashboard.ts) ──────────────────────────────
43
+
44
+ interface InboundPeerApi {
45
+ id: string;
46
+ label: string;
47
+ source?: string;
48
+ firstSeen: string;
49
+ lastSeen: string;
50
+ taskCount: number;
51
+ activeTaskIds: string[];
52
+ streaming: boolean;
53
+ }
54
+
55
+ interface OutboundAgentApi {
56
+ id: string;
57
+ name: string;
58
+ agentCardUrl: string;
59
+ agentName?: string;
60
+ skillCount: number;
61
+ toolCount: number;
62
+ state: 'connected' | 'disconnected' | 'reconnecting';
63
+ lastSeen: string;
64
+ /** Stable connection id the control actions operate on. */
65
+ connectionId?: string;
66
+ /** True when this connection comes from the profile config. */
67
+ configured?: boolean;
68
+ /** False when this agent is configured but disabled (no live connection). */
69
+ enabled?: boolean;
70
+ /** Skills advertised by the remote AgentCard. */
71
+ skills?: Array<{ id: string; name: string; description?: string; tags?: string[] }>;
72
+ }
73
+
74
+ interface SnapshotApi {
75
+ inbound: InboundPeerApi[];
76
+ outbound: OutboundAgentApi[];
77
+ at: number;
78
+ }
79
+
80
+ interface ControlResultApi {
81
+ ok: boolean;
82
+ message: string;
83
+ }
84
+
85
+ async function fetchSnapshot(): Promise<SnapshotApi> {
86
+ const res = await fetch('/a2a/api', { headers: { accept: 'application/json' } });
87
+ if (!res.ok) throw new Error(`GET /a2a/api → HTTP ${res.status}`);
88
+ return (await res.json()) as SnapshotApi;
89
+ }
90
+
91
+ async function postControl(action: string, target: string, payload?: Record<string, unknown>): Promise<ControlResultApi> {
92
+ const res = await fetch('/a2a/api', {
93
+ method: 'POST',
94
+ headers: { 'content-type': 'application/json' },
95
+ body: JSON.stringify({ action, target, ...payload }),
96
+ });
97
+ const body = (await res.json()) as ControlResultApi;
98
+ return body;
99
+ }
100
+
101
+ type SectionProps = PropsRuntime<'settings.section'>;
102
+
103
+ /** The A2A connection dashboard settings section. */
104
+ export function DashboardSection(_props: SectionProps): ReactElement {
105
+ // ── state (all hooks at the top, fixed order; helper components below
106
+ // never call hooks — otherwise React #310 crashes the whole slot) ──────
107
+ const [snap, setSnap] = useState<SnapshotApi | null>(null);
108
+ const [error, setError] = useState<string | null>(null);
109
+ const [busy, setBusy] = useState<string | null>(null);
110
+ const [notice, setNotice] = useState<string | null>(null);
111
+ const [serve, setServe] = useState<ServeStatus | null>(null);
112
+ const [addUrl, setAddUrl] = useState('');
113
+ const [discovering, setDiscovering] = useState(false);
114
+ const [discovered, setDiscovered] = useState<DiscoveredCard | null>(null);
115
+ const [tab, setTab] = useState<'out' | 'in' | 'serve'>('out');
116
+ /** connection ids whose skill list is expanded in the outbound tab. */
117
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
118
+
119
+ interface ServeStatus {
120
+ enabled: boolean;
121
+ baseUrl?: string;
122
+ agentName?: string;
123
+ agentDescription?: string;
124
+ agentVersion?: string;
125
+ endpoint?: string;
126
+ agentCardUrl?: string;
127
+ skills?: Array<{ id: string; name: string; description?: string }>;
128
+ customExecutor?: boolean;
129
+ }
130
+
131
+ const refresh = async (): Promise<void> => {
132
+ try {
133
+ setError(null);
134
+ setSnap(await fetchSnapshot());
135
+ const sres = await fetch('/a2a/api', {
136
+ method: 'POST',
137
+ headers: { 'content-type': 'application/json' },
138
+ body: JSON.stringify({ action: 'server-status' }),
139
+ });
140
+ const sbody = (await sres.json()) as ControlResultApi;
141
+ if (sbody.ok && sbody.message) setServe(JSON.parse(sbody.message) as ServeStatus);
142
+ else setServe(null);
143
+ } catch (err) {
144
+ setError(err instanceof Error ? err.message : String(err));
145
+ }
146
+ };
147
+
148
+ // initial load + 3s polling (live view)
149
+ useEffect(() => {
150
+ void refresh();
151
+ const timer = setInterval(() => void refresh(), 3000);
152
+ return () => clearInterval(timer);
153
+ }, []);
154
+
155
+ const act = async (action: string, target: string, payload?: Record<string, unknown>): Promise<void> => {
156
+ setBusy(`${action}:${target}`);
157
+ setNotice(null);
158
+ try {
159
+ const result = await postControl(action, target, payload);
160
+ setNotice(`${result.ok ? '✓' : '✗'} ${result.message}`);
161
+ } catch (err) {
162
+ setNotice(`✗ ${err instanceof Error ? err.message : String(err)}`);
163
+ } finally {
164
+ setBusy(null);
165
+ await refresh();
166
+ }
167
+ };
168
+
169
+ const inbound = snap?.inbound ?? [];
170
+ const outbound = snap?.outbound ?? [];
171
+
172
+ interface DiscoveredCard {
173
+ name: string;
174
+ description?: string;
175
+ version?: string;
176
+ agentCardUrl: string;
177
+ endpoint?: string;
178
+ skills?: Array<{ id: string; name: string; description?: string }>;
179
+ capabilities?: Record<string, unknown>;
180
+ defaultInputModes?: string[];
181
+ defaultOutputModes?: string[];
182
+ }
183
+
184
+ const doDiscover = async (): Promise<void> => {
185
+ const u = addUrl.trim();
186
+ if (!u) return;
187
+ setDiscovering(true);
188
+ setDiscovered(null);
189
+ setNotice(null);
190
+ try {
191
+ const res = await fetch('/a2a/api', {
192
+ method: 'POST',
193
+ headers: { 'content-type': 'application/json' },
194
+ body: JSON.stringify({ action: 'discover-agent', agentCardUrl: u }),
195
+ });
196
+ const body = (await res.json()) as ControlResultApi;
197
+ if (!body.ok || !body.message) {
198
+ setNotice(`✗ ${body.message}`);
199
+ return;
200
+ }
201
+ setDiscovered(JSON.parse(body.message) as DiscoveredCard);
202
+ } catch (err) {
203
+ setNotice(`✗ ${err instanceof Error ? err.message : String(err)}`);
204
+ } finally {
205
+ setDiscovering(false);
206
+ }
207
+ };
208
+
209
+ const doConnect = async (): Promise<void> => {
210
+ if (!discovered) return;
211
+ const n = discovered.name.trim() || 'remote';
212
+ await act('add-agent', n, { agentCardUrl: discovered.agentCardUrl });
213
+ if (!busy) {
214
+ setAddUrl('');
215
+ setDiscovered(null);
216
+ }
217
+ };
218
+
219
+ const inputStyle = { padding: '4px 8px', fontSize: 13, borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.3))', background: 'var(--dsw-alias-fill-l1, rgba(127,127,127,.06))', color: 'var(--dsw-alias-label-primary, #222)' };
220
+
221
+ return createElement(
222
+ 'div',
223
+ { style: { display: 'flex', flexDirection: 'column', gap: 20 } },
224
+ // ── intro ─────────────────────────────────────────────
225
+ createElement(
226
+ 'div',
227
+ { style: { color: 'var(--dsw-alias-label-secondary, #666)', fontSize: 13, lineHeight: 1.6 } },
228
+ '管理本 DSH 的 A2A Agent 连接:连接远程 Agent(出站),查看谁在调用本 DSH 的 A2A 服务(入站)。',
229
+ ),
230
+ error
231
+ ? createElement(
232
+ 'div',
233
+ { style: { color: 'var(--dsw-danger, #c62828)', fontSize: 13, background: 'var(--dsw-alias-fill-l2, rgba(127,127,127,.08))', borderRadius: 8, padding: '8px 12px' } },
234
+ `加载失败:${error}`,
235
+ )
236
+ : null,
237
+ notice
238
+ ? createElement(
239
+ 'div',
240
+ { style: { color: 'var(--dsw-alias-label-primary, #222)', fontSize: 13, background: 'var(--dsw-alias-fill-l2, rgba(127,127,127,.08))', borderRadius: 8, padding: '8px 12px' } },
241
+ notice,
242
+ )
243
+ : null,
244
+ // ── tabs (underline style, same as the 插件 settings page) ─────────
245
+ tabRail([
246
+ tabBtn('出站 Agent', outbound.length, tab === 'out', () => setTab('out')),
247
+ tabBtn('入站连接', inbound.length, tab === 'in', () => setTab('in')),
248
+ serve ? tabBtn('A2A 服务', 0, tab === 'serve', () => setTab('serve')) : null,
249
+ ]),
250
+ // ── outbound tab ──────────────────────────────────────
251
+ tab === 'out'
252
+ ? createElement(
253
+ 'div',
254
+ { style: { display: 'flex', flexDirection: 'column', gap: 16 } },
255
+ // Connected agents (cards)
256
+ createElement(
257
+ 'div',
258
+ { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
259
+ sec('已连接 Agent', outbound.length),
260
+ outbound.length === 0
261
+ ? emptyRow('暂无已连接的 Agent。使用下方"添加 Agent"通过 Agent Card URL 连接远程 A2A Agent。')
262
+ : createElement(
263
+ 'div',
264
+ { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
265
+ ...outbound.map((a) => agentCard(a, busy, act, expanded, setExpanded)),
266
+ ),
267
+ ),
268
+ // Add agent (discover → connect)
269
+ createElement(
270
+ 'div',
271
+ { style: { display: 'flex', flexDirection: 'column', gap: 10, background: 'var(--dsw-alias-fill-l2, rgba(127,127,127,.05))', borderRadius: 10, padding: '14px 16px' } },
272
+ createElement('div', { style: { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary, #222)' } }, '添加 Agent'),
273
+ createElement(
274
+ 'div',
275
+ { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
276
+ createElement('input', {
277
+ placeholder: 'Agent Card URL(https://…/.well-known/agent-card.json)',
278
+ value: addUrl,
279
+ onChange: (e: { target: { value: string } }) => {
280
+ setAddUrl(e.target.value);
281
+ setDiscovered(null);
282
+ },
283
+ style: { ...inputStyle, flex: 1, minWidth: 260 },
284
+ }),
285
+ actionBtn('导入', false, () => void doDiscover(), discovering || !addUrl.trim()),
286
+ ),
287
+ discovering
288
+ ? createElement('div', { style: { fontSize: 13, color: 'var(--dsw-alias-label-tertiary, #999)' } }, '正在读取 Agent Card…')
289
+ : discovered
290
+ ? createElement(
291
+ 'div',
292
+ { style: { display: 'flex', flexDirection: 'column', gap: 8, background: 'var(--dsw-alias-fill-l1, rgba(127,127,127,.06))', borderRadius: 8, padding: '12px 14px' } },
293
+ createElement(
294
+ 'div',
295
+ { style: { display: 'flex', alignItems: 'center', gap: 8 } },
296
+ createElement('span', { style: { fontSize: 15, fontWeight: 700, color: 'var(--dsw-alias-label-primary, #222)' } }, discovered.name),
297
+ discovered.version
298
+ ? createElement('span', { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-tertiary, #999)', fontFamily: 'monospace' } }, `v${discovered.version}`)
299
+ : null,
300
+ stateDot('connected'),
301
+ ),
302
+ discovered.description
303
+ ? createElement('div', { style: { fontSize: 12.5, color: 'var(--dsw-alias-label-secondary, #666)' } }, discovered.description)
304
+ : null,
305
+ (discovered.skills?.length ?? 0) > 0
306
+ ? createElement(
307
+ 'div',
308
+ { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
309
+ createElement('div', { style: { fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-label-secondary, #666)' } }, 'Skills'),
310
+ ...(discovered.skills ?? []).map((s) =>
311
+ createElement('div', { key: s.id, style: { fontSize: 12.5, color: 'var(--dsw-alias-label-primary, #222)' } }, `• ${s.name}${s.description ? ` — ${s.description}` : ''}`),
312
+ ),
313
+ )
314
+ : null,
315
+ discovered.endpoint
316
+ ? createElement('div', { style: { fontSize: 11.5, fontFamily: 'monospace', color: 'var(--dsw-alias-label-tertiary, #999)' } }, `端点 ${discovered.endpoint}`)
317
+ : null,
318
+ createElement(
319
+ 'div',
320
+ { style: { display: 'flex', gap: 8, marginTop: 2 } },
321
+ actionBtn('连接', true, () => void doConnect(), busy === 'add-agent'),
322
+ actionBtn('取消', false, () => { setDiscovered(null); setAddUrl(''); }, false),
323
+ ),
324
+ )
325
+ : createElement(
326
+ 'div',
327
+ { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)' } },
328
+ '输入远程 Agent 的 Agent Card URL 并点击"导入",将自动读取其名称、描述、技能与能力,确认后建立连接。',
329
+ ),
330
+ ),
331
+ )
332
+ : null,
333
+ // ── serve tab ─────────────────────────────────────────
334
+ tab === 'serve' && serve
335
+ ? createElement(
336
+ 'div',
337
+ { style: { display: 'flex', flexDirection: 'column', gap: 10, background: 'var(--dsw-alias-fill-l2, rgba(127,127,127,.05))', borderRadius: 10, padding: '14px 16px' } },
338
+ createElement(
339
+ 'div',
340
+ { style: { display: 'flex', alignItems: 'center', gap: 8 } },
341
+ sec('A2A 服务(本 DSH 作为 Agent 对外)', 0),
342
+ stateDot(serve.enabled ? 'connected' : 'disconnected'),
343
+ createElement(
344
+ 'span',
345
+ { style: { fontSize: 12.5, color: serve.enabled ? '#2e7d32' : '#9e9e9e' } },
346
+ serve.enabled ? '运行中' : '已下线',
347
+ ),
348
+ createElement(
349
+ 'span',
350
+ { style: { marginLeft: 'auto' } },
351
+ serve.enabled
352
+ ? actionBtn('下线', false, () => void act('server-disable', ''), busy === 'server-disable')
353
+ : actionBtn('上线', true, () => void act('server-enable', ''), busy === 'server-enable'),
354
+ ),
355
+ ),
356
+ createElement(
357
+ 'div',
358
+ { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
359
+ serve.agentName
360
+ ? createElement(
361
+ 'div',
362
+ { style: { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary, #222)' } },
363
+ serve.agentName,
364
+ serve.agentVersion ? createElement('span', { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-tertiary, #999)', fontFamily: 'monospace', marginLeft: 8 } }, `v${serve.agentVersion}`) : null,
365
+ )
366
+ : null,
367
+ serve.agentDescription
368
+ ? createElement('div', { style: { fontSize: 12.5, color: 'var(--dsw-alias-label-secondary, #666)' } }, serve.agentDescription)
369
+ : null,
370
+ serve.endpoint
371
+ ? createElement('div', { style: { fontSize: 12, fontFamily: 'monospace', color: 'var(--dsw-alias-label-tertiary, #999)' } }, `端点 ${serve.endpoint}`)
372
+ : null,
373
+ serve.agentCardUrl
374
+ ? createElement('div', { style: { fontSize: 12, fontFamily: 'monospace', color: 'var(--dsw-alias-label-tertiary, #999)' } }, `Agent Card ${serve.agentCardUrl}`)
375
+ : null,
376
+ (serve.skills?.length ?? 0) > 0
377
+ ? createElement(
378
+ 'div',
379
+ { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
380
+ createElement('div', { style: { fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-label-secondary, #666)' } }, 'Skills'),
381
+ ...(serve.skills ?? []).map((s) =>
382
+ createElement('div', { key: s.id, style: { fontSize: 12.5, color: 'var(--dsw-alias-label-primary, #222)' } }, `• ${s.name}${s.description ? ` — ${s.description}` : ''}`),
383
+ ),
384
+ )
385
+ : null,
386
+ ),
387
+ serve.enabled && !serve.customExecutor
388
+ ? createElement(
389
+ 'div',
390
+ { style: { fontSize: 12, color: 'var(--dsw-warning, #b26a00)', background: 'rgba(178,106,0,.08)', borderRadius: 6, padding: '6px 10px' } },
391
+ '⚠ 默认 executor 会执行 shell 命令。生产环境请通过配置指定受限的 execute 实现。',
392
+ )
393
+ : null,
394
+ )
395
+ : null,
396
+ // ── inbound tab ────────────────────────────────────────
397
+ tab === 'in'
398
+ ? createElement(
399
+ 'div',
400
+ { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
401
+ sec('入站连接(谁在连接本 DSH)', inbound.length),
402
+ inbound.length === 0
403
+ ? emptyRow('暂无入站连接。其他 A2A 客户端调用本 DSH 的 JSON-RPC 端点后会显示在这里。')
404
+ : createElement('table', tblStyle, createElement('thead', null, headRow(['来源', '地址', '首次连接', '最近活动', '任务', '流', '操作'])), createElement('tbody', null, ...inbound.map((p) => inboundRow(p, busy, act)))),
405
+ )
406
+ : null,
407
+ createElement(
408
+ 'div',
409
+ { style: { color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 12 } },
410
+ `快照时间 ${snap ? new Date(snap.at).toLocaleTimeString() : '—'} · 每 3 秒自动刷新`,
411
+ ),
412
+ );
413
+ }
414
+
415
+ // ── connected agent card (pure: hooks live in the parent section) ────────
416
+ function agentCard(
417
+ a: OutboundAgentApi,
418
+ busy: string | null,
419
+ act: (a: string, t: string, p?: Record<string, unknown>) => Promise<void>,
420
+ expanded: Set<string>,
421
+ setExpanded: (s: Set<string>) => void,
422
+ ): ReactElement {
423
+ const target = a.connectionId ?? a.id;
424
+ const open = expanded.has(target);
425
+ const skills = a.skills ?? [];
426
+ const toggle = (): void => {
427
+ const next = new Set(expanded);
428
+ if (next.has(target)) next.delete(target);
429
+ else next.add(target);
430
+ setExpanded(next);
431
+ };
432
+ return createElement(
433
+ 'div',
434
+ { key: a.id, style: { display: 'flex', flexDirection: 'column', gap: 8, background: 'var(--dsw-alias-fill-l1, rgba(127,127,127,.05))', borderRadius: 10, padding: '12px 14px', opacity: a.enabled === false ? 0.72 : 1 } },
435
+ // header row — click toggles skill list
436
+ createElement(
437
+ 'div',
438
+ { style: { display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', userSelect: 'none' as const, flexWrap: 'wrap' as const }, onClick: toggle },
439
+ createElement('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary, #999)', width: 14, display: 'inline-block', transition: 'transform .15s', transform: open ? 'rotate(90deg)' : 'none' } }, '▶'),
440
+ stateDot(a.enabled === false ? 'disabled' : a.state),
441
+ createElement('span', { style: { fontSize: 14, fontWeight: 600, color: 'var(--dsw-alias-label-primary, #222)' } }, a.agentName ?? a.name),
442
+ a.enabled === false
443
+ ? createElement('span', { style: { fontSize: 11.5, ...sourceBadge('#b26a00', 'rgba(178,106,0,.1)', '已禁用') } }, '已禁用')
444
+ : createElement(
445
+ 'span',
446
+ { style: { fontSize: 11.5, ...(a.configured ? sourceBadge('#666', 'rgba(127,127,127,.12)', '配置') : sourceBadge('#4a7dff', 'rgba(74,125,255,.1)', '运行时')) } },
447
+ a.configured ? '配置' : '运行时',
448
+ ),
449
+ createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 } }, `${a.skillCount} 技能 · ${a.toolCount} 工具 · ${new Date(a.lastSeen).toLocaleTimeString()}`),
450
+ // ── three operations: reconnect/enable · disable · delete (never wrap) ──
451
+ createElement(
452
+ 'div',
453
+ { style: { display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0, marginLeft: 'auto', paddingLeft: 10 } },
454
+ a.enabled === false
455
+ ? actionBtn('启用', true, () => void act('enable-agent', target), busy === `enable-agent:${target}`)
456
+ : actionBtn('重连', true, () => void act('reconnect-agent', target), busy === `reconnect-agent:${target}`),
457
+ actionBtn('禁用', false, () => void act('disable-agent', target), busy === `disable-agent:${target}`),
458
+ actionBtn('删除', false, () => void act('remove-agent', target), busy === `remove-agent:${target}`),
459
+ ),
460
+ ),
461
+ createElement(
462
+ 'div',
463
+ { style: { fontSize: 12, fontFamily: 'monospace', color: 'var(--dsw-alias-label-tertiary, #999)', wordBreak: 'break-all' } },
464
+ a.agentCardUrl,
465
+ ),
466
+ // expandable skills
467
+ open && skills.length > 0
468
+ ? createElement(
469
+ 'div',
470
+ { style: { display: 'flex', flexDirection: 'column', gap: 6, background: 'var(--dsw-alias-fill-l2, rgba(0,0,0,.04))', borderRadius: 8, padding: '10px 12px', marginTop: 2 } },
471
+ ...skills.map((s) =>
472
+ createElement(
473
+ 'div',
474
+ { key: s.id, style: { display: 'flex', flexDirection: 'column', gap: 2 } },
475
+ createElement(
476
+ 'div',
477
+ { style: { display: 'flex', alignItems: 'baseline', gap: 6 } },
478
+ createElement('span', { style: { fontSize: 12.5, fontWeight: 600, color: 'var(--dsw-alias-label-primary, #222)', fontFamily: 'monospace' } }, s.name),
479
+ s.tags?.length
480
+ ? createElement('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary, #999)' } }, s.tags.join(', '))
481
+ : null,
482
+ ),
483
+ s.description
484
+ ? createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)' } }, s.description)
485
+ : null,
486
+ ),
487
+ ),
488
+ )
489
+ : open
490
+ ? createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)' } }, '(该 Agent 未声明技能)')
491
+ : null,
492
+ );
493
+ }
494
+
495
+ function sourceBadge(color: string, bg: string, _label: string): Record<string, string | number> {
496
+ return { color, background: bg, borderRadius: 4, padding: '1px 6px' };
497
+ }
498
+
499
+ /** A tab button matching the settings "插件" page's underline tab style. */
500
+ function tabBtn(label: string, count: number, active: boolean, onClick: () => void): ReactElement {
501
+ return createElement(
502
+ 'button',
503
+ {
504
+ onClick,
505
+ 'aria-selected': active,
506
+ role: 'tab',
507
+ 'data-active': active,
508
+ style: {
509
+ padding: '7px 1px 9px',
510
+ fontSize: 13,
511
+ lineHeight: '20px',
512
+ fontWeight: 400,
513
+ color: active ? 'var(--dsw-alias-label-primary, #0f1115)' : 'var(--dsw-alias-label-tertiary, #81858c)',
514
+ background: 'transparent',
515
+ border: 'none',
516
+ cursor: 'pointer',
517
+ position: 'relative',
518
+ // underline via box-shadow inset (React inline styles can't do ::after)
519
+ boxShadow: active ? 'inset 0 -2px 0 var(--dsw-alias-label-primary, #0f1115)' : 'none',
520
+ borderRadius: '2px 2px 0 0',
521
+ transition: 'color .15s, box-shadow .15s',
522
+ },
523
+ },
524
+ label,
525
+ count > 0 ? createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)', marginLeft: 6 } }, String(count)) : null,
526
+ );
527
+ }
528
+
529
+ /** The tab strip container replicating `.pbvGtq_tabs` (border-bottom rail). */
530
+ function tabRail(children: Array<ReactElement | null>): ReactElement {
531
+ return createElement(
532
+ 'div',
533
+ { role: 'tablist', style: { display: 'flex', alignItems: 'flex-end', gap: 22, borderBottom: '1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.1))', marginTop: 2 } },
534
+ ...children,
535
+ );
536
+ }
537
+
538
+ function sec(title: string, count: number): ReactElement {
539
+ return createElement(
540
+ 'h3',
541
+ { style: { margin: 0, fontSize: 14, fontWeight: 600, color: 'var(--dsw-alias-label-primary, #222)', display: 'flex', alignItems: 'center', gap: 8 } },
542
+ title,
543
+ createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)', background: 'var(--dsw-alias-fill-l2, rgba(127,127,127,.1))', borderRadius: 10, padding: '1px 8px' } }, String(count)),
544
+ );
545
+ }
546
+
547
+ function emptyRow(text: string): ReactElement {
548
+ return createElement(
549
+ 'div',
550
+ { style: { color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 13, background: 'var(--dsw-alias-fill-l2, rgba(127,127,127,.06))', borderRadius: 8, padding: '12px 14px' } },
551
+ text,
552
+ );
553
+ }
554
+
555
+ const tblStyle = { borderCollapse: 'collapse' as const, width: '100%', fontSize: 13 };
556
+ const thStyle = { textAlign: 'left' as const, padding: '6px 8px', borderBottom: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.18))', color: 'var(--dsw-alias-label-secondary, #666)', fontWeight: 600, whiteSpace: 'nowrap' as const };
557
+ const tdStyle = { padding: '6px 8px', borderBottom: '1px solid var(--dsw-alias-border-l1, rgba(127,127,127,.1))', color: 'var(--dsw-alias-label-primary, #222)', verticalAlign: 'top' as const, fontSize: 12.5 };
558
+
559
+ function headRow(cols: string[]): ReactElement {
560
+ return createElement('tr', null, ...cols.map((c) => createElement('th', { style: thStyle }, c)));
561
+ }
562
+
563
+ const stateColor: Record<string, string> = {
564
+ connected: '#2e7d32',
565
+ disconnected: '#9e9e9e',
566
+ reconnecting: '#f57c00',
567
+ disabled: '#b26a00',
568
+ };
569
+
570
+ function stateDot(state: string): ReactElement {
571
+ return createElement('span', { style: { display: 'inline-block', width: 8, height: 8, borderRadius: 4, marginRight: 6, background: stateColor[state] ?? '#9e9e9e' } });
572
+ }
573
+
574
+ function actionBtn(label: string, primary: boolean, onClick: () => void, disabled: boolean): ReactElement {
575
+ return createElement(
576
+ 'button',
577
+ {
578
+ onClick,
579
+ disabled,
580
+ style: {
581
+ marginRight: 6,
582
+ padding: '3px 10px',
583
+ fontSize: 12,
584
+ lineHeight: '18px',
585
+ borderRadius: 6,
586
+ border: `1px solid ${primary ? 'var(--dsw-brand, #4a7dff)' : 'var(--dsw-alias-border-l2, rgba(127,127,127,.3))'}`,
587
+ background: primary ? 'var(--dsw-brand, #4a7dff)' : 'transparent',
588
+ color: primary ? '#fff' : 'var(--dsw-alias-label-primary, #222)',
589
+ cursor: disabled ? 'default' : 'pointer',
590
+ opacity: disabled ? 0.5 : 1,
591
+ },
592
+ },
593
+ label,
594
+ );
595
+ }
596
+
597
+ function inboundRow(p: InboundPeerApi, busy: string | null, act: (a: string, t: string) => void): ReactElement {
598
+ return createElement(
599
+ 'tr',
600
+ { key: p.id },
601
+ createElement('td', { style: tdStyle }, p.label),
602
+ createElement('td', { style: { ...tdStyle, fontFamily: 'monospace', fontSize: 12 } }, p.source ?? '—'),
603
+ createElement('td', { style: tdStyle }, new Date(p.firstSeen).toLocaleTimeString()),
604
+ createElement('td', { style: tdStyle }, new Date(p.lastSeen).toLocaleTimeString()),
605
+ createElement('td', { style: tdStyle }, String(p.taskCount)),
606
+ createElement('td', { style: tdStyle }, p.streaming ? '● 流' : '—'),
607
+ createElement(
608
+ 'td',
609
+ { style: tdStyle },
610
+ actionBtn('关闭', false, () => void act('close-peer', p.id), busy === `close-peer:${p.id}`),
611
+ ),
612
+ );
613
+ }