@ryubyte/dsh-a2a 0.1.5 → 0.2.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/README.en.md +41 -19
- package/README.md +27 -13
- package/lib/client.js +601 -443
- package/lib/index.js +185 -14
- package/lib/types/a2a-config.d.ts.map +1 -1
- package/lib/types/client/dashboard.css.d.ts +20 -0
- package/lib/types/client/dashboard.css.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +4 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/dashboard.d.ts +20 -0
- package/lib/types/dashboard.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/server.d.ts +33 -1
- package/lib/types/server.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/a2a-config.ts +15 -2
- package/src/client/dashboard.css.ts +295 -0
- package/src/client/index.ts +669 -265
- package/src/dashboard.ts +39 -0
- package/src/index.ts +138 -17
- package/src/server.ts +67 -1
package/src/client/index.ts
CHANGED
|
@@ -8,17 +8,28 @@
|
|
|
8
8
|
* The data and the controls live behind the host's `/a2a/api` routes
|
|
9
9
|
* (served by the node half on the same webserver); the browser half is a
|
|
10
10
|
* thin read/write client with no protocol knowledge.
|
|
11
|
+
*
|
|
12
|
+
* Styling: a single idempotent stylesheet (dashboard.css.ts) built entirely
|
|
13
|
+
* on the product's `--dsw-alias-*` design tokens, so light/dark themes and
|
|
14
|
+
* narrow-window layouts follow the DSH shell. No inline styles.
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
|
|
14
18
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client';
|
|
15
19
|
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
16
20
|
import { createElement, useEffect, useState, type ReactElement } from 'react';
|
|
21
|
+
import { injectDashboardStyles } from './dashboard.css.js';
|
|
17
22
|
|
|
18
23
|
/** Services this plugin needs from the client runtime. */
|
|
19
24
|
export const inject = ['slots'];
|
|
20
25
|
|
|
21
26
|
export function apply(ctx: ClientContext): void {
|
|
27
|
+
// Inject the dashboard stylesheet once; removed when the plugin unloads.
|
|
28
|
+
ctx.effect(() => {
|
|
29
|
+
const dispose = injectDashboardStyles();
|
|
30
|
+
return () => dispose();
|
|
31
|
+
}, 'dsh-a2a: dashboard styles');
|
|
32
|
+
|
|
22
33
|
ctx.effect(() => {
|
|
23
34
|
try {
|
|
24
35
|
const off = ctx.slots.register(
|
|
@@ -60,6 +71,7 @@ interface OutboundAgentApi {
|
|
|
60
71
|
skillCount: number;
|
|
61
72
|
toolCount: number;
|
|
62
73
|
state: 'connected' | 'disconnected' | 'reconnecting';
|
|
74
|
+
/** Last-activity ISO timestamp. */
|
|
63
75
|
lastSeen: string;
|
|
64
76
|
/** Stable connection id the control actions operate on. */
|
|
65
77
|
connectionId?: string;
|
|
@@ -100,6 +112,99 @@ async function postControl(action: string, target: string, payload?: Record<stri
|
|
|
100
112
|
|
|
101
113
|
type SectionProps = PropsRuntime<'settings.section'>;
|
|
102
114
|
|
|
115
|
+
interface ServeStatus {
|
|
116
|
+
enabled: boolean;
|
|
117
|
+
baseUrl?: string;
|
|
118
|
+
agentName?: string;
|
|
119
|
+
agentDescription?: string;
|
|
120
|
+
agentVersion?: string;
|
|
121
|
+
endpoint?: string;
|
|
122
|
+
/** Raw JSON-RPC path (for the edit form; `endpoint` is the full URL). */
|
|
123
|
+
endpointPath?: string;
|
|
124
|
+
agentCardUrl?: string;
|
|
125
|
+
skills?: Array<{ id: string; name: string; description?: string; tags?: string[] }>;
|
|
126
|
+
/** Legacy: true when a custom executor was injected (kept for old snapshots). */
|
|
127
|
+
customExecutor?: boolean;
|
|
128
|
+
/** How inbound tasks are executed. */
|
|
129
|
+
executor?: 'custom' | 'dsh-agent' | 'none';
|
|
130
|
+
/** Whether the inbound endpoint is token-gated (never the token value). */
|
|
131
|
+
authConfigured?: boolean;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface DiscoveredCard {
|
|
135
|
+
name: string;
|
|
136
|
+
description?: string;
|
|
137
|
+
version?: string;
|
|
138
|
+
agentCardUrl: string;
|
|
139
|
+
endpoint?: string;
|
|
140
|
+
skills?: Array<{ id: string; name: string; description?: string }>;
|
|
141
|
+
capabilities?: Record<string, unknown>;
|
|
142
|
+
defaultInputModes?: string[];
|
|
143
|
+
defaultOutputModes?: string[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** One editable skill row in the serve-identity form (tags as a raw CSV). */
|
|
147
|
+
interface SkillDraft {
|
|
148
|
+
id: string;
|
|
149
|
+
name: string;
|
|
150
|
+
description: string;
|
|
151
|
+
tags: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Serve-identity edit form draft (server tab). */
|
|
155
|
+
interface ServeDraft {
|
|
156
|
+
agentName: string;
|
|
157
|
+
agentDescription: string;
|
|
158
|
+
agentVersion: string;
|
|
159
|
+
baseUrl: string;
|
|
160
|
+
endpointPath: string;
|
|
161
|
+
skills: SkillDraft[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Per-agent advanced-settings form draft (client tab). */
|
|
165
|
+
interface AgentDraft {
|
|
166
|
+
/** Raw string so the field can be empty; parsed to a number on save. */
|
|
167
|
+
timeoutMs: string;
|
|
168
|
+
mapSkills: boolean;
|
|
169
|
+
/** Empty means "leave unchanged". */
|
|
170
|
+
bearerToken: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Sensible starter values for the first-time guided setup, so a new user edits
|
|
175
|
+
* a ready-to-go config instead of filling every field from scratch — they can
|
|
176
|
+
* accept it as-is and click "创建并上线", or tweak what they want. baseUrl /
|
|
177
|
+
* endpointPath stay blank (auto-inferred). The seed skill is a placeholder the
|
|
178
|
+
* user renames or removes.
|
|
179
|
+
*/
|
|
180
|
+
function guidedServeDraft(): ServeDraft {
|
|
181
|
+
return {
|
|
182
|
+
agentName: 'DSH Agent',
|
|
183
|
+
agentDescription: '基于 DeepSeek Harness 的 A2A Agent',
|
|
184
|
+
agentVersion: '0.1.0',
|
|
185
|
+
baseUrl: '',
|
|
186
|
+
endpointPath: '',
|
|
187
|
+
skills: [{ id: 'coding', name: 'Coding', description: '在 DSH 工作区中执行编码与命令行任务', tags: 'coding, shell' }],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Build a serve draft from the live serve status (prefill the edit form). */
|
|
192
|
+
function serveDraftFrom(serve: ServeStatus): ServeDraft {
|
|
193
|
+
return {
|
|
194
|
+
agentName: serve.agentName ?? '',
|
|
195
|
+
agentDescription: serve.agentDescription ?? '',
|
|
196
|
+
agentVersion: serve.agentVersion ?? '',
|
|
197
|
+
baseUrl: serve.baseUrl ?? '',
|
|
198
|
+
endpointPath: serve.endpointPath ?? '',
|
|
199
|
+
skills: (serve.skills ?? []).map((s) => ({
|
|
200
|
+
id: s.id,
|
|
201
|
+
name: s.name,
|
|
202
|
+
description: s.description ?? '',
|
|
203
|
+
tags: (s.tags ?? []).join(', '),
|
|
204
|
+
})),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
103
208
|
/** The A2A connection dashboard settings section. */
|
|
104
209
|
export function DashboardSection(_props: SectionProps): ReactElement {
|
|
105
210
|
// ── state (all hooks at the top, fixed order; helper components below
|
|
@@ -112,30 +217,27 @@ export function DashboardSection(_props: SectionProps): ReactElement {
|
|
|
112
217
|
const [addUrl, setAddUrl] = useState('');
|
|
113
218
|
const [discovering, setDiscovering] = useState(false);
|
|
114
219
|
const [discovered, setDiscovered] = useState<DiscoveredCard | null>(null);
|
|
115
|
-
|
|
220
|
+
// Two tabs, one per role this DSH plays: `client` (it connects out to remote
|
|
221
|
+
// agents) and `server` (it serves inbound A2A callers). The server tab folds
|
|
222
|
+
// together the serve-config panel and the live inbound-connection list.
|
|
223
|
+
const [tab, setTab] = useState<'client' | 'server'>('client');
|
|
116
224
|
/** connection ids whose skill list is expanded in the outbound tab. */
|
|
117
225
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
118
226
|
/** Optional bearer token for the add-agent form (outbound). */
|
|
119
227
|
const [addToken, setAddToken] = useState('');
|
|
120
228
|
/** Inbound authToken input (serve tab); empty string means "clear". */
|
|
121
229
|
const [authInput, setAuthInput] = useState('');
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
customExecutor?: boolean;
|
|
134
|
-
/** How inbound tasks are executed. */
|
|
135
|
-
executor?: 'custom' | 'dsh-agent' | 'none';
|
|
136
|
-
/** Whether the inbound endpoint is token-gated (never the token value). */
|
|
137
|
-
authConfigured?: boolean;
|
|
138
|
-
}
|
|
230
|
+
/** Serve-identity edit mode + form draft (server tab). All hooks stay at the
|
|
231
|
+
* top level in fixed order; a single shared draft avoids per-card hooks. */
|
|
232
|
+
const [editingServe, setEditingServe] = useState(false);
|
|
233
|
+
// Seed with guided starter values so the first-time setup form is pre-filled
|
|
234
|
+
// and editable, not blank. Editing an existing server overwrites this draft
|
|
235
|
+
// via startEditServe → serveDraftFrom(serve).
|
|
236
|
+
const [serveDraft, setServeDraft] = useState<ServeDraft>(guidedServeDraft());
|
|
237
|
+
/** connectionId of the outbound agent whose advanced-settings form is open
|
|
238
|
+
* (null = none). One shared draft keeps agentCard() a pure, hookless fn. */
|
|
239
|
+
const [editingAgent, setEditingAgent] = useState<string | null>(null);
|
|
240
|
+
const [agentDraft, setAgentDraft] = useState<AgentDraft>({ timeoutMs: '', mapSkills: true, bearerToken: '' });
|
|
139
241
|
|
|
140
242
|
const refresh = async (): Promise<void> => {
|
|
141
243
|
try {
|
|
@@ -178,261 +280,278 @@ export function DashboardSection(_props: SectionProps): ReactElement {
|
|
|
178
280
|
const inbound = snap?.inbound ?? [];
|
|
179
281
|
const outbound = snap?.outbound ?? [];
|
|
180
282
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
283
|
+
const doDiscover = async (): Promise<void> => {
|
|
284
|
+
const u = addUrl.trim();
|
|
285
|
+
if (!u) return;
|
|
286
|
+
setDiscovering(true);
|
|
287
|
+
setDiscovered(null);
|
|
288
|
+
setNotice(null);
|
|
289
|
+
try {
|
|
290
|
+
const res = await fetch('/a2a/api', {
|
|
291
|
+
method: 'POST',
|
|
292
|
+
headers: { 'content-type': 'application/json' },
|
|
293
|
+
body: JSON.stringify({ action: 'discover-agent', agentCardUrl: u, bearerToken: addToken.trim() || undefined }),
|
|
294
|
+
});
|
|
295
|
+
const body = (await res.json()) as ControlResultApi;
|
|
296
|
+
if (!body.ok || !body.message) {
|
|
297
|
+
setNotice(`✗ ${body.message}`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
setDiscovered(JSON.parse(body.message) as DiscoveredCard);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
setNotice(`✗ ${err instanceof Error ? err.message : String(err)}`);
|
|
303
|
+
} finally {
|
|
304
|
+
setDiscovering(false);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
192
307
|
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
method: 'POST',
|
|
202
|
-
headers: { 'content-type': 'application/json' },
|
|
203
|
-
body: JSON.stringify({ action: 'discover-agent', agentCardUrl: u, bearerToken: addToken.trim() || undefined }),
|
|
204
|
-
});
|
|
205
|
-
const body = (await res.json()) as ControlResultApi;
|
|
206
|
-
if (!body.ok || !body.message) {
|
|
207
|
-
setNotice(`✗ ${body.message}`);
|
|
208
|
-
return;
|
|
308
|
+
const doConnect = async (): Promise<void> => {
|
|
309
|
+
if (!discovered) return;
|
|
310
|
+
const n = discovered.name.trim() || 'remote';
|
|
311
|
+
await act('add-agent', n, { agentCardUrl: discovered.agentCardUrl, bearerToken: addToken.trim() || undefined });
|
|
312
|
+
if (!busy) {
|
|
313
|
+
setAddUrl('');
|
|
314
|
+
setAddToken('');
|
|
315
|
+
setDiscovered(null);
|
|
209
316
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
const doConnect = async (): Promise<void> => {
|
|
219
|
-
if (!discovered) return;
|
|
220
|
-
const n = discovered.name.trim() || 'remote';
|
|
221
|
-
await act('add-agent', n, { agentCardUrl: discovered.agentCardUrl, bearerToken: addToken.trim() || undefined });
|
|
222
|
-
if (!busy) {
|
|
223
|
-
setAddUrl('');
|
|
224
|
-
setAddToken('');
|
|
225
|
-
setDiscovered(null);
|
|
226
|
-
}
|
|
227
|
-
};
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
// Open the serve-identity editor, prefilled from the current status.
|
|
320
|
+
const startEditServe = (): void => {
|
|
321
|
+
if (serve) setServeDraft(serveDraftFrom(serve));
|
|
322
|
+
setEditingServe(true);
|
|
323
|
+
};
|
|
228
324
|
|
|
229
|
-
|
|
325
|
+
// Serialize the serve draft → set-server-identity. Skills with a blank id or
|
|
326
|
+
// name are dropped; tags split on comma. `also` optionally chains a follow-up
|
|
327
|
+
// action (used by guided init to enable the server right after creating it).
|
|
328
|
+
const saveServeIdentity = async (also?: 'enable'): Promise<void> => {
|
|
329
|
+
const skills = serveDraft.skills
|
|
330
|
+
.map((s) => ({
|
|
331
|
+
id: s.id.trim(),
|
|
332
|
+
name: s.name.trim(),
|
|
333
|
+
description: s.description.trim(),
|
|
334
|
+
tags: s.tags.split(',').map((t) => t.trim()).filter(Boolean),
|
|
335
|
+
}))
|
|
336
|
+
.filter((s) => s.id && s.name);
|
|
337
|
+
await act('set-server-identity', '', {
|
|
338
|
+
agentName: serveDraft.agentName.trim(),
|
|
339
|
+
agentDescription: serveDraft.agentDescription.trim(),
|
|
340
|
+
agentVersion: serveDraft.agentVersion.trim(),
|
|
341
|
+
// Always send baseUrl (even ''): empty means "clear pin → auto-infer from
|
|
342
|
+
// the real listen address", which the host can only honor if it sees the
|
|
343
|
+
// empty string rather than a dropped field.
|
|
344
|
+
baseUrl: serveDraft.baseUrl.trim(),
|
|
345
|
+
endpointPath: serveDraft.endpointPath.trim() || undefined,
|
|
346
|
+
skills,
|
|
347
|
+
});
|
|
348
|
+
if (also === 'enable') await act('server-enable', '');
|
|
349
|
+
setEditingServe(false);
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// Open the per-agent advanced editor, prefilled from the agent's card values.
|
|
353
|
+
const startEditAgent = (a: OutboundAgentApi): void => {
|
|
354
|
+
const target = a.connectionId ?? a.id;
|
|
355
|
+
setAgentDraft({ timeoutMs: '', mapSkills: true, bearerToken: '' });
|
|
356
|
+
setEditingAgent(target);
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const saveAgentAdvanced = async (target: string): Promise<void> => {
|
|
360
|
+
const t = agentDraft.timeoutMs.trim();
|
|
361
|
+
await act('update-agent', target, {
|
|
362
|
+
timeoutMs: t ? Number(t) : undefined,
|
|
363
|
+
mapSkills: agentDraft.mapSkills,
|
|
364
|
+
bearerToken: agentDraft.bearerToken.trim() || undefined,
|
|
365
|
+
});
|
|
366
|
+
setEditingAgent(null);
|
|
367
|
+
};
|
|
230
368
|
|
|
231
369
|
return createElement(
|
|
370
|
+
// `.a2a-root` is the inline-size query container; the responsive
|
|
371
|
+
// `@container` rules in dashboard.css react to this element's width (the
|
|
372
|
+
// settings drawer), not the browser viewport. Keep it as the outermost
|
|
373
|
+
// node so the whole section is measured.
|
|
232
374
|
'div',
|
|
233
|
-
{
|
|
375
|
+
{ className: 'a2a-root' },
|
|
376
|
+
createElement(
|
|
377
|
+
'div',
|
|
378
|
+
{ className: 'a2a-section' },
|
|
234
379
|
// ── intro ─────────────────────────────────────────────
|
|
235
380
|
createElement(
|
|
236
|
-
'
|
|
237
|
-
{
|
|
238
|
-
'管理本 DSH 的 A2A Agent
|
|
381
|
+
'p',
|
|
382
|
+
{ className: 'a2a-intro' },
|
|
383
|
+
'管理本 DSH 的 A2A 连接:在「连接的 Agent」里连接并调用远程 Agent,在「对外服务」里把本 DSH 作为 Agent 发布、并查看谁在调用它。',
|
|
239
384
|
),
|
|
240
385
|
error
|
|
241
|
-
? createElement(
|
|
242
|
-
'div',
|
|
243
|
-
{ style: { color: 'var(--dsw-danger, #c62828)', fontSize: 13, background: 'var(--dsw-alias-fill-l2, rgba(127,127,127,.08))', borderRadius: 8, padding: '8px 12px' } },
|
|
244
|
-
`加载失败:${error}`,
|
|
245
|
-
)
|
|
246
|
-
: null,
|
|
247
|
-
notice
|
|
248
|
-
? createElement(
|
|
249
|
-
'div',
|
|
250
|
-
{ 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' } },
|
|
251
|
-
notice,
|
|
252
|
-
)
|
|
386
|
+
? createElement('div', { className: 'a2a-notice a2a-error' }, `加载失败:${error}`)
|
|
253
387
|
: null,
|
|
388
|
+
notice ? createElement('div', { className: 'a2a-notice' }, notice) : null,
|
|
254
389
|
// ── tabs (underline style, same as the 插件 settings page) ─────────
|
|
255
390
|
tabRail([
|
|
256
|
-
tabBtn('
|
|
257
|
-
tabBtn('
|
|
258
|
-
serve ? tabBtn('A2A 服务', 0, tab === 'serve', () => setTab('serve')) : null,
|
|
391
|
+
tabBtn('连接的 Agent', outbound.length, tab === 'client', () => setTab('client')),
|
|
392
|
+
tabBtn('对外服务', inbound.length, tab === 'server', () => setTab('server')),
|
|
259
393
|
]),
|
|
260
|
-
// ──
|
|
261
|
-
tab === '
|
|
394
|
+
// ── client tab (outbound: agents this DSH connects out to) ──────────
|
|
395
|
+
tab === 'client'
|
|
262
396
|
? createElement(
|
|
263
397
|
'div',
|
|
264
|
-
{
|
|
398
|
+
{ className: 'a2a-stack' },
|
|
265
399
|
// Connected agents (cards)
|
|
266
400
|
createElement(
|
|
267
401
|
'div',
|
|
268
|
-
{
|
|
402
|
+
{ className: 'a2a-crew' },
|
|
269
403
|
sec('已连接 Agent', outbound.length),
|
|
270
404
|
outbound.length === 0
|
|
271
405
|
? emptyRow('暂无已连接的 Agent。使用下方"添加 Agent"通过 Agent Card URL 连接远程 A2A Agent。')
|
|
272
406
|
: createElement(
|
|
273
407
|
'div',
|
|
274
|
-
{
|
|
275
|
-
...outbound.map((a) =>
|
|
408
|
+
{ className: 'a2a-card-list' },
|
|
409
|
+
...outbound.map((a) =>
|
|
410
|
+
agentCard(a, busy, act, expanded, setExpanded, {
|
|
411
|
+
editingAgent,
|
|
412
|
+
agentDraft,
|
|
413
|
+
setAgentDraft,
|
|
414
|
+
startEditAgent,
|
|
415
|
+
saveAgentAdvanced: (t: string) => void saveAgentAdvanced(t),
|
|
416
|
+
cancelEditAgent: () => setEditingAgent(null),
|
|
417
|
+
}),
|
|
418
|
+
),
|
|
276
419
|
),
|
|
277
420
|
),
|
|
278
421
|
// Add agent (discover → connect)
|
|
279
422
|
createElement(
|
|
280
423
|
'div',
|
|
281
|
-
{
|
|
282
|
-
createElement('div', {
|
|
424
|
+
{ className: 'a2a-panel' },
|
|
425
|
+
createElement('div', { className: 'a2a-panel-title' }, '添加 Agent'),
|
|
283
426
|
createElement(
|
|
284
427
|
'div',
|
|
285
|
-
{
|
|
428
|
+
{ className: 'a2a-field-row' },
|
|
286
429
|
createElement('input', {
|
|
430
|
+
className: 'a2a-input',
|
|
287
431
|
placeholder: 'Agent Card URL(https://…/.well-known/agent-card.json)',
|
|
288
432
|
value: addUrl,
|
|
289
433
|
onChange: (e: { target: { value: string } }) => {
|
|
290
434
|
setAddUrl(e.target.value);
|
|
291
435
|
setDiscovered(null);
|
|
292
436
|
},
|
|
293
|
-
style: { ...inputStyle, flex: 1, minWidth: 260 },
|
|
294
437
|
}),
|
|
295
438
|
actionBtn('导入', false, () => void doDiscover(), discovering || !addUrl.trim()),
|
|
296
439
|
),
|
|
297
440
|
createElement(
|
|
298
441
|
'div',
|
|
299
|
-
{
|
|
442
|
+
{ className: 'a2a-field-row' },
|
|
300
443
|
createElement('input', {
|
|
444
|
+
className: 'a2a-input',
|
|
301
445
|
type: 'password',
|
|
302
446
|
placeholder: 'Bearer Token(可选,发送为 Authorization: Bearer …)',
|
|
303
447
|
value: addToken,
|
|
304
448
|
onChange: (e: { target: { value: string } }) => setAddToken(e.target.value),
|
|
305
|
-
style: { ...inputStyle, flex: 1, minWidth: 260 },
|
|
306
449
|
}),
|
|
307
450
|
),
|
|
308
451
|
createElement(
|
|
309
452
|
'div',
|
|
310
|
-
{
|
|
453
|
+
{ className: 'a2a-hint' },
|
|
311
454
|
'若远程 Agent 需要鉴权,填入 token;导入与后续调用都会带上。token 会明文存入 a2a.json。',
|
|
312
455
|
),
|
|
313
456
|
discovering
|
|
314
|
-
? createElement('div', {
|
|
457
|
+
? createElement('div', { className: 'a2a-hint' }, '正在读取 Agent Card…')
|
|
315
458
|
: discovered
|
|
316
459
|
? createElement(
|
|
317
460
|
'div',
|
|
318
|
-
{
|
|
461
|
+
{ className: 'a2a-preview' },
|
|
319
462
|
createElement(
|
|
320
463
|
'div',
|
|
321
|
-
{
|
|
322
|
-
createElement('span', {
|
|
464
|
+
{ className: 'a2a-preview-head' },
|
|
465
|
+
createElement('span', { className: 'a2a-preview-name' }, discovered.name),
|
|
323
466
|
discovered.version
|
|
324
|
-
? createElement('span', {
|
|
467
|
+
? createElement('span', { className: 'a2a-preview-version a2a-mono' }, `v${discovered.version}`)
|
|
325
468
|
: null,
|
|
326
469
|
stateDot('connected'),
|
|
327
470
|
),
|
|
328
471
|
discovered.description
|
|
329
|
-
? createElement('div', {
|
|
472
|
+
? createElement('div', { className: 'a2a-preview-desc' }, discovered.description)
|
|
330
473
|
: null,
|
|
331
474
|
(discovered.skills?.length ?? 0) > 0
|
|
332
475
|
? createElement(
|
|
333
476
|
'div',
|
|
334
|
-
{
|
|
335
|
-
createElement('div', {
|
|
477
|
+
{ className: 'a2a-skill-list' },
|
|
478
|
+
createElement('div', { className: 'a2a-skill-list-title' }, 'Skills'),
|
|
336
479
|
...(discovered.skills ?? []).map((s) =>
|
|
337
|
-
createElement('div', { key: s.id
|
|
480
|
+
createElement('div', { key: s.id }, `• ${s.name}${s.description ? ` — ${s.description}` : ''}`),
|
|
338
481
|
),
|
|
339
482
|
)
|
|
340
483
|
: null,
|
|
341
484
|
discovered.endpoint
|
|
342
|
-
? createElement('div', {
|
|
485
|
+
? createElement('div', { className: 'a2a-preview-meta a2a-mono' }, `端点 ${discovered.endpoint}`)
|
|
343
486
|
: null,
|
|
344
487
|
createElement(
|
|
345
488
|
'div',
|
|
346
|
-
{
|
|
489
|
+
{ className: 'a2a-btn-row' },
|
|
347
490
|
actionBtn('连接', true, () => void doConnect(), busy?.startsWith('add-agent:') ?? false),
|
|
348
491
|
actionBtn('取消', false, () => { setDiscovered(null); setAddUrl(''); }, false),
|
|
349
492
|
),
|
|
350
493
|
)
|
|
351
494
|
: createElement(
|
|
352
495
|
'div',
|
|
353
|
-
{
|
|
496
|
+
{ className: 'a2a-hint' },
|
|
354
497
|
'输入远程 Agent 的 Agent Card URL 并点击"导入",将自动读取其名称、描述、技能与能力,确认后建立连接。',
|
|
355
498
|
),
|
|
356
499
|
),
|
|
357
500
|
)
|
|
358
501
|
: null,
|
|
359
|
-
// ──
|
|
360
|
-
tab === '
|
|
502
|
+
// ── server tab (this DSH as an A2A agent: serve config + inbound) ──────
|
|
503
|
+
tab === 'server'
|
|
361
504
|
? createElement(
|
|
362
505
|
'div',
|
|
363
|
-
{
|
|
506
|
+
{ className: 'a2a-stack' },
|
|
507
|
+
// (1) serve configuration panel — read-only view, edit form, or (for
|
|
508
|
+
// a never-configured server) a guided setup card.
|
|
509
|
+
serve
|
|
510
|
+
? editingServe
|
|
511
|
+
? serveEditPanel(serveDraft, setServeDraft, (also) => void saveServeIdentity(also), () => setEditingServe(false), busy, false)
|
|
512
|
+
: needsServeSetup(serve)
|
|
513
|
+
? serveEditPanel(serveDraft, setServeDraft, (also) => void saveServeIdentity(also), () => setEditingServe(false), busy, true)
|
|
514
|
+
: serveViewPanel(serve, busy, act, authInput, setAuthInput, startEditServe)
|
|
515
|
+
: null,
|
|
516
|
+
// (2) inbound connections — who is calling this DSH's A2A endpoint
|
|
364
517
|
createElement(
|
|
365
518
|
'div',
|
|
366
|
-
{
|
|
367
|
-
sec('
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
{ style: { fontSize: 12.5, color: serve.enabled ? '#2e7d32' : '#9e9e9e' } },
|
|
372
|
-
serve.enabled ? '运行中' : '已下线',
|
|
373
|
-
),
|
|
374
|
-
createElement(
|
|
375
|
-
'span',
|
|
376
|
-
{ style: { marginLeft: 'auto' } },
|
|
377
|
-
serve.enabled
|
|
378
|
-
? actionBtn('下线', false, () => void act('server-disable', ''), busy === 'server-disable:')
|
|
379
|
-
: actionBtn('上线', true, () => void act('server-enable', ''), busy === 'server-enable:'),
|
|
380
|
-
),
|
|
381
|
-
),
|
|
382
|
-
createElement(
|
|
383
|
-
'div',
|
|
384
|
-
{ style: { display: 'flex', flexDirection: 'column', gap: 6 } },
|
|
385
|
-
serve.agentName
|
|
386
|
-
? createElement(
|
|
387
|
-
'div',
|
|
388
|
-
{ style: { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary, #222)' } },
|
|
389
|
-
serve.agentName,
|
|
390
|
-
serve.agentVersion ? createElement('span', { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-tertiary, #999)', fontFamily: 'monospace', marginLeft: 8 } }, `v${serve.agentVersion}`) : null,
|
|
391
|
-
)
|
|
392
|
-
: null,
|
|
393
|
-
serve.agentDescription
|
|
394
|
-
? createElement('div', { style: { fontSize: 12.5, color: 'var(--dsw-alias-label-secondary, #666)' } }, serve.agentDescription)
|
|
395
|
-
: null,
|
|
396
|
-
serve.endpoint
|
|
397
|
-
? createElement('div', { style: { fontSize: 12, fontFamily: 'monospace', color: 'var(--dsw-alias-label-tertiary, #999)' } }, `端点 ${serve.endpoint}`)
|
|
398
|
-
: null,
|
|
399
|
-
serve.agentCardUrl
|
|
400
|
-
? createElement('div', { style: { fontSize: 12, fontFamily: 'monospace', color: 'var(--dsw-alias-label-tertiary, #999)' } }, `Agent Card ${serve.agentCardUrl}`)
|
|
401
|
-
: null,
|
|
402
|
-
(serve.skills?.length ?? 0) > 0
|
|
403
|
-
? createElement(
|
|
519
|
+
{ className: 'a2a-crew' },
|
|
520
|
+
sec('入站连接(谁在连接本 DSH)', inbound.length),
|
|
521
|
+
inbound.length === 0
|
|
522
|
+
? emptyRow('暂无入站连接。其他 A2A 客户端调用本 DSH 的 JSON-RPC 端点后会显示在这里。')
|
|
523
|
+
: createElement(
|
|
404
524
|
'div',
|
|
405
|
-
{
|
|
406
|
-
createElement(
|
|
407
|
-
|
|
408
|
-
|
|
525
|
+
{ className: 'a2a-table-wrap' },
|
|
526
|
+
createElement(
|
|
527
|
+
'table',
|
|
528
|
+
{ className: 'a2a-table' },
|
|
529
|
+
createElement('thead', null, headRow(['来源', '地址', '首次连接', '最近活动', '任务', '流', '操作'])),
|
|
530
|
+
createElement('tbody', null, ...inbound.map((p) => inboundRow(p, busy, act))),
|
|
409
531
|
),
|
|
410
|
-
)
|
|
411
|
-
: null,
|
|
532
|
+
),
|
|
412
533
|
),
|
|
413
|
-
serve.enabled ? executorNote(serve) : null,
|
|
414
|
-
serve.enabled ? authControl(serve, busy, act, authInput, setAuthInput) : null,
|
|
415
|
-
)
|
|
416
|
-
: null,
|
|
417
|
-
// ── inbound tab ────────────────────────────────────────
|
|
418
|
-
tab === 'in'
|
|
419
|
-
? createElement(
|
|
420
|
-
'div',
|
|
421
|
-
{ style: { display: 'flex', flexDirection: 'column', gap: 10 } },
|
|
422
|
-
sec('入站连接(谁在连接本 DSH)', inbound.length),
|
|
423
|
-
inbound.length === 0
|
|
424
|
-
? emptyRow('暂无入站连接。其他 A2A 客户端调用本 DSH 的 JSON-RPC 端点后会显示在这里。')
|
|
425
|
-
: createElement('table', tblStyle, createElement('thead', null, headRow(['来源', '地址', '首次连接', '最近活动', '任务', '流', '操作'])), createElement('tbody', null, ...inbound.map((p) => inboundRow(p, busy, act)))),
|
|
426
534
|
)
|
|
427
535
|
: null,
|
|
428
536
|
createElement(
|
|
429
537
|
'div',
|
|
430
|
-
{
|
|
538
|
+
{ className: 'a2a-footer' },
|
|
431
539
|
`快照时间 ${snap ? new Date(snap.at).toLocaleTimeString() : '—'} · 每 3 秒自动刷新`,
|
|
432
540
|
),
|
|
541
|
+
),
|
|
433
542
|
);
|
|
434
543
|
}
|
|
435
544
|
|
|
545
|
+
/** Editing wiring passed from the section into the (pure) agent card. */
|
|
546
|
+
interface AgentEditProps {
|
|
547
|
+
editingAgent: string | null;
|
|
548
|
+
agentDraft: AgentDraft;
|
|
549
|
+
setAgentDraft: (d: AgentDraft) => void;
|
|
550
|
+
startEditAgent: (a: OutboundAgentApi) => void;
|
|
551
|
+
saveAgentAdvanced: (target: string) => void;
|
|
552
|
+
cancelEditAgent: () => void;
|
|
553
|
+
}
|
|
554
|
+
|
|
436
555
|
// ── connected agent card (pure: hooks live in the parent section) ────────
|
|
437
556
|
function agentCard(
|
|
438
557
|
a: OutboundAgentApi,
|
|
@@ -440,10 +559,12 @@ function agentCard(
|
|
|
440
559
|
act: (a: string, t: string, p?: Record<string, unknown>) => Promise<void>,
|
|
441
560
|
expanded: Set<string>,
|
|
442
561
|
setExpanded: (s: Set<string>) => void,
|
|
562
|
+
edit: AgentEditProps,
|
|
443
563
|
): ReactElement {
|
|
444
564
|
const target = a.connectionId ?? a.id;
|
|
445
565
|
const open = expanded.has(target);
|
|
446
566
|
const skills = a.skills ?? [];
|
|
567
|
+
const editingThis = edit.editingAgent === target;
|
|
447
568
|
const toggle = (): void => {
|
|
448
569
|
const next = new Set(expanded);
|
|
449
570
|
if (next.has(target)) next.delete(target);
|
|
@@ -452,69 +573,136 @@ function agentCard(
|
|
|
452
573
|
};
|
|
453
574
|
return createElement(
|
|
454
575
|
'div',
|
|
455
|
-
{ key: a.id,
|
|
576
|
+
{ key: a.id, className: a.enabled === false ? 'a2a-card a2a-card-disabled' : 'a2a-card' },
|
|
456
577
|
// header row — click toggles skill list
|
|
457
578
|
createElement(
|
|
458
579
|
'div',
|
|
459
|
-
{
|
|
460
|
-
createElement(
|
|
580
|
+
{ className: 'a2a-card-header', onClick: toggle },
|
|
581
|
+
createElement(
|
|
582
|
+
'span',
|
|
583
|
+
{ className: 'a2a-card-chevron', 'data-open': String(open) },
|
|
584
|
+
'▶',
|
|
585
|
+
),
|
|
461
586
|
stateDot(a.enabled === false ? 'disabled' : a.state),
|
|
462
|
-
createElement('span', {
|
|
587
|
+
createElement('span', { className: 'a2a-card-title' }, a.agentName ?? a.name),
|
|
463
588
|
a.enabled === false
|
|
464
|
-
?
|
|
465
|
-
:
|
|
466
|
-
|
|
467
|
-
{ style: { fontSize: 11.5, ...(a.configured ? sourceBadge('#666', 'rgba(127,127,127,.12)', '配置') : sourceBadge('#4a7dff', 'rgba(74,125,255,.1)', '运行时')) } },
|
|
468
|
-
a.configured ? '配置' : '运行时',
|
|
469
|
-
),
|
|
470
|
-
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()}`),
|
|
589
|
+
? badge('disabled', '已禁用')
|
|
590
|
+
: badge(a.configured ? 'config' : 'runtime', a.configured ? '配置' : '运行时'),
|
|
591
|
+
createElement('span', { className: 'a2a-card-sub' }, `${a.skillCount} 技能 · ${a.toolCount} 工具 · ${new Date(a.lastSeen).toLocaleTimeString()}`),
|
|
471
592
|
// ── three operations: reconnect/enable · disable · delete (never wrap) ──
|
|
472
593
|
createElement(
|
|
473
594
|
'div',
|
|
474
|
-
{
|
|
595
|
+
{ className: 'a2a-card-ops' },
|
|
475
596
|
a.enabled === false
|
|
476
597
|
? actionBtn('启用', true, () => void act('enable-agent', target), busy === `enable-agent:${target}`)
|
|
477
598
|
: actionBtn('重连', true, () => void act('reconnect-agent', target), busy === `reconnect-agent:${target}`),
|
|
599
|
+
// Advanced settings only for saved (configured) agents.
|
|
600
|
+
a.configured
|
|
601
|
+
? actionBtn('设置', false, () => (editingThis ? edit.cancelEditAgent() : edit.startEditAgent(a)), false)
|
|
602
|
+
: null,
|
|
478
603
|
actionBtn('禁用', false, () => void act('disable-agent', target), busy === `disable-agent:${target}`),
|
|
479
604
|
actionBtn('删除', false, () => void act('remove-agent', target), busy === `remove-agent:${target}`),
|
|
480
605
|
),
|
|
481
606
|
),
|
|
482
607
|
createElement(
|
|
483
608
|
'div',
|
|
484
|
-
{
|
|
485
|
-
a.agentCardUrl,
|
|
609
|
+
{ className: 'a2a-meta-row' },
|
|
610
|
+
createElement('span', { className: 'a2a-card-url a2a-mono' }, a.agentCardUrl),
|
|
611
|
+
createElement(CopyButton, { text: a.agentCardUrl, label: 'Agent Card URL' }),
|
|
486
612
|
),
|
|
487
613
|
// expandable skills
|
|
488
614
|
open && skills.length > 0
|
|
489
615
|
? createElement(
|
|
490
616
|
'div',
|
|
491
|
-
{
|
|
617
|
+
{ className: 'a2a-skills' },
|
|
492
618
|
...skills.map((s) =>
|
|
493
619
|
createElement(
|
|
494
620
|
'div',
|
|
495
|
-
{ key: s.id,
|
|
621
|
+
{ key: s.id, className: 'a2a-skill-row' },
|
|
496
622
|
createElement(
|
|
497
623
|
'div',
|
|
498
|
-
{
|
|
499
|
-
|
|
624
|
+
{ className: 'a2a-skill-name' },
|
|
625
|
+
s.name,
|
|
500
626
|
s.tags?.length
|
|
501
|
-
? createElement('span', {
|
|
627
|
+
? createElement('span', { className: 'a2a-skill-tags' }, s.tags.join(', '))
|
|
502
628
|
: null,
|
|
503
629
|
),
|
|
504
630
|
s.description
|
|
505
|
-
? createElement('div', {
|
|
631
|
+
? createElement('div', { className: 'a2a-skill-desc' }, s.description)
|
|
506
632
|
: null,
|
|
507
633
|
),
|
|
508
634
|
),
|
|
509
635
|
)
|
|
510
636
|
: open
|
|
511
|
-
? createElement('div', {
|
|
637
|
+
? createElement('div', { className: 'a2a-skill-none' }, '(该 Agent 未声明技能)')
|
|
512
638
|
: null,
|
|
639
|
+
// advanced settings form (toggled by the 设置 button; independent of skills)
|
|
640
|
+
editingThis ? agentAdvancedForm(target, edit, busy) : null,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Per-agent advanced-settings form: timeoutMs / mapSkills / bearerToken. Saving
|
|
646
|
+
* persists to a2a.json and reconnects the agent to apply. Pure — draft lives in
|
|
647
|
+
* the parent section.
|
|
648
|
+
*/
|
|
649
|
+
function agentAdvancedForm(target: string, edit: AgentEditProps, busy: string | null): ReactElement {
|
|
650
|
+
const d = edit.agentDraft;
|
|
651
|
+
const saving = busy === `update-agent:${target}`;
|
|
652
|
+
return createElement(
|
|
653
|
+
'div',
|
|
654
|
+
{ className: 'a2a-adv' },
|
|
655
|
+
createElement('div', { className: 'a2a-form-label' }, '高级设置'),
|
|
656
|
+
createElement(
|
|
657
|
+
'div',
|
|
658
|
+
{ className: 'a2a-form-row' },
|
|
659
|
+
createElement('label', { className: 'a2a-form-label' }, '超时 (ms)'),
|
|
660
|
+
createElement('input', {
|
|
661
|
+
className: 'a2a-input',
|
|
662
|
+
type: 'number',
|
|
663
|
+
placeholder: '留空用默认',
|
|
664
|
+
value: d.timeoutMs,
|
|
665
|
+
onChange: (e: { target: { value: string } }) => edit.setAgentDraft({ ...d, timeoutMs: e.target.value }),
|
|
666
|
+
}),
|
|
667
|
+
),
|
|
668
|
+
createElement(
|
|
669
|
+
'label',
|
|
670
|
+
{ className: 'a2a-check-row' },
|
|
671
|
+
createElement('input', {
|
|
672
|
+
type: 'checkbox',
|
|
673
|
+
checked: d.mapSkills,
|
|
674
|
+
onChange: (e: { target: { checked: boolean } }) => edit.setAgentDraft({ ...d, mapSkills: e.target.checked }),
|
|
675
|
+
}),
|
|
676
|
+
createElement('span', null, '按技能拆分为多个工具(关闭则合并为单个工具)'),
|
|
677
|
+
),
|
|
678
|
+
createElement(
|
|
679
|
+
'div',
|
|
680
|
+
{ className: 'a2a-form-row' },
|
|
681
|
+
createElement('label', { className: 'a2a-form-label' }, 'Token'),
|
|
682
|
+
createElement('input', {
|
|
683
|
+
className: 'a2a-input',
|
|
684
|
+
type: 'password',
|
|
685
|
+
placeholder: '留空不修改',
|
|
686
|
+
value: d.bearerToken,
|
|
687
|
+
onChange: (e: { target: { value: string } }) => edit.setAgentDraft({ ...d, bearerToken: e.target.value }),
|
|
688
|
+
}),
|
|
689
|
+
),
|
|
690
|
+
createElement(
|
|
691
|
+
'div',
|
|
692
|
+
{ className: 'a2a-hint' },
|
|
693
|
+
'保存后会以新配置重连该 Agent,使超时/工具拆分/Token 生效。',
|
|
694
|
+
),
|
|
695
|
+
createElement(
|
|
696
|
+
'div',
|
|
697
|
+
{ className: 'a2a-btn-row' },
|
|
698
|
+
actionBtn('保存并重连', true, () => edit.saveAgentAdvanced(target), saving),
|
|
699
|
+
actionBtn('取消', false, edit.cancelEditAgent, false),
|
|
700
|
+
),
|
|
513
701
|
);
|
|
514
702
|
}
|
|
515
703
|
|
|
516
|
-
function
|
|
517
|
-
return {
|
|
704
|
+
function badge(kind: 'config' | 'runtime' | 'disabled', label: string): ReactElement {
|
|
705
|
+
return createElement('span', { className: `a2a-badge a2a-badge-${kind}` }, label);
|
|
518
706
|
}
|
|
519
707
|
|
|
520
708
|
/** A tab button matching the settings "插件" page's underline tab style. */
|
|
@@ -525,71 +713,38 @@ function tabBtn(label: string, count: number, active: boolean, onClick: () => vo
|
|
|
525
713
|
onClick,
|
|
526
714
|
'aria-selected': active,
|
|
527
715
|
role: 'tab',
|
|
528
|
-
'data-active': active,
|
|
529
|
-
|
|
530
|
-
padding: '7px 1px 9px',
|
|
531
|
-
fontSize: 13,
|
|
532
|
-
lineHeight: '20px',
|
|
533
|
-
fontWeight: 400,
|
|
534
|
-
color: active ? 'var(--dsw-alias-label-primary, #0f1115)' : 'var(--dsw-alias-label-tertiary, #81858c)',
|
|
535
|
-
background: 'transparent',
|
|
536
|
-
border: 'none',
|
|
537
|
-
cursor: 'pointer',
|
|
538
|
-
position: 'relative',
|
|
539
|
-
// underline via box-shadow inset (React inline styles can't do ::after)
|
|
540
|
-
boxShadow: active ? 'inset 0 -2px 0 var(--dsw-alias-label-primary, #0f1115)' : 'none',
|
|
541
|
-
borderRadius: '2px 2px 0 0',
|
|
542
|
-
transition: 'color .15s, box-shadow .15s',
|
|
543
|
-
},
|
|
716
|
+
'data-active': String(active),
|
|
717
|
+
className: 'a2a-tab',
|
|
544
718
|
},
|
|
545
719
|
label,
|
|
546
|
-
count > 0 ? createElement('span', {
|
|
720
|
+
count > 0 ? createElement('span', { className: 'a2a-tab-count' }, String(count)) : null,
|
|
547
721
|
);
|
|
548
722
|
}
|
|
549
723
|
|
|
550
|
-
/** The tab strip container
|
|
724
|
+
/** The tab strip container (underline rail, like first-party settings pages). */
|
|
551
725
|
function tabRail(children: Array<ReactElement | null>): ReactElement {
|
|
552
|
-
return createElement(
|
|
553
|
-
'div',
|
|
554
|
-
{ 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 } },
|
|
555
|
-
...children,
|
|
556
|
-
);
|
|
726
|
+
return createElement('div', { role: 'tablist', className: 'a2a-tabs' }, ...children);
|
|
557
727
|
}
|
|
558
728
|
|
|
559
729
|
function sec(title: string, count: number): ReactElement {
|
|
560
730
|
return createElement(
|
|
561
731
|
'h3',
|
|
562
|
-
{
|
|
732
|
+
{ className: 'a2a-h3' },
|
|
563
733
|
title,
|
|
564
|
-
createElement('span', {
|
|
734
|
+
createElement('span', { className: 'a2a-count' }, String(count)),
|
|
565
735
|
);
|
|
566
736
|
}
|
|
567
737
|
|
|
568
738
|
function emptyRow(text: string): ReactElement {
|
|
569
|
-
return createElement(
|
|
570
|
-
'div',
|
|
571
|
-
{ 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' } },
|
|
572
|
-
text,
|
|
573
|
-
);
|
|
739
|
+
return createElement('div', { className: 'a2a-empty' }, text);
|
|
574
740
|
}
|
|
575
741
|
|
|
576
|
-
const tblStyle = { borderCollapse: 'collapse' as const, width: '100%', fontSize: 13 };
|
|
577
|
-
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 };
|
|
578
|
-
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 };
|
|
579
|
-
|
|
580
742
|
function headRow(cols: string[]): ReactElement {
|
|
581
|
-
return createElement('tr', null, ...cols.map((c) => createElement('th',
|
|
743
|
+
return createElement('tr', null, ...cols.map((c) => createElement('th', null, c)));
|
|
582
744
|
}
|
|
583
745
|
|
|
584
|
-
const stateColor: Record<string, string> = {
|
|
585
|
-
connected: '#2e7d32',
|
|
586
|
-
disconnected: '#9e9e9e',
|
|
587
|
-
reconnecting: '#f57c00',
|
|
588
|
-
disabled: '#b26a00',
|
|
589
|
-
};
|
|
590
|
-
|
|
591
746
|
function stateDot(state: string): ReactElement {
|
|
592
|
-
return createElement('span', {
|
|
747
|
+
return createElement('span', { className: 'a2a-dot', 'data-state': state });
|
|
593
748
|
}
|
|
594
749
|
|
|
595
750
|
function actionBtn(label: string, primary: boolean, onClick: () => void, disabled: boolean): ReactElement {
|
|
@@ -598,23 +753,60 @@ function actionBtn(label: string, primary: boolean, onClick: () => void, disable
|
|
|
598
753
|
{
|
|
599
754
|
onClick,
|
|
600
755
|
disabled,
|
|
601
|
-
|
|
602
|
-
marginRight: 6,
|
|
603
|
-
padding: '3px 10px',
|
|
604
|
-
fontSize: 12,
|
|
605
|
-
lineHeight: '18px',
|
|
606
|
-
borderRadius: 6,
|
|
607
|
-
border: `1px solid ${primary ? 'var(--dsw-brand, #4a7dff)' : 'var(--dsw-alias-border-l2, rgba(127,127,127,.3))'}`,
|
|
608
|
-
background: primary ? 'var(--dsw-brand, #4a7dff)' : 'transparent',
|
|
609
|
-
color: primary ? '#fff' : 'var(--dsw-alias-label-primary, #222)',
|
|
610
|
-
cursor: disabled ? 'default' : 'pointer',
|
|
611
|
-
opacity: disabled ? 0.5 : 1,
|
|
612
|
-
},
|
|
756
|
+
className: primary ? 'a2a-btn a2a-btn-primary' : 'a2a-btn',
|
|
613
757
|
},
|
|
614
758
|
label,
|
|
615
759
|
);
|
|
616
760
|
}
|
|
617
761
|
|
|
762
|
+
/**
|
|
763
|
+
* One-click copy button with transient "已复制 ✓" feedback. Uses the async
|
|
764
|
+
* Clipboard API when available (secure contexts), falls back to execCommand
|
|
765
|
+
* otherwise. The copied state lives in this component so the section's hook
|
|
766
|
+
* order stays untouched.
|
|
767
|
+
*/
|
|
768
|
+
function CopyButton(props: { text: string; label?: string }): ReactElement {
|
|
769
|
+
const [copied, setCopied] = useState(false);
|
|
770
|
+
useEffect(() => {
|
|
771
|
+
if (!copied) return;
|
|
772
|
+
const t = window.setTimeout(() => setCopied(false), 1500);
|
|
773
|
+
return () => window.clearTimeout(t);
|
|
774
|
+
}, [copied]);
|
|
775
|
+
const onCopy = async (): Promise<void> => {
|
|
776
|
+
try {
|
|
777
|
+
await navigator.clipboard.writeText(props.text);
|
|
778
|
+
setCopied(true);
|
|
779
|
+
return;
|
|
780
|
+
} catch {
|
|
781
|
+
// Clipboard API unavailable (non-secure context) — fall through.
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
const ta = document.createElement('textarea');
|
|
785
|
+
ta.value = props.text;
|
|
786
|
+
ta.style.position = 'fixed';
|
|
787
|
+
ta.style.opacity = '0';
|
|
788
|
+
document.body.appendChild(ta);
|
|
789
|
+
ta.select();
|
|
790
|
+
document.execCommand('copy');
|
|
791
|
+
document.body.removeChild(ta);
|
|
792
|
+
setCopied(true);
|
|
793
|
+
} catch {
|
|
794
|
+
// Give up silently; the button is cosmetic.
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
return createElement(
|
|
798
|
+
'button',
|
|
799
|
+
{
|
|
800
|
+
type: 'button',
|
|
801
|
+
className: 'a2a-copy-btn',
|
|
802
|
+
onClick: () => void onCopy(),
|
|
803
|
+
'data-copied': String(copied),
|
|
804
|
+
'aria-label': `${copied ? '已复制' : '复制'}${props.label ? ` ${props.label}` : ''}`,
|
|
805
|
+
},
|
|
806
|
+
copied ? '已复制 ✓' : '复制',
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
|
|
618
810
|
/**
|
|
619
811
|
* A semantically-correct note about how inbound tasks are executed. Dispatches
|
|
620
812
|
* on the `executor` field; falls back to the legacy `customExecutor` boolean
|
|
@@ -629,20 +821,233 @@ function executorNote(serve: {
|
|
|
629
821
|
if (kind === 'dsh-agent') {
|
|
630
822
|
return createElement(
|
|
631
823
|
'div',
|
|
632
|
-
{
|
|
824
|
+
{ className: 'a2a-note' },
|
|
633
825
|
'ℹ 入站任务由本 DSH 的 agent 会话执行,按 A2A contextId 一个对话一个会话。',
|
|
634
826
|
);
|
|
635
827
|
}
|
|
636
828
|
if (kind === 'none') {
|
|
637
829
|
return createElement(
|
|
638
830
|
'div',
|
|
639
|
-
{
|
|
831
|
+
{ className: 'a2a-note a2a-note-warn' },
|
|
640
832
|
'⚠ 未配置 executor,且未检测到可用的 agent 循环:入站任务会被拒绝。通过配置注入 execute,或加载 agent-loop 插件。',
|
|
641
833
|
);
|
|
642
834
|
}
|
|
643
835
|
return null;
|
|
644
836
|
}
|
|
645
837
|
|
|
838
|
+
/**
|
|
839
|
+
* True when the server has never been given an identity — no agentName (or the
|
|
840
|
+
* bare default) and no skills. Drives the guided-setup card for fresh installs.
|
|
841
|
+
*/
|
|
842
|
+
function needsServeSetup(serve: ServeStatus): boolean {
|
|
843
|
+
const name = (serve.agentName ?? '').trim();
|
|
844
|
+
const unnamed = name === '' || name === 'DSH Agent';
|
|
845
|
+
const noSkills = (serve.skills?.length ?? 0) === 0;
|
|
846
|
+
return unnamed && noSkills;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Read-only serve panel: status + identity + endpoints + executor/auth, with an
|
|
851
|
+
* "编辑" button that flips the section into {@link serveEditPanel}. Pure (no
|
|
852
|
+
* hooks): the edit-mode flag and draft live in the parent section.
|
|
853
|
+
*/
|
|
854
|
+
function serveViewPanel(
|
|
855
|
+
serve: ServeStatus,
|
|
856
|
+
busy: string | null,
|
|
857
|
+
act: (a: string, t: string, p?: Record<string, unknown>) => Promise<void>,
|
|
858
|
+
authInput: string,
|
|
859
|
+
setAuthInput: (v: string) => void,
|
|
860
|
+
onEdit: () => void,
|
|
861
|
+
): ReactElement {
|
|
862
|
+
return createElement(
|
|
863
|
+
'div',
|
|
864
|
+
{ className: 'a2a-panel' },
|
|
865
|
+
createElement(
|
|
866
|
+
'div',
|
|
867
|
+
{ className: 'a2a-serve-head' },
|
|
868
|
+
sec('A2A 服务(本 DSH 作为 Agent 对外)', 0),
|
|
869
|
+
stateDot(serve.enabled ? 'connected' : 'disconnected'),
|
|
870
|
+
createElement(
|
|
871
|
+
'span',
|
|
872
|
+
{ className: 'a2a-serve-status', 'data-on': String(serve.enabled) },
|
|
873
|
+
serve.enabled ? '运行中' : '已下线',
|
|
874
|
+
),
|
|
875
|
+
createElement(
|
|
876
|
+
'span',
|
|
877
|
+
{ className: 'a2a-serve-ops' },
|
|
878
|
+
actionBtn('编辑', false, onEdit, false),
|
|
879
|
+
serve.enabled
|
|
880
|
+
? actionBtn('下线', false, () => void act('server-disable', ''), busy === 'server-disable:')
|
|
881
|
+
: actionBtn('上线', true, () => void act('server-enable', ''), busy === 'server-enable:'),
|
|
882
|
+
),
|
|
883
|
+
),
|
|
884
|
+
createElement(
|
|
885
|
+
'div',
|
|
886
|
+
{ className: 'a2a-serve-meta' },
|
|
887
|
+
serve.agentName
|
|
888
|
+
? createElement(
|
|
889
|
+
'div',
|
|
890
|
+
{ className: 'a2a-serve-name' },
|
|
891
|
+
serve.agentName,
|
|
892
|
+
serve.agentVersion ? createElement('span', { className: 'a2a-serve-version a2a-mono' }, `v${serve.agentVersion}`) : null,
|
|
893
|
+
)
|
|
894
|
+
: null,
|
|
895
|
+
serve.agentDescription
|
|
896
|
+
? createElement('div', { className: 'a2a-serve-desc' }, serve.agentDescription)
|
|
897
|
+
: null,
|
|
898
|
+
serve.endpoint
|
|
899
|
+
? createElement(
|
|
900
|
+
'div',
|
|
901
|
+
{ className: 'a2a-meta-row' },
|
|
902
|
+
createElement('span', { className: 'a2a-meta-key' }, '端点'),
|
|
903
|
+
createElement('span', { className: 'a2a-serve-endpoint a2a-mono' }, serve.endpoint),
|
|
904
|
+
createElement(CopyButton, { text: serve.endpoint, label: '端点' }),
|
|
905
|
+
)
|
|
906
|
+
: null,
|
|
907
|
+
serve.agentCardUrl
|
|
908
|
+
? createElement(
|
|
909
|
+
'div',
|
|
910
|
+
{ className: 'a2a-meta-row' },
|
|
911
|
+
createElement('span', { className: 'a2a-meta-key' }, 'Agent Card'),
|
|
912
|
+
createElement('span', { className: 'a2a-serve-endpoint a2a-mono' }, serve.agentCardUrl),
|
|
913
|
+
createElement(CopyButton, { text: serve.agentCardUrl, label: 'Agent Card URL' }),
|
|
914
|
+
)
|
|
915
|
+
: null,
|
|
916
|
+
(serve.skills?.length ?? 0) > 0
|
|
917
|
+
? createElement(
|
|
918
|
+
'div',
|
|
919
|
+
{ className: 'a2a-skill-list' },
|
|
920
|
+
createElement('div', { className: 'a2a-skill-list-title' }, 'Skills'),
|
|
921
|
+
...(serve.skills ?? []).map((s) =>
|
|
922
|
+
createElement('div', { key: s.id }, `• ${s.name}${s.description ? ` — ${s.description}` : ''}`),
|
|
923
|
+
),
|
|
924
|
+
)
|
|
925
|
+
: null,
|
|
926
|
+
),
|
|
927
|
+
serve.enabled ? executorNote(serve) : null,
|
|
928
|
+
serve.enabled ? authControl(serve, busy, act, authInput, setAuthInput) : null,
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Serve-identity edit form (also the guided-setup card for fresh installs).
|
|
934
|
+
* Pure: the draft + setter live in the parent section. `guided` switches the
|
|
935
|
+
* copy and the primary action (create-and-serve vs save).
|
|
936
|
+
*/
|
|
937
|
+
function serveEditPanel(
|
|
938
|
+
draft: ServeDraft,
|
|
939
|
+
setDraft: (d: ServeDraft) => void,
|
|
940
|
+
onSave: (also?: 'enable') => void,
|
|
941
|
+
onCancel: () => void,
|
|
942
|
+
busy: string | null,
|
|
943
|
+
guided: boolean,
|
|
944
|
+
): ReactElement {
|
|
945
|
+
const set = (patch: Partial<ServeDraft>): void => setDraft({ ...draft, ...patch });
|
|
946
|
+
const setSkill = (i: number, patch: Partial<SkillDraft>): void =>
|
|
947
|
+
setDraft({ ...draft, skills: draft.skills.map((s, j) => (j === i ? { ...s, ...patch } : s)) });
|
|
948
|
+
const addSkill = (): void => setDraft({ ...draft, skills: [...draft.skills, { id: '', name: '', description: '', tags: '' }] });
|
|
949
|
+
const delSkill = (i: number): void => setDraft({ ...draft, skills: draft.skills.filter((_, j) => j !== i) });
|
|
950
|
+
const saving = busy === 'set-server-identity:' || busy === 'server-enable:';
|
|
951
|
+
const field = (label: string, key: keyof Omit<ServeDraft, 'skills'>, placeholder: string): ReactElement =>
|
|
952
|
+
createElement(
|
|
953
|
+
'div',
|
|
954
|
+
{ className: 'a2a-form-row' },
|
|
955
|
+
createElement('label', { className: 'a2a-form-label' }, label),
|
|
956
|
+
createElement('input', {
|
|
957
|
+
className: 'a2a-input',
|
|
958
|
+
placeholder,
|
|
959
|
+
value: draft[key],
|
|
960
|
+
onChange: (e: { target: { value: string } }) => set({ [key]: e.target.value } as Partial<ServeDraft>),
|
|
961
|
+
}),
|
|
962
|
+
);
|
|
963
|
+
return createElement(
|
|
964
|
+
'div',
|
|
965
|
+
{ className: 'a2a-panel' },
|
|
966
|
+
createElement(
|
|
967
|
+
'div',
|
|
968
|
+
{ className: 'a2a-panel-title' },
|
|
969
|
+
guided ? '配置对外服务(首次设置)' : '编辑对外服务',
|
|
970
|
+
),
|
|
971
|
+
guided
|
|
972
|
+
? createElement(
|
|
973
|
+
'div',
|
|
974
|
+
{ className: 'a2a-note' },
|
|
975
|
+
'ℹ 填写下面的信息即可把本 DSH 作为 A2A Agent 发布,保存后自动上线。所有配置写入 a2a.json,无需手改文件。',
|
|
976
|
+
)
|
|
977
|
+
: null,
|
|
978
|
+
field('名称', 'agentName', '如 My DSH Agent'),
|
|
979
|
+
field('描述', 'agentDescription', '一句话说明这个 Agent 能做什么'),
|
|
980
|
+
field('版本', 'agentVersion', '如 0.1.0'),
|
|
981
|
+
field('Base URL', 'baseUrl', '留空自动推断(对外可达地址)'),
|
|
982
|
+
createElement(
|
|
983
|
+
'div',
|
|
984
|
+
{ className: 'a2a-hint' },
|
|
985
|
+
'留空则按服务实际监听地址自动推断;仅在有反向代理或公网域名时才需要手动填写。',
|
|
986
|
+
),
|
|
987
|
+
field('端点路径', 'endpointPath', '留空默认 /a2a'),
|
|
988
|
+
// ── skills editor ──
|
|
989
|
+
createElement(
|
|
990
|
+
'div',
|
|
991
|
+
{ className: 'a2a-skill-edit-head' },
|
|
992
|
+
createElement('span', { className: 'a2a-form-label' }, `技能(${draft.skills.length})`),
|
|
993
|
+
actionBtn('+ 添加技能', false, addSkill, false),
|
|
994
|
+
),
|
|
995
|
+
draft.skills.length === 0
|
|
996
|
+
? createElement('div', { className: 'a2a-hint' }, '至少添加一个技能,远程调用方才知道本 Agent 能做什么。')
|
|
997
|
+
: createElement(
|
|
998
|
+
'div',
|
|
999
|
+
{ className: 'a2a-skill-edit-list' },
|
|
1000
|
+
...draft.skills.map((s, i) => skillEditRow(s, i, setSkill, delSkill)),
|
|
1001
|
+
),
|
|
1002
|
+
createElement(
|
|
1003
|
+
'div',
|
|
1004
|
+
{ className: 'a2a-btn-row' },
|
|
1005
|
+
guided
|
|
1006
|
+
? actionBtn('创建并上线', true, () => onSave('enable'), saving)
|
|
1007
|
+
: actionBtn('保存', true, () => onSave(), saving),
|
|
1008
|
+
actionBtn('取消', false, onCancel, false),
|
|
1009
|
+
),
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** One editable skill row (id/name/description + tags) with a delete button. */
|
|
1014
|
+
function skillEditRow(
|
|
1015
|
+
s: SkillDraft,
|
|
1016
|
+
i: number,
|
|
1017
|
+
setSkill: (i: number, patch: Partial<SkillDraft>) => void,
|
|
1018
|
+
delSkill: (i: number) => void,
|
|
1019
|
+
): ReactElement {
|
|
1020
|
+
const inp = (key: keyof SkillDraft, placeholder: string): ReactElement =>
|
|
1021
|
+
createElement('input', {
|
|
1022
|
+
className: 'a2a-input',
|
|
1023
|
+
placeholder,
|
|
1024
|
+
value: s[key],
|
|
1025
|
+
onChange: (e: { target: { value: string } }) => setSkill(i, { [key]: e.target.value } as Partial<SkillDraft>),
|
|
1026
|
+
});
|
|
1027
|
+
return createElement(
|
|
1028
|
+
'div',
|
|
1029
|
+
{ key: String(i), className: 'a2a-skill-edit-row' },
|
|
1030
|
+
createElement(
|
|
1031
|
+
'div',
|
|
1032
|
+
{ className: 'a2a-skill-edit-grid' },
|
|
1033
|
+
inp('id', 'id(唯一标识,如 coding)'),
|
|
1034
|
+
inp('name', '名称(如 Coding)'),
|
|
1035
|
+
),
|
|
1036
|
+
inp('description', '描述'),
|
|
1037
|
+
createElement(
|
|
1038
|
+
'div',
|
|
1039
|
+
{ className: 'a2a-skill-edit-foot' },
|
|
1040
|
+
createElement('input', {
|
|
1041
|
+
className: 'a2a-input',
|
|
1042
|
+
placeholder: '标签(逗号分隔,可选)',
|
|
1043
|
+
value: s.tags,
|
|
1044
|
+
onChange: (e: { target: { value: string } }) => setSkill(i, { tags: e.target.value }),
|
|
1045
|
+
}),
|
|
1046
|
+
actionBtn('删除', false, () => delSkill(i), false),
|
|
1047
|
+
),
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
646
1051
|
/**
|
|
647
1052
|
* Inbound bearer-token control for the serve tab: shows whether the endpoint is
|
|
648
1053
|
* token-gated, and lets the operator set or clear the shared token. The token
|
|
@@ -655,30 +1060,29 @@ function authControl(
|
|
|
655
1060
|
authInput: string,
|
|
656
1061
|
setAuthInput: (v: string) => void,
|
|
657
1062
|
): ReactElement {
|
|
658
|
-
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)' };
|
|
659
1063
|
return createElement(
|
|
660
1064
|
'div',
|
|
661
|
-
{
|
|
1065
|
+
{ className: 'a2a-auth' },
|
|
662
1066
|
createElement(
|
|
663
1067
|
'div',
|
|
664
|
-
{
|
|
665
|
-
createElement('span', {
|
|
1068
|
+
{ className: 'a2a-auth-row' },
|
|
1069
|
+
createElement('span', { className: 'a2a-auth-label' }, '入站鉴权'),
|
|
666
1070
|
stateDot(serve.authConfigured ? 'connected' : 'disabled'),
|
|
667
1071
|
createElement(
|
|
668
1072
|
'span',
|
|
669
|
-
{
|
|
1073
|
+
{ className: 'a2a-auth-state', 'data-on': String(serve.authConfigured) },
|
|
670
1074
|
serve.authConfigured ? '已启用 Bearer 鉴权' : '未鉴权(任何客户端可调用)',
|
|
671
1075
|
),
|
|
672
1076
|
),
|
|
673
1077
|
createElement(
|
|
674
1078
|
'div',
|
|
675
|
-
{
|
|
1079
|
+
{ className: 'a2a-auth-input-row' },
|
|
676
1080
|
createElement('input', {
|
|
1081
|
+
className: 'a2a-input',
|
|
677
1082
|
type: 'password',
|
|
678
1083
|
placeholder: serve.authConfigured ? '输入新 token 以替换' : '设置 Bearer Token 以开启鉴权',
|
|
679
1084
|
value: authInput,
|
|
680
1085
|
onChange: (e: { target: { value: string } }) => setAuthInput(e.target.value),
|
|
681
|
-
style: { ...inputStyle, flex: 1, minWidth: 220 },
|
|
682
1086
|
}),
|
|
683
1087
|
actionBtn('保存', true, () => { void act('set-server-auth', '', { authToken: authInput.trim() }); setAuthInput(''); }, busy === 'set-server-auth:' || !authInput.trim()),
|
|
684
1088
|
serve.authConfigured
|
|
@@ -687,7 +1091,7 @@ function authControl(
|
|
|
687
1091
|
),
|
|
688
1092
|
createElement(
|
|
689
1093
|
'div',
|
|
690
|
-
{
|
|
1094
|
+
{ className: 'a2a-hint' },
|
|
691
1095
|
'token 会明文存入 a2a.json,重启后仍生效。设置后,入站 JSON-RPC 请求需带 Authorization: Bearer <token>。',
|
|
692
1096
|
),
|
|
693
1097
|
);
|
|
@@ -697,16 +1101,16 @@ function inboundRow(p: InboundPeerApi, busy: string | null, act: (a: string, t:
|
|
|
697
1101
|
return createElement(
|
|
698
1102
|
'tr',
|
|
699
1103
|
{ key: p.id },
|
|
700
|
-
createElement('td', {
|
|
701
|
-
createElement('td', {
|
|
702
|
-
createElement('td', {
|
|
703
|
-
createElement('td', {
|
|
704
|
-
createElement('td', {
|
|
705
|
-
createElement('td', {
|
|
1104
|
+
createElement('td', { 'data-label': '来源' }, p.label),
|
|
1105
|
+
createElement('td', { className: 'a2a-cell-mono', 'data-label': '地址' }, p.source ?? '—'),
|
|
1106
|
+
createElement('td', { 'data-label': '首次连接' }, new Date(p.firstSeen).toLocaleTimeString()),
|
|
1107
|
+
createElement('td', { 'data-label': '最近活动' }, new Date(p.lastSeen).toLocaleTimeString()),
|
|
1108
|
+
createElement('td', { 'data-label': '任务' }, String(p.taskCount)),
|
|
1109
|
+
createElement('td', { 'data-label': '流' }, p.streaming ? '● 流' : '—'),
|
|
706
1110
|
createElement(
|
|
707
1111
|
'td',
|
|
708
|
-
{
|
|
1112
|
+
{ 'data-label': '操作' },
|
|
709
1113
|
actionBtn('关闭', false, () => void act('close-peer', p.id), busy === `close-peer:${p.id}`),
|
|
710
1114
|
),
|
|
711
1115
|
);
|
|
712
|
-
}
|
|
1116
|
+
}
|