@vibe-x/dsh-html-workbench 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -30,7 +30,40 @@ window.__ModuleLoader__.load({
30
30
  .map((k) => encodeURIComponent(k) + '=' + encodeURIComponent(String(a[k])))
31
31
  .join('&');
32
32
  const url = '/html-workbench/' + method + (params ? '?' + params : '');
33
- return fetch(url).then((r) => r.json());
33
+ // A browser client may update before DSH reloads the Node-side host. An
34
+ // old host does not know newer routes (e.g. diagnostics/restart) and
35
+ // falls through to the SPA HTML shell. Never expose that as the useless
36
+ // Do not expose a raw JSON parse error; name the version mismatch and
37
+ // tell the user to restart the DSH host process.
38
+ return fetch(url).then(async (response) => {
39
+ const text = await response.text();
40
+ const contentType = response.headers.get('content-type') || '';
41
+ if (!contentType.includes('application/json')) {
42
+ const staleHost = /<!doctype html|<html[s>]/i.test(text);
43
+ throw new Error(staleHost
44
+ ? 'HTML Workbench 的界面已更新,但 DSH 后台仍是旧版本,暂不支持「' + method + '」。请完全重启 DSH 后重试。'
45
+ : '本地服务返回了非 JSON 响应(HTTP ' + response.status + ')。');
46
+ }
47
+ try {
48
+ return JSON.parse(text);
49
+ } catch (error) {
50
+ throw new Error('本地服务返回了无法解析的 JSON(HTTP ' + response.status + ')。');
51
+ }
52
+ }, (error) => {
53
+ // fetch REJECTS (rather than resolving with a status) when the request
54
+ // never reached DSH's web server. Left alone, the browser's bare
55
+ // "Failed to fetch" surfaced in the panel — the single least actionable
56
+ // string in this plugin, and the one users reported. It also means the
57
+ // host half never ran, so none of its diagnostics exist yet: the only
58
+ // useful thing to report is WHICH request died and what to check.
59
+ const reason = (error && error.message) || String(error);
60
+ throw new Error(
61
+ '无法连接 HTML Workbench 后台(' + method + ')。请求 ' + url + ' 没有到达 DSH。'
62
+ + '通常说明插件的 Node 半没有加载成功,或 DSH 尚未重启以注册该路由。'
63
+ + '请完全重启 DSH;若仍失败,请在启动 DSH 的终端里查找 [html-workbench] 开头的报错。'
64
+ + '(底层错误:' + reason + ')'
65
+ );
66
+ });
34
67
  },
35
68
  };
36
69
 
