@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.
@@ -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
- const [tab, setTab] = useState<'out' | 'in' | 'serve'>('out');
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
- interface ServeStatus {
124
- enabled: boolean;
125
- baseUrl?: string;
126
- agentName?: string;
127
- agentDescription?: string;
128
- agentVersion?: string;
129
- endpoint?: string;
130
- agentCardUrl?: string;
131
- skills?: Array<{ id: string; name: string; description?: string }>;
132
- /** Legacy: true when a custom executor was injected (kept for old snapshots). */
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
- interface DiscoveredCard {
182
- name: string;
183
- description?: string;
184
- version?: string;
185
- agentCardUrl: string;
186
- endpoint?: string;
187
- skills?: Array<{ id: string; name: string; description?: string }>;
188
- capabilities?: Record<string, unknown>;
189
- defaultInputModes?: string[];
190
- defaultOutputModes?: string[];
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 doDiscover = async (): Promise<void> => {
194
- const u = addUrl.trim();
195
- if (!u) return;
196
- setDiscovering(true);
197
- setDiscovered(null);
198
- setNotice(null);
199
- try {
200
- const res = await fetch('/a2a/api', {
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
- setDiscovered(JSON.parse(body.message) as DiscoveredCard);
211
- } catch (err) {
212
- setNotice(`✗ ${err instanceof Error ? err.message : String(err)}`);
213
- } finally {
214
- setDiscovering(false);
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
- 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)' };
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
- { style: { display: 'flex', flexDirection: 'column', gap: 20 } },
375
+ { className: 'a2a-root' },
376
+ createElement(
377
+ 'div',
378
+ { className: 'a2a-section' },
234
379
  // ── intro ─────────────────────────────────────────────
235
380
  createElement(
236
- 'div',
237
- { style: { color: 'var(--dsw-alias-label-secondary, #666)', fontSize: 13, lineHeight: 1.6 } },
238
- '管理本 DSH 的 A2A Agent 连接:连接远程 Agent(出站),查看谁在调用本 DSH 的 A2A 服务(入站)。',
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('出站 Agent', outbound.length, tab === 'out', () => setTab('out')),
257
- tabBtn('入站连接', inbound.length, tab === 'in', () => setTab('in')),
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
- // ── outbound tab ──────────────────────────────────────
261
- tab === 'out'
394
+ // ── client tab (outbound: agents this DSH connects out to) ──────────
395
+ tab === 'client'
262
396
  ? createElement(
263
397
  'div',
264
- { style: { display: 'flex', flexDirection: 'column', gap: 16 } },
398
+ { className: 'a2a-stack' },
265
399
  // Connected agents (cards)
266
400
  createElement(
267
401
  'div',
268
- { style: { display: 'flex', flexDirection: 'column', gap: 10 } },
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
- { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
275
- ...outbound.map((a) => agentCard(a, busy, act, expanded, setExpanded)),
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
- { style: { display: 'flex', flexDirection: 'column', gap: 10, background: 'var(--dsw-alias-fill-l2, rgba(127,127,127,.05))', borderRadius: 10, padding: '14px 16px' } },
282
- createElement('div', { style: { fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary, #222)' } }, '添加 Agent'),
424
+ { className: 'a2a-panel' },
425
+ createElement('div', { className: 'a2a-panel-title' }, '添加 Agent'),
283
426
  createElement(
284
427
  'div',
285
- { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
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
- { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
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
- { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-tertiary, #999)' } },
453
+ { className: 'a2a-hint' },
311
454
  '若远程 Agent 需要鉴权,填入 token;导入与后续调用都会带上。token 会明文存入 a2a.json。',
312
455
  ),
313
456
  discovering
314
- ? createElement('div', { style: { fontSize: 13, color: 'var(--dsw-alias-label-tertiary, #999)' } }, '正在读取 Agent Card…')
457
+ ? createElement('div', { className: 'a2a-hint' }, '正在读取 Agent Card…')
315
458
  : discovered
316
459
  ? createElement(
317
460
  'div',
318
- { style: { display: 'flex', flexDirection: 'column', gap: 8, background: 'var(--dsw-alias-fill-l1, rgba(127,127,127,.06))', borderRadius: 8, padding: '12px 14px' } },
461
+ { className: 'a2a-preview' },
319
462
  createElement(
320
463
  'div',
321
- { style: { display: 'flex', alignItems: 'center', gap: 8 } },
322
- createElement('span', { style: { fontSize: 15, fontWeight: 700, color: 'var(--dsw-alias-label-primary, #222)' } }, discovered.name),
464
+ { className: 'a2a-preview-head' },
465
+ createElement('span', { className: 'a2a-preview-name' }, discovered.name),
323
466
  discovered.version
324
- ? createElement('span', { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-tertiary, #999)', fontFamily: 'monospace' } }, `v${discovered.version}`)
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', { style: { fontSize: 12.5, color: 'var(--dsw-alias-label-secondary, #666)' } }, discovered.description)
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
- { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
335
- createElement('div', { style: { fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-label-secondary, #666)' } }, 'Skills'),
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, style: { fontSize: 12.5, color: 'var(--dsw-alias-label-primary, #222)' } }, `• ${s.name}${s.description ? ` — ${s.description}` : ''}`),
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', { style: { fontSize: 11.5, fontFamily: 'monospace', color: 'var(--dsw-alias-label-tertiary, #999)' } }, `端点 ${discovered.endpoint}`)
485
+ ? createElement('div', { className: 'a2a-preview-meta a2a-mono' }, `端点 ${discovered.endpoint}`)
343
486
  : null,
344
487
  createElement(
345
488
  'div',
346
- { style: { display: 'flex', gap: 8, marginTop: 2 } },
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
- { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)' } },
496
+ { className: 'a2a-hint' },
354
497
  '输入远程 Agent 的 Agent Card URL 并点击"导入",将自动读取其名称、描述、技能与能力,确认后建立连接。',
355
498
  ),
356
499
  ),
357
500
  )
358
501
  : null,
359
- // ── serve tab ─────────────────────────────────────────
360
- tab === 'serve' && serve
502
+ // ── server tab (this DSH as an A2A agent: serve config + inbound) ──────
503
+ tab === 'server'
361
504
  ? createElement(
362
505
  'div',
363
- { style: { display: 'flex', flexDirection: 'column', gap: 10, background: 'var(--dsw-alias-fill-l2, rgba(127,127,127,.05))', borderRadius: 10, padding: '14px 16px' } },
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
- { style: { display: 'flex', alignItems: 'center', gap: 8 } },
367
- sec('A2A 服务(本 DSH 作为 Agent 对外)', 0),
368
- stateDot(serve.enabled ? 'connected' : 'disconnected'),
369
- createElement(
370
- 'span',
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
- { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
406
- createElement('div', { style: { fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-label-secondary, #666)' } }, 'Skills'),
407
- ...(serve.skills ?? []).map((s) =>
408
- createElement('div', { key: s.id, style: { fontSize: 12.5, color: 'var(--dsw-alias-label-primary, #222)' } }, `• ${s.name}${s.description ? ` — ${s.description}` : ''}`),
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
- { style: { color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 12 } },
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, 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 } },
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
- { style: { display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', userSelect: 'none' as const, flexWrap: 'wrap' as const }, onClick: toggle },
460
- 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' } }, '▶'),
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', { style: { fontSize: 14, fontWeight: 600, color: 'var(--dsw-alias-label-primary, #222)' } }, a.agentName ?? a.name),
587
+ createElement('span', { className: 'a2a-card-title' }, a.agentName ?? a.name),
463
588
  a.enabled === false
464
- ? createElement('span', { style: { fontSize: 11.5, ...sourceBadge('#b26a00', 'rgba(178,106,0,.1)', '已禁用') } }, '已禁用')
465
- : createElement(
466
- 'span',
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
- { style: { display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0, marginLeft: 'auto', paddingLeft: 10 } },
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
- { style: { fontSize: 12, fontFamily: 'monospace', color: 'var(--dsw-alias-label-tertiary, #999)', wordBreak: 'break-all' } },
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
- { 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 } },
617
+ { className: 'a2a-skills' },
492
618
  ...skills.map((s) =>
493
619
  createElement(
494
620
  'div',
495
- { key: s.id, style: { display: 'flex', flexDirection: 'column', gap: 2 } },
621
+ { key: s.id, className: 'a2a-skill-row' },
496
622
  createElement(
497
623
  'div',
498
- { style: { display: 'flex', alignItems: 'baseline', gap: 6 } },
499
- createElement('span', { style: { fontSize: 12.5, fontWeight: 600, color: 'var(--dsw-alias-label-primary, #222)', fontFamily: 'monospace' } }, s.name),
624
+ { className: 'a2a-skill-name' },
625
+ s.name,
500
626
  s.tags?.length
501
- ? createElement('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary, #999)' } }, s.tags.join(', '))
627
+ ? createElement('span', { className: 'a2a-skill-tags' }, s.tags.join(', '))
502
628
  : null,
503
629
  ),
504
630
  s.description
505
- ? createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)' } }, s.description)
631
+ ? createElement('div', { className: 'a2a-skill-desc' }, s.description)
506
632
  : null,
507
633
  ),
508
634
  ),
509
635
  )
510
636
  : open
511
- ? createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)' } }, '(该 Agent 未声明技能)')
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 sourceBadge(color: string, bg: string, _label: string): Record<string, string | number> {
517
- return { color, background: bg, borderRadius: 4, padding: '1px 6px' };
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
- style: {
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', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)', marginLeft: 6 } }, String(count)) : null,
720
+ count > 0 ? createElement('span', { className: 'a2a-tab-count' }, String(count)) : null,
547
721
  );
548
722
  }
549
723
 
550
- /** The tab strip container replicating `.pbvGtq_tabs` (border-bottom rail). */
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
- { style: { margin: 0, fontSize: 14, fontWeight: 600, color: 'var(--dsw-alias-label-primary, #222)', display: 'flex', alignItems: 'center', gap: 8 } },
732
+ { className: 'a2a-h3' },
563
733
  title,
564
- 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)),
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', { style: thStyle }, c)));
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', { style: { display: 'inline-block', width: 8, height: 8, borderRadius: 4, marginRight: 6, background: stateColor[state] ?? '#9e9e9e' } });
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
- style: {
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
- { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)', background: 'rgba(74,125,255,.08)', borderRadius: 6, padding: '6px 10px' } },
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
- { style: { fontSize: 12, color: 'var(--dsw-warning, #b26a00)', background: 'rgba(178,106,0,.08)', borderRadius: 6, padding: '6px 10px' } },
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
- { style: { display: 'flex', flexDirection: 'column', gap: 6, marginTop: 4 } },
1065
+ { className: 'a2a-auth' },
662
1066
  createElement(
663
1067
  'div',
664
- { style: { display: 'flex', alignItems: 'center', gap: 8 } },
665
- createElement('span', { style: { fontSize: 12.5, fontWeight: 600, color: 'var(--dsw-alias-label-primary, #222)' } }, '入站鉴权'),
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
- { style: { fontSize: 12, color: serve.authConfigured ? '#2e7d32' : '#9e9e9e' } },
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
- { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
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
- { style: { fontSize: 11.5, color: 'var(--dsw-alias-label-tertiary, #999)' } },
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', { style: tdStyle }, p.label),
701
- createElement('td', { style: { ...tdStyle, fontFamily: 'monospace', fontSize: 12 } }, p.source ?? '—'),
702
- createElement('td', { style: tdStyle }, new Date(p.firstSeen).toLocaleTimeString()),
703
- createElement('td', { style: tdStyle }, new Date(p.lastSeen).toLocaleTimeString()),
704
- createElement('td', { style: tdStyle }, String(p.taskCount)),
705
- createElement('td', { style: tdStyle }, p.streaming ? '● 流' : '—'),
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
- { style: tdStyle },
1112
+ { 'data-label': '操作' },
709
1113
  actionBtn('关闭', false, () => void act('close-peer', p.id), busy === `close-peer:${p.id}`),
710
1114
  ),
711
1115
  );
712
- }
1116
+ }