@@ -72,7 +105,7 @@ return {
72
105
  // positioned chevron collapsing into a block below the input). So manage the
73
106
  // <style> element here: drop every previous generation, then insert fresh.
74
107
  const STYLE_MARK = 'data-hwb-styles'
75
- const STYLE_VERSION = '2'
108
+ const STYLE_VERSION = '5'
76
109
 
77
110
  const CSS = `
78
111
  html #root {
@@ -117,6 +150,33 @@ header:has([data-slot="conversation.session.header.utilities"]) {
117
150
  .hwb-dot { position: relative; width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--dsw-alias-label-dimmed, var(--dsw-alias-label-tertiary)); box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 0%, transparent); }
118
151
  .hwb-dot[data-on] { background: var(--dsw-alias-state-success-primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-success-primary) 16%, transparent); }
119
152
  .hwb-dot[data-off] { background: var(--dsw-alias-state-error-primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-error-primary) 16%, transparent); }
153
+ /* The dot is the ONLY signal that a start failed, so make it the way in: it is a
154
+ button that opens the diagnostics log. */
155
+ .hwb-statusbtn { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 2px 6px 2px 4px; margin-left: -4px; border: 0; border-radius: 7px; background: transparent; color: inherit; font: inherit; cursor: pointer; }
156
+ .hwb-statusbtn:hover { background: var(--dsw-alias-interactive-bg-hover); }
157
+ .hwb-statusbtn:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #4d6bfe); outline-offset: 1px; }
158
+
159
+ /* ── Diagnostics ──────────────────────────────────────────────────────────── */
160
+ .hwb-diag { flex: none; display: flex; flex-direction: column; max-height: 46vh; border-bottom: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); }
161
+ .hwb-diag-head { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 14px; border-bottom: 1px solid var(--dsw-alias-border-l2); }
162
+ .hwb-diag-title { font-size: 12px; font-weight: 600; }
163
+ .hwb-diag-facts { flex: none; display: grid; grid-template-columns: auto 1fr; gap: 2px 10px; padding: 8px 14px; border-bottom: 1px solid var(--dsw-alias-border-l2); font-size: 11.5px; }
164
+ .hwb-diag-key { color: var(--dsw-alias-label-tertiary); white-space: nowrap; }
165
+ .hwb-diag-val { min-width: 0; font-family: var(--hwb-mono); word-break: break-all; }
166
+ .hwb-diag-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 6px 0; }
167
+ .hwb-diag-row { display: grid; grid-template-columns: auto 1fr; gap: 8px; padding: 4px 14px; font-size: 11.5px; line-height: 1.55; }
168
+ .hwb-diag-row + .hwb-diag-row { border-top: 1px solid color-mix(in srgb, var(--dsw-alias-border-l2) 50%, transparent); }
169
+ .hwb-diag-time { color: var(--dsw-alias-label-tertiary); font-family: var(--hwb-mono); font-size: 10.5px; padding-top: 1px; }
170
+ .hwb-diag-msg { min-width: 0; }
171
+ .hwb-diag-count { margin-left: 6px; padding: 0 5px; border-radius: 999px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-tertiary); font-family: var(--hwb-mono); font-size: 10px; }
172
+ .hwb-diag-row[data-level="error"] .hwb-diag-msg { color: var(--dsw-alias-state-error-primary); }
173
+ .hwb-diag-detail { display: block; margin-top: 3px; padding: 6px 8px; border-radius: 6px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-secondary); font-family: var(--hwb-mono); font-size: 10.5px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; max-height: 180px; overflow-y: auto; }
174
+ .hwb-diag-empty { padding: 16px 14px; color: var(--dsw-alias-label-tertiary); font-size: 11.5px; }
175
+ .hwb-diag-hint { padding: 8px 14px; border-top: 1px solid var(--dsw-alias-border-l2); color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 1.6; }
176
+ .hwb-diag-hint code { padding: 1px 4px; border-radius: 4px; background: var(--dsw-alias-bg-base); font-family: var(--hwb-mono); font-size: 10.5px; }
177
+ .hwb-btn-quiet { background: transparent; border-color: var(--dsw-alias-border-l2); color: var(--dsw-alias-label-primary); }
178
+ .hwb-btn-quiet:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
179
+ .hwb-btn-sm { height: 26px; padding: 0 10px; border-radius: 7px; font-size: 11.5px; }
120
180
  .hwb-name { font-weight: 600; font-size: 13px; white-space: nowrap; }
121
181
  .hwb-sep { width: 1px; height: 14px; flex: none; background: var(--dsw-alias-border-l1); }
122
182
  .hwb-filechip { display: flex; align-items: center; gap: 6px; min-width: 0; height: 22px; padding: 0 8px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2, var(--dsw-alias-interactive-bg-hover)); color: var(--dsw-alias-label-secondary); font-size: 11.5px; }
@@ -184,6 +244,33 @@ header:has([data-slot="conversation.session.header.utilities"]) {
184
244
  .hwb-body { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; background: var(--dsw-alias-bg-base); }
185
245
  .hwb-frame { flex: 1 1 auto; width: 100%; border: 0; background: #fff; }
186
246
 
247
+ /* ── Visual selection chips (rendered INTO the host composer) ─────────────── */
248
+ /* The workbench posts one selection at a time; each becomes a removable chip
249
+ above the chat input, mirroring how file attachments behave. The markdown
250
+ evidence is held in memory and only spliced into the message on send, so the
251
+ user never has to look at a wall of HTML while typing. */
252
+ .hwb-chips {
253
+ display: flex; flex-wrap: wrap; gap: 6px;
254
+ margin: 0 14px 8px; padding: 0;
255
+ }
256
+ .hwb-chip {
257
+ display: inline-flex; align-items: center; gap: 6px; max-width: 260px;
258
+ padding: 3px 4px 3px 8px; border-radius: 8px;
259
+ border: 1px solid var(--dsw-alias-border-l2);
260
+ background: var(--dsw-alias-bg-layer-1);
261
+ color: var(--dsw-alias-label-primary);
262
+ font-size: 11.5px; line-height: 18px;
263
+ }
264
+ .hwb-chip > svg { flex: none; color: var(--dsw-alias-state-business-primary, #4d6bfe); }
265
+ .hwb-chip-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--hwb-mono); }
266
+ .hwb-chip-x {
267
+ flex: none; display: grid; place-items: center; width: 18px; height: 18px; padding: 0;
268
+ border: 0; border-radius: 5px; background: transparent;
269
+ color: var(--dsw-alias-label-tertiary); cursor: pointer;
270
+ }
271
+ .hwb-chip-x:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
272
+ .hwb-chip-note { align-self: center; color: var(--dsw-alias-label-tertiary); font-size: 11px; }
273
+
187
274
  .hwb-center { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; padding: 24px; overflow-y: auto; text-align: center; }
188
275
  .hwb-blank { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 14px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-tertiary); }
189
276
  .hwb-blank-title { font-size: 13.5px; font-weight: 600; color: var(--dsw-alias-label-primary); }
@@ -257,6 +344,10 @@ header:has([data-slot="conversation.session.header.utilities"]) {
257
344
  error: null,
258
345
  pathInput: '',
259
346
  resolveState: 'idle',
347
+ context: null,
348
+ diag: null,
349
+ diagOpen: false,
350
+ restarting: false,
260
351
  listeners: [],
261
352
  subscribe(fn) { this.listeners.push(fn); return () => { this.listeners = this.listeners.filter((f) => f !== fn) } },
262
353
  set(patch) {
@@ -307,6 +398,20 @@ header:has([data-slot="conversation.session.header.utilities"]) {
307
398
  return true
308
399
  }
309
400
 
401
+ // The panel polls `list` every few seconds and the payload now carries the
402
+ // whole diagnostics journal — a fresh object every tick. Compare by the
403
+ // facts that actually change so polling does not re-render continuously.
404
+ const sameDiag = (a, b) => {
405
+ if (a === b) return true
406
+ if (!a || !b) return false
407
+ const ja = a.journal || []
408
+ const jb = b.journal || []
409
+ if (ja.length !== jb.length) return false
410
+ if (ja.length && (ja[0].at !== jb[0].at || ja[0].message !== jb[0].message)) return false
411
+ return a.running === b.running && a.startError === b.startError
412
+ && a.processStatus === b.processStatus && a.exitCode === b.exitCode
413
+ }
414
+
310
415
  const refresh = (explicit) => {
311
416
  if (explicit) store.set({ refreshing: true })
312
417
  return host.call('list').then((res) => {
@@ -315,6 +420,7 @@ header:has([data-slot="conversation.session.header.utilities"]) {
315
420
  store.set({
316
421
  assets: sameAssets(store.assets, next) ? store.assets : next,
317
422
  running: !!res.running,
423
+ diag: sameDiag(store.diag, res) ? store.diag : res,
318
424
  })
319
425
  } else {
320
426
  store.set({ error: (res && res.error) || 'list failed' })
@@ -323,6 +429,21 @@ header:has([data-slot="conversation.session.header.utilities"]) {
323
429
  .then(() => { if (explicit) store.set({ refreshing: false }) })
324
430
  }
325
431
 
432
+ // Killing and re-spawning is the one action that fixes most start failures,
433
+ // so it belongs next to the log that reports them.
434
+ const restartService = () => {
435
+ store.set({ restarting: true, error: null })
436
+ return host.call('restart').then((res) => {
437
+ store.set({
438
+ restarting: false,
439
+ running: !!(res && res.ok),
440
+ diag: (res && res.diagnostics) || store.diag,
441
+ error: res && res.ok ? null : (res && res.error) || '重启失败',
442
+ })
443
+ if (res && res.ok) refresh()
444
+ }).catch((e) => store.set({ restarting: false, error: String(e && e.message ? e.message : e) }))
445
+ }
446
+
326
447
  const openFile = (path) => {
327
448
  const file = String(path || '').trim()
328
449
  if (!file) return
@@ -340,7 +461,15 @@ header:has([data-slot="conversation.session.header.utilities"]) {
340
461
  nonce: store.nonce + 1,
341
462
  })
342
463
  } else {
343
- store.set({ loading: false, error: (res && res.error) || 'open failed' })
464
+ // A failed open used to collapse into one opaque line. The host now
465
+ // sends the journal along, so open the log on failure: the cause is
466
+ // one glance away instead of a terminal session away.
467
+ store.set({
468
+ loading: false,
469
+ error: (res && res.error) || 'open failed',
470
+ diag: (res && res.diagnostics) || store.diag,
471
+ diagOpen: !!(res && res.diagnostics),
472
+ })
344
473
  }
345
474
  }).catch((e) => store.set({ loading: false, error: String(e && e.message ? e.message : e) }))
346
475
  }
@@ -352,6 +481,204 @@ header:has([data-slot="conversation.session.header.utilities"]) {
352
481
  checking: '检测中…',
353
482
  }
354
483
 
484
+ // ── Visual selection → composer chips ────────────────────────────────────
485
+ //
486
+ // The workbench posts one selection per click. Each becomes a removable chip
487
+ // above the chat input, and the bulky markdown evidence stays in memory
488
+ // until the user actually sends — so they compose against a short list of
489
+ // labels instead of a wall of HTML.
490
+ //
491
+ // Two deliberate choices:
492
+ // 1. Chips are plain DOM appended into the host's composer card, not a
493
+ // React portal. The card is a flex column owned by the host's React
494
+ // tree; React leaves DOM nodes it never created alone, but it may
495
+ // `insertBefore` around them, so a MutationObserver re-asserts position.
496
+ // 2. The evidence is spliced in during the CAPTURE phase of send. React 18
497
+ // dispatches discrete events synchronously, so writing the value through
498
+ // the native setter + `input` event during capture means React state is
499
+ // already updated by the time the host's own handler reads it.
500
+
501
+ const CHIP_HOST = '.hwb-chips'
502
+ const pendingSelections = []
503
+ let chipRow = null
504
+ let composerObserver = null
505
+
506
+ const findComposer = () => {
507
+ const nodes = document.querySelectorAll('textarea')
508
+ for (let i = 0; i < nodes.length; i += 1) {
509
+ const node = nodes[i]
510
+ // The panel's own inputs are inside `.hwb-panel`; never target those.
511
+ if (node.closest('.hwb-panel')) continue
512
+ const box = node.getBoundingClientRect()
513
+ if (box.width >= 120 && box.height > 0) return node
514
+ }
515
+ return null
516
+ }
517
+
518
+ // The card is the flex column holding the textarea and the tool row. Walking
519
+ // up from the textarea keeps this working if the host renames its classes.
520
+ const findComposerCard = (textarea) => {
521
+ let node = textarea && textarea.parentElement
522
+ let depth = 0
523
+ while (node && depth < 6) {
524
+ const style = window.getComputedStyle(node)
525
+ if (style.display === 'flex' && style.flexDirection === 'column' && node.querySelector('button')) return node
526
+ node = node.parentElement
527
+ depth += 1
528
+ }
529
+ return textarea ? textarea.parentElement : null
530
+ }
531
+
532
+ const setComposerValue = (node, next) => {
533
+ const descriptor = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')
534
+ const setter = descriptor && descriptor.set
535
+ if (setter) setter.call(node, next)
536
+ else node.value = next
537
+ node.dispatchEvent(new Event('input', { bubbles: true }))
538
+ }
539
+
540
+ const evidenceBlock = () => {
541
+ if (!pendingSelections.length) return ''
542
+ return pendingSelections.map((item) => item.markdown).join('\n')
543
+ }
544
+
545
+ const clearSelections = () => {
546
+ pendingSelections.length = 0
547
+ renderChips()
548
+ }
549
+
550
+ const removeSelection = (key) => {
551
+ const index = pendingSelections.findIndex((item) => item.key === key)
552
+ if (index >= 0) pendingSelections.splice(index, 1)
553
+ renderChips()
554
+ }
555
+
556
+ const chipIcon = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7.5v-3A1.5 1.5 0 0 1 4.5 3h3M16.5 3h3A1.5 1.5 0 0 1 21 4.5v3M21 16.5v3a1.5 1.5 0 0 1-1.5 1.5h-3M7.5 21h-3A1.5 1.5 0 0 1 3 19.5v-3"/></svg>'
557
+
558
+ const renderChips = () => {
559
+ const textarea = findComposer()
560
+ const card = findComposerCard(textarea)
561
+ if (!card) return
562
+ if (!pendingSelections.length) {
563
+ if (chipRow && chipRow.parentNode) chipRow.parentNode.removeChild(chipRow)
564
+ chipRow = null
565
+ return
566
+ }
567
+ if (!chipRow) {
568
+ chipRow = document.createElement('div')
569
+ chipRow.className = CHIP_HOST.slice(1)
570
+ }
571
+ chipRow.textContent = ''
572
+ pendingSelections.forEach((item) => {
573
+ const chip = document.createElement('span')
574
+ chip.className = 'hwb-chip'
575
+ chip.title = item.textHint ? item.label + ' — ' + item.textHint : item.label
576
+ chip.innerHTML = chipIcon
577
+ const label = document.createElement('span')
578
+ label.className = 'hwb-chip-label'
579
+ label.textContent = item.label
580
+ const close = document.createElement('button')
581
+ close.type = 'button'
582
+ close.className = 'hwb-chip-x'
583
+ close.setAttribute('aria-label', '移除 ' + item.label)
584
+ close.innerHTML = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>'
585
+ close.addEventListener('click', (event) => {
586
+ event.preventDefault()
587
+ event.stopPropagation()
588
+ removeSelection(item.key)
589
+ })
590
+ chip.appendChild(label)
591
+ chip.appendChild(close)
592
+ chipRow.appendChild(chip)
593
+ })
594
+ const note = document.createElement('span')
595
+ note.className = 'hwb-chip-note'
596
+ note.textContent = '发送时会自动附上选区源码'
597
+ chipRow.appendChild(note)
598
+
599
+ // Sit directly above the tool row (the last flex child), which puts the
600
+ // chips between the text input and the model/send controls.
601
+ const anchor = card.lastElementChild
602
+ if (chipRow.parentNode !== card || chipRow.nextElementSibling !== anchor) {
603
+ card.insertBefore(chipRow, anchor)
604
+ }
605
+ observeComposer(card)
606
+ }
607
+
608
+ const observeComposer = (card) => {
609
+ if (composerObserver) return
610
+ composerObserver = new MutationObserver(() => {
611
+ if (!pendingSelections.length) return
612
+ if (!chipRow || chipRow.parentNode !== card) renderChips()
613
+ })
614
+ composerObserver.observe(card, { childList: true })
615
+ ctx.effect(() => () => {
616
+ if (composerObserver) composerObserver.disconnect()
617
+ composerObserver = null
618
+ })
619
+ }
620
+
621
+ const receiveContext = (packet) => {
622
+ const key = packet.key || packet.label
623
+ const existing = pendingSelections.findIndex((item) => item.key === key)
624
+ const entry = {
625
+ key: key,
626
+ label: packet.label || 'element',
627
+ textHint: packet.textHint || '',
628
+ markdown: packet.markdown,
629
+ fileName: packet.fileName,
630
+ }
631
+ // Re-adding the same element refreshes its evidence rather than stacking a
632
+ // duplicate: the file may have changed since the first click.
633
+ if (existing >= 0) pendingSelections[existing] = entry
634
+ else pendingSelections.push(entry)
635
+ renderChips()
636
+ const textarea = findComposer()
637
+ if (textarea) textarea.focus()
638
+ store.set({ context: { count: pendingSelections.length, label: entry.label, at: Date.now() } })
639
+ }
640
+
641
+ // Splice the evidence in just before the host reads the composer, then drop
642
+ // the chips: the message now carries the context, so keeping them would
643
+ // silently re-attach the same source to the next turn.
644
+ const spliceEvidenceIntoMessage = () => {
645
+ if (!pendingSelections.length) return
646
+ const textarea = findComposer()
647
+ if (!textarea) return
648
+ const typed = (textarea.value || '').trim()
649
+ if (!typed) return
650
+ setComposerValue(textarea, evidenceBlock() + '\n' + typed)
651
+ clearSelections()
652
+ }
653
+
654
+ const bindSendInterception = () => {
655
+ const onClick = (event) => {
656
+ if (!pendingSelections.length) return
657
+ const target = event.target instanceof Element ? event.target : null
658
+ const button = target && target.closest('button')
659
+ if (!button || button.closest('.hwb-panel')) return
660
+ const label = button.getAttribute('aria-label') || ''
661
+ if (!/发送|send/i.test(label)) return
662
+ spliceEvidenceIntoMessage()
663
+ }
664
+ const onKeyDown = (event) => {
665
+ if (!pendingSelections.length) return
666
+ if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return
667
+ const target = event.target
668
+ if (!(target instanceof HTMLTextAreaElement) || target.closest('.hwb-panel')) return
669
+ spliceEvidenceIntoMessage()
670
+ }
671
+ document.addEventListener('click', onClick, true)
672
+ document.addEventListener('keydown', onKeyDown, true)
673
+ ctx.effect(() => () => {
674
+ document.removeEventListener('click', onClick, true)
675
+ document.removeEventListener('keydown', onKeyDown, true)
676
+ })
677
+ }
678
+
679
+ bindSendInterception()
680
+
681
+
355
682
  const checkPath = (value) => {
356
683
  const trimmed = (value || '').trim()
357
684
  if (resolveTimer) { clearTimeout(resolveTimer); resolveTimer = null }
@@ -364,8 +691,12 @@ header:has([data-slot="conversation.session.header.utilities"]) {
364
691
  if (!res.isHtml) store.set({ resolveState: 'invalid' })
365
692
  else if (res.exists === true) store.set({ resolveState: 'exists' })
366
693
  else if (res.exists === false) store.set({ resolveState: 'missing' })
694
+ // `exists: null` means the CHECK could not run (no interpreter, no
695
+ // shell). Staying silent made the field look unresponsive, so promote
696
+ // the host's reason to the banner instead of dropping it.
697
+ else if (res.error) store.set({ resolveState: 'idle', error: res.error })
367
698
  else store.set({ resolveState: 'idle' })
368
- }).catch(() => store.set({ resolveState: 'idle' }))
699
+ }).catch((e) => store.set({ resolveState: 'idle', error: String(e && e.message ? e.message : e) }))
369
700
  }, 300)
370
701
  }
371
702
 
@@ -398,6 +729,131 @@ header:has([data-slot="conversation.session.header.utilities"]) {
398
729
  path('M8 21h8', 'c'),
399
730
  path('M12 18v3', 'd'),
400
731
  ])
732
+ const I_LOGS = icon(15, [path('M8 6h10', 'a'), path('M8 12h10', 'b'), path('M8 18h6', 'c'), path('M4 6h.01', 'd'), path('M4 12h.01', 'e'), path('M4 18h.01', 'f')])
733
+
734
+ // ── Diagnostics view ─────────────────────────────────────────────────────
735
+ //
736
+ // A start failure has exactly one visible symptom by default: a red dot. The
737
+ // cause lives in a Python traceback or a spawn error inside the host process,
738
+ // so surface it verbatim here — plus the facts needed to reproduce by hand
739
+ // (port, script path, exit code). That turns "服务起不来" into a fixable
740
+ // report without leaving the panel.
741
+
742
+ const clockTime = (at) => {
743
+ try {
744
+ return new Date(at).toLocaleTimeString('zh-CN', { hour12: false })
745
+ } catch (e) { return '' }
746
+ }
747
+
748
+ const Diagnostics = () => {
749
+ const s = useStore()
750
+ const d = s.diag
751
+ const journal = (d && d.journal) || []
752
+ const [copied, setCopied] = React.useState(false)
753
+
754
+ const fact = (key, value) => value == null || value === ''
755
+ ? null
756
+ : [
757
+ React.createElement('span', { className: 'hwb-diag-key', key: key + '-k' }, key),
758
+ React.createElement('span', { className: 'hwb-diag-val', key: key + '-v' }, String(value)),
759
+ ]
760
+
761
+ // A bug report is only useful if it carries the journal. Asking a user to
762
+ // screenshot a scrolling panel loses exactly the detail lines that matter,
763
+ // so hand them one paste-ready block instead — plain text, not JSON, so it
764
+ // survives being dropped into a chat window.
765
+ const copyReport = () => {
766
+ const lines = ['# HTML Workbench 诊断报告']
767
+ if (d) {
768
+ lines.push('插件版本: ' + (d.version || '未知(可能是动态热加载)'))
769
+ lines.push('平台: ' + (d.platform || '未知'))
770
+ lines.push('状态: ' + (d.running ? '运行中' : '未就绪'))
771
+ lines.push('端口: ' + d.port)
772
+ lines.push('进程: ' + (d.processStatus || '未由本插件启动')
773
+ + (d.exitCode == null ? '' : '(exit=' + d.exitCode + ')'))
774
+ lines.push('Python: ' + (d.pythonCommand || (d.pythonCandidates || []).join(' → ') || '未解析'))
775
+ lines.push('脚本: ' + (d.script || '未配置'))
776
+ lines.push('运行目录: ' + (d.runtimeDir || '未知'))
777
+ if (d.startError) lines.push('失败原因: ' + d.startError)
778
+ } else {
779
+ lines.push('(后台未返回诊断信息,通常说明请求根本没有到达 DSH)')
780
+ }
781
+ if (s.error) lines.push('界面报错: ' + s.error)
782
+ lines.push('', '## 日志(最新在前)')
783
+ if (journal.length) {
784
+ journal.forEach((entry) => {
785
+ lines.push('[' + clockTime(entry.at) + '] ' + entry.level + ': ' + entry.message)
786
+ if (entry.detail) lines.push(' ' + String(entry.detail).replace(/\n/g, '\n '))
787
+ })
788
+ } else {
789
+ lines.push('(无)')
790
+ }
791
+ const text = lines.join('\n')
792
+ const done = () => { setCopied(true); setTimeout(() => setCopied(false), 1600) }
793
+ if (navigator.clipboard && navigator.clipboard.writeText) {
794
+ navigator.clipboard.writeText(text).then(done, () => store.set({ error: '复制失败,请手动访问 /html-workbench/diagnostics 获取原始信息。' }))
795
+ } else {
796
+ store.set({ error: '当前环境不支持剪贴板,请访问 /html-workbench/diagnostics 获取原始信息。' })
797
+ }
798
+ }
799
+
800
+ return React.createElement('div', { className: 'hwb-diag' },
801
+ React.createElement('div', { className: 'hwb-diag-head' },
802
+ React.createElement('span', { className: 'hwb-diag-title' }, '服务诊断'),
803
+ React.createElement('span', { className: 'hwb-spacer' }),
804
+ React.createElement('button', {
805
+ type: 'button', className: 'hwb-btn hwb-btn-quiet hwb-btn-sm',
806
+ onClick: copyReport, title: '复制完整诊断信息,便于反馈问题',
807
+ }, copied ? '已复制' : '复制报告'),
808
+ React.createElement('button', {
809
+ type: 'button', className: 'hwb-btn hwb-btn-quiet hwb-btn-sm',
810
+ disabled: s.restarting, onClick: restartService,
811
+ }, s.restarting ? '重启中…' : '重启服务'),
812
+ React.createElement('button', {
813
+ type: 'button', className: 'hwb-icon', title: '收起诊断', 'aria-label': '收起诊断',
814
+ onClick: () => store.set({ diagOpen: false }),
815
+ }, I_CLOSE),
816
+ ),
817
+ d
818
+ ? React.createElement('div', { className: 'hwb-diag-facts' },
819
+ fact('状态', d.running ? '运行中' : (d.processStatus === 'running' ? '启动中' : '未就绪')),
820
+ fact('端口', d.port),
821
+ fact('进程', d.processStatus ? d.processStatus + (d.exitCode == null ? '' : '(exit=' + d.exitCode + ')') : '未由本插件启动'),
822
+ fact('Python', d.pythonCommand || ((d.pythonCandidates || []).join(' → ') || null)),
823
+ fact('运行目录', d.runtimeDir),
824
+ fact('失败原因', d.startError),
825
+ fact('脚本', d.script),
826
+ fact('版本', d.version ? d.version + (d.platform ? ' · ' + d.platform : '') : d.platform),
827
+ d.hasShell === false ? fact('shell', '不可用') : null,
828
+ )
829
+ : null,
830
+ React.createElement('div', { className: 'hwb-diag-body' },
831
+ journal.length
832
+ ? journal.map((entry, i) => React.createElement('div', {
833
+ className: 'hwb-diag-row', 'data-level': entry.level, key: String(entry.at) + '-' + i,
834
+ },
835
+ React.createElement('span', { className: 'hwb-diag-time' }, clockTime(entry.at)),
836
+ React.createElement('span', { className: 'hwb-diag-msg' },
837
+ entry.message,
838
+ // Folded duplicates would otherwise read as a single occurrence,
839
+ // hiding that the user retried and hit the same wall every time.
840
+ entry.repeated > 1
841
+ ? React.createElement('span', { className: 'hwb-diag-count' }, '×' + entry.repeated)
842
+ : null,
843
+ entry.detail ? React.createElement('span', { className: 'hwb-diag-detail' }, entry.detail) : null,
844
+ ),
845
+ ))
846
+ : React.createElement('div', { className: 'hwb-diag-empty' }, '暂无日志。点「重启服务」可重新拉起并记录完整过程。'),
847
+ ),
848
+ React.createElement('div', { className: 'hwb-diag-hint' },
849
+ '服务自身日志:',
850
+ React.createElement('code', null, (d && d.logDir) || '<工作区>/.html-workbench/logs/'),
851
+ ';也可直接访问 ',
852
+ React.createElement('code', null, '/html-workbench/diagnostics'),
853
+ ' 查看原始 JSON。',
854
+ ),
855
+ )
856
+ }
401
857
 
402
858
  const STATE_ICON = { exists: I_CHECK, missing: I_MISSING, invalid: I_WARN, checking: I_LOADING }
403
859
 
@@ -452,6 +908,19 @@ header:has([data-slot="conversation.session.header.utilities"]) {
452
908
  return () => { if (dispose) dispose() }
453
909
  }, [s.open])
454
910
 
911
+ // The workbench runs in an iframe, so its selections arrive as messages.
912
+ // Bind while the panel is mounted, regardless of which file is open.
913
+ React.useEffect(() => {
914
+ const onMessage = (event) => {
915
+ const data = event.data
916
+ if (!data || data.type !== 'html-workbench:context') return
917
+ if (!data.markdown) return
918
+ receiveContext(data)
919
+ }
920
+ window.addEventListener('message', onMessage)
921
+ return () => window.removeEventListener('message', onMessage)
922
+ }, [])
923
+
455
924
  // Drop any in-flight path check when the panel closes.
456
925
  React.useEffect(() => () => { if (resolveTimer) { clearTimeout(resolveTimer); resolveTimer = null } }, [])
457
926
 
@@ -488,6 +957,9 @@ header:has([data-slot="conversation.session.header.utilities"]) {
488
957
  inMenu && a.kind ? React.createElement('span', { className: 'hwb-tag', 'data-kind': a.kind }, a.kind === 'create' ? '新建' : '编辑') : null,
489
958
  )
490
959
 
960
+ // A dead service makes "填入路径开始编辑" a lie — every open will fail.
961
+ // Say what is actually wrong and offer the two things that help.
962
+ const serviceDown = !s.running && !!s.diag && !s.loading
491
963
  const body = s.loading
492
964
  ? React.createElement('div', { className: 'hwb-center' },
493
965
  React.createElement('div', { className: 'hwb-spinner' }),
@@ -500,7 +972,26 @@ header:has([data-slot="conversation.session.header.utilities"]) {
500
972
  src: s.current.url,
501
973
  title: 'HTML Workbench — ' + basename(s.current.path),
502
974
  })
503
- : React.createElement('div', { className: 'hwb-center' },
975
+ : serviceDown
976
+ ? React.createElement('div', { className: 'hwb-center' },
977
+ React.createElement('div', { className: 'hwb-blank' }, I_ALERT),
978
+ React.createElement('div', null,
979
+ React.createElement('div', { className: 'hwb-blank-title' }, '本地服务未就绪'),
980
+ React.createElement('div', { className: 'hwb-blank-desc' },
981
+ s.diag.startError || '服务进程没有响应健康检查,暂时无法打开文件。'),
982
+ ),
983
+ React.createElement('div', { style: { display: 'flex', gap: '8px' } },
984
+ React.createElement('button', {
985
+ type: 'button', className: 'hwb-btn hwb-btn-primary hwb-btn-sm',
986
+ disabled: s.restarting, onClick: restartService,
987
+ }, s.restarting ? '重启中…' : '重启服务'),
988
+ React.createElement('button', {
989
+ type: 'button', className: 'hwb-btn hwb-btn-quiet hwb-btn-sm',
990
+ onClick: () => store.set({ diagOpen: true }),
991
+ }, '查看诊断日志'),
992
+ ),
993
+ )
994
+ : React.createElement('div', { className: 'hwb-center' },
504
995
  React.createElement('div', { className: 'hwb-blank' }, I_BLANK),
505
996
  React.createElement('div', null,
506
997
  React.createElement('div', { className: 'hwb-blank-title' }, '还没有打开文件'),
@@ -539,12 +1030,17 @@ header:has([data-slot="conversation.session.header.utilities"]) {
539
1030
 
540
1031
  React.createElement('div', { className: 'hwb-chrome' },
541
1032
  React.createElement('div', { className: 'hwb-idrow' },
542
- React.createElement('div', { className: 'hwb-brand' },
1033
+ React.createElement('button', {
1034
+ type: 'button',
1035
+ className: 'hwb-statusbtn',
1036
+ 'aria-expanded': !!s.diagOpen,
1037
+ title: s.running ? '本地服务运行中 — 点击查看诊断' : '本地服务未就绪 — 点击查看原因',
1038
+ onClick: () => store.set({ diagOpen: !s.diagOpen }),
1039
+ },
543
1040
  React.createElement('span', {
544
1041
  className: 'hwb-dot',
545
1042
  'data-on': s.running ? '' : undefined,
546
1043
  'data-off': !s.running ? '' : undefined,
547
- title: s.running ? '本地服务运行中' : '本地服务未就绪',
548
1044
  }),
549
1045
  React.createElement('span', { className: 'hwb-name' }, 'HTML Workbench'),
550
1046
  ),
@@ -554,6 +1050,11 @@ header:has([data-slot="conversation.session.header.utilities"]) {
554
1050
  : null,
555
1051
  React.createElement('span', { className: 'hwb-spacer' }),
556
1052
  React.createElement('div', { className: 'hwb-actions' },
1053
+ React.createElement('button', {
1054
+ type: 'button', className: 'hwb-icon', title: '服务诊断',
1055
+ 'aria-label': '服务诊断', 'aria-expanded': !!s.diagOpen,
1056
+ onClick: () => store.set({ diagOpen: !s.diagOpen }),
1057
+ }, I_LOGS),
557
1058
  React.createElement('button', {
558
1059
  type: 'button', className: 'hwb-icon', title: '刷新产物列表',
559
1060
  'aria-label': '刷新产物列表', 'data-spin': s.refreshing ? '' : undefined,
@@ -630,6 +1131,10 @@ header:has([data-slot="conversation.session.header.utilities"]) {
630
1131
  ? React.createElement('div', { className: 'hwb-banner', role: 'alert' },
631
1132
  I_ALERT,
632
1133
  React.createElement('span', { className: 'hwb-banner-text' }, s.error),
1134
+ React.createElement('button', {
1135
+ type: 'button', className: 'hwb-icon', title: '查看诊断', 'aria-label': '查看诊断',
1136
+ onClick: () => store.set({ diagOpen: true }),
1137
+ }, I_LOGS),
633
1138
  React.createElement('button', {
634
1139
  type: 'button', className: 'hwb-icon', title: '忽略', 'aria-label': '忽略',
635
1140
  onClick: () => store.set({ error: null }),
@@ -637,6 +1142,8 @@ header:has([data-slot="conversation.session.header.utilities"]) {
637
1142
  )
638
1143
  : null,
639
1144
 
1145
+ s.diagOpen ? React.createElement(Diagnostics) : null,
1146
+
640
1147
  React.createElement('div', { className: 'hwb-body' }, body),
641
1148
  )
642
1149
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-x/dsh-html-workbench",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "DeepSeek Harness 插件:在右侧栏预览并可视化编辑 agent 生成的 HTML(复用/拉起本地 html-workbench 服务)。",
5
5
  "license": "MIT",
6
6
  "type": "module",