@vibe-x/dsh-html-workbench 0.2.0 → 0.3.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.
@@ -133,6 +133,32 @@ button { -webkit-tap-highlight-color: transparent; }
133
133
  .actions .save-button:hover { color: #fff; background: #353532; }
134
134
  .actions button:disabled { opacity: .45; cursor: not-allowed; }
135
135
 
136
+ /* ── Visual selection: "add to chat" outcome toast ────────────────────────── */
137
+ /* The entry point lives in GrapesJS's own element toolbar (see
138
+ `addContextToolbarItem`), and the accumulated list lives in the chat
139
+ composer. All this file needs is transient feedback. */
140
+ .context-toast {
141
+ position: absolute;
142
+ z-index: 34;
143
+ right: 14px;
144
+ bottom: 14px;
145
+ max-width: min(340px, calc(100% - 28px));
146
+ padding: 9px 13px;
147
+ border: 1px solid var(--line-strong);
148
+ border-radius: 10px;
149
+ color: #fff;
150
+ background: var(--ink);
151
+ box-shadow: var(--shadow);
152
+ font-size: 11.5px;
153
+ line-height: 1.5;
154
+ }
155
+ .context-toast[data-state="error"] { color: #fff; background: var(--danger); border-color: transparent; }
156
+
157
+ /* The toolbar button itself is inside GrapesJS's chrome, so it is styled with
158
+ the editor's own class rather than one of ours. */
159
+ .gjs-toolbar-item svg { display: block; }
160
+
161
+
136
162
  .tool-rail {
137
163
  position: absolute;
138
164
  z-index: 28;
@@ -274,6 +300,7 @@ button { -webkit-tap-highlight-color: transparent; }
274
300
  .viewport-controls [data-width="375"], .viewport-controls [data-width="1280"] { display: none; }
275
301
  .toolbar-separator { margin-inline: 1px; }
276
302
  .actions #replace-image-button { display: none; }
303
+ .context-toast { right: 10px; left: 10px; max-width: none; }
277
304
  }
278
305
 
279
306
  </style>
@@ -372,6 +399,11 @@ button { -webkit-tap-highlight-color: transparent; }
372
399
  </div>
373
400
  </div>
374
401
  </section>
402
+
403
+ <!-- Selection now flows through GrapesJS's own element toolbar (an "add to
404
+ chat" arrow next to move/copy/delete), so there is no separate tray to
405
+ manage. All that is left is a transient toast for the outcome. -->
406
+ <output id="context-toast" class="context-toast" hidden aria-live="polite"></output>
375
407
  </main>
376
408
  <script src="/vendor/grapesjs/grapes.min.js"></script>
377
409
  <script type="module" data-bundle="workbench">
@@ -401,6 +433,7 @@ const hrefInput = document.querySelector('#href-input')
401
433
  const workbench = document.querySelector('.workbench')
402
434
  const modeButtons = [...document.querySelectorAll('[data-mode]')]
403
435
  const previewFrame = document.querySelector('#preview-frame')
436
+ const contextToast = document.querySelector('#context-toast')
404
437
 
405
438
  let editor
406
439
  let activeFile = ''
@@ -408,6 +441,8 @@ let assetBase = ''
408
441
  let revision = ''
409
442
  let bodyScripts = []
410
443
  let sourceCss = ''
444
+ let sourceBodyHtml = ''
445
+ let sourceIdentityCache = null
411
446
  let links = []
412
447
  let headScripts = []
413
448
  let htmlAttributes = {}
@@ -423,6 +458,8 @@ let fitCanvas = true
423
458
  let activePanel = ''
424
459
  let panelPinned = false
425
460
  let workbenchMode = 'edit'
461
+ let contextSending = false
462
+ let contextToastTimer
426
463
 
427
464
  function setStatus(text, state = 'idle') {
428
465
  status.querySelector('b').textContent = text
@@ -434,6 +471,23 @@ function api(route) {
434
471
  return `${route}?file=${encodeURIComponent(activeFile)}`
435
472
  }
436
473
 
474
+ // The HTML/JS assets are re-read from disk on every request, but a long-lived
475
+ // service keeps running the Python it was started with. So a new frontend can
476
+ // meet an old API, whose missing routes answer 501 with an HTML error page.
477
+ // Parsing that as JSON reports "Unexpected token '<'" and buries the real cause,
478
+ // so name it explicitly instead.
479
+ async function readJson(response) {
480
+ const text = await response.text()
481
+ try {
482
+ return JSON.parse(text)
483
+ } catch (error) {
484
+ if (response.status === 501 || response.status === 404) {
485
+ throw new Error('本地服务是旧版本,缺少该接口。请重启 Workbench 服务后重试。')
486
+ }
487
+ throw new Error(`服务返回了非 JSON 响应(HTTP ${response.status})。`)
488
+ }
489
+ }
490
+
437
491
  function setFilePopover(open) {
438
492
  filePopover.hidden = !open
439
493
  fileMenuButton.setAttribute('aria-expanded', String(open))
@@ -662,6 +716,11 @@ async function injectSourceAssets() {
662
716
  interactionStyle.textContent = '[data-gjs-type="default"]:empty{pointer-events:none!important}'
663
717
  document.head.append(interactionStyle)
664
718
 
719
+ const contextStyle = document.createElement('style')
720
+ contextStyle.dataset.sourceAsset = 'true'
721
+ contextStyle.textContent = CONTEXT_CANVAS_CSS
722
+ document.head.append(contextStyle)
723
+
665
724
  if (!document.documentElement.dataset.workbenchInteractionBound) {
666
725
  document.documentElement.dataset.workbenchInteractionBound = 'true'
667
726
  document.addEventListener('click', (event) => {
@@ -680,6 +739,211 @@ async function injectSourceAssets() {
680
739
 
681
740
  }
682
741
 
742
+ // ── Visual selection context ────────────────────────────────────────────────
743
+ //
744
+ // The agent cannot see the page, so a selection is only useful if it can be
745
+ // turned into coordinates in the file on disk. This half of the feature does
746
+ // exactly one job: describe WHICH node the user picked, in terms the server can
747
+ // re-resolve against the source. It deliberately does not try to locate source
748
+ // offsets itself — the canvas DOM is GrapesJS's runtime model, not the file.
749
+ //
750
+ // The descriptor's `path` is the load-bearing part, because most elements have
751
+ // no id. It MUST be numbered the same way the server numbers
752
+ // `structural_children`: element children only, with `<script>` excluded. The
753
+ // server strips body scripts before GrapesJS ever sees the document, so
754
+ // counting them here would shift every index after an inline script and the
755
+ // selection would silently land on its neighbour.
756
+
757
+ // The entry point is GrapesJS's own element toolbar: selecting an element in the
758
+ // canvas already shows move/copy/delete, so "add to chat" belongs right there
759
+ // rather than behind a separate mode the user has to remember to turn on.
760
+
761
+ const CONTEXT_CANVAS_CSS = `
762
+ [data-wb-context-sent] {
763
+ animation: wb-context-flash 900ms ease-out;
764
+ }
765
+ @keyframes wb-context-flash {
766
+ from { box-shadow: 0 0 0 3px rgba(47, 77, 196, .55); }
767
+ to { box-shadow: 0 0 0 3px rgba(47, 77, 196, 0); }
768
+ }
769
+ `
770
+
771
+ // Mirrors the server's `structural_children`: element children with `<script>`
772
+ // excluded, because `parse_source` strips body scripts before GrapesJS ever sees
773
+ // the document. Counting them would shift every index after an inline script.
774
+ function structuralComponents(parent) {
775
+ const children = parent.components?.()
776
+ if (!children) return null
777
+ return children.filter((child) => child.get('tagName') !== 'script' && child.get('type') !== 'script')
778
+ }
779
+
780
+ // The path is computed over GrapesJS's COMPONENT tree, not the canvas DOM.
781
+ // GrapesJS injects its own nodes as siblings of the user's content — a `<style>`
782
+ // element plus `div.gjs-css-rules` and `div.gjs-js-cont` helpers — so walking the
783
+ // live DOM yields `[1, 0, 1]` where the source body only knows `[0, 1]`, and the
784
+ // server then resolves a different element or none at all. The component tree
785
+ // contains exactly the document that was loaded from disk, so it lines up with
786
+ // the server's `structural_children` numbering.
787
+ function contextPathFor(element) {
788
+ const wrapper = editor?.getWrapper()
789
+ let component = componentFromCanvasElement(element)
790
+ if (!wrapper || !component) return null
791
+ const path = []
792
+ while (component !== wrapper) {
793
+ const parent = component.parent?.()
794
+ if (!parent) return null
795
+ const structural = structuralComponents(parent)
796
+ if (!structural) return null
797
+ const index = structural.indexOf(component)
798
+ if (index < 0) return null
799
+ path.push(index)
800
+ component = parent
801
+ }
802
+ return path.reverse()
803
+ }
804
+
805
+ // Mirrors the server's IDENTITY_ATTRIBUTES ordering: our own `data-wb-id` is
806
+ // preferred because it is readable, whereas a page `id` may be a random token.
807
+ const GRAPESJS_ID_PATTERN = /^i[a-z0-9]{4,6}$/
808
+
809
+ function identityFor(element) {
810
+ for (const name of ['data-wb-id', 'id', 'data-mode']) {
811
+ const value = element.getAttribute(name)
812
+ if (!value) continue
813
+ if (name === 'id') {
814
+ // GrapesJS mints `id` attributes for its own style rules. Those ids do not
815
+ // exist in the source file, so trusting one would make the server look for
816
+ // a node that is not there. Only accept an id the source actually has —
817
+ // and skip the generated-looking ones even then, because an earlier bug may
818
+ // have persisted one and it would make a useless anchor label.
819
+ if (!sourceHasIdentity(name, value)) continue
820
+ if (GRAPESJS_ID_PATTERN.test(value)) continue
821
+ }
822
+ return { name, value }
823
+ }
824
+ return null
825
+ }
826
+
827
+ // GrapesJS-generated ids are absent from the document we loaded from disk, so
828
+ // compare against the source body we were given rather than the live canvas.
829
+ function sourceHasIdentity(name, value) {
830
+ if (!sourceIdentityCache) {
831
+ const holder = document.implementation.createHTMLDocument('source')
832
+ holder.body.innerHTML = sourceBodyHtml
833
+ sourceIdentityCache = new Set()
834
+ holder.body.querySelectorAll('[id], [data-wb-id], [data-mode]').forEach((node) => {
835
+ for (const attribute of ['id', 'data-wb-id', 'data-mode']) {
836
+ const found = node.getAttribute(attribute)
837
+ if (found) sourceIdentityCache.add(`${attribute}=${found}`)
838
+ }
839
+ })
840
+ }
841
+ return sourceIdentityCache.has(`${name}=${value}`)
842
+ }
843
+
844
+ function describeSelection(element) {
845
+ const path = contextPathFor(element)
846
+ if (!path) return null
847
+ const label = element.getAttribute('data-wb-id')
848
+ || element.getAttribute('id')
849
+ || [...element.classList].slice(0, 2).map((item) => `.${item}`).join('')
850
+ || element.tagName.toLowerCase()
851
+ return {
852
+ key: path.join('-'),
853
+ tag: element.tagName.toLowerCase(),
854
+ path,
855
+ identity: identityFor(element),
856
+ textHint: (element.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80),
857
+ label,
858
+ element,
859
+ }
860
+ }
861
+
862
+ function showContextToast(message, state = 'info') {
863
+ contextToast.hidden = false
864
+ contextToast.dataset.state = state
865
+ contextToast.textContent = message
866
+ clearTimeout(contextToastTimer)
867
+ contextToastTimer = setTimeout(() => { contextToast.hidden = true }, state === 'error' ? 6000 : 2600)
868
+ }
869
+
870
+ function flashContextElement(element) {
871
+ if (!element?.isConnected) return
872
+ element.removeAttribute('data-wb-context-sent')
873
+ // Force a reflow so re-adding the attribute restarts the animation even when
874
+ // the same element is sent twice in a row.
875
+ void element.offsetWidth
876
+ element.setAttribute('data-wb-context-sent', '')
877
+ setTimeout(() => element.removeAttribute('data-wb-context-sent'), 1000)
878
+ }
879
+
880
+ // Adds the currently selected element to the chat. One element per click keeps
881
+ // the mental model simple: the toolbar acts on exactly what the user just
882
+ // clicked, and the accumulating list lives in the composer where they can see
883
+ // and remove entries.
884
+ async function sendSelectionToChat(element) {
885
+ if (contextSending) return
886
+ const described = describeSelection(element)
887
+ if (!described) return showContextToast('该元素无法定位,请选择它的父级区域。', 'error')
888
+
889
+ contextSending = true
890
+ setStatus('正在生成上下文', 'saving')
891
+ try {
892
+ // Adding visual context must be read-only. A structural path plus the
893
+ // verbatim source snippet is already sufficient evidence for the agent;
894
+ // trying to mint `data-wb-id` here turns an otherwise harmless toolbar click
895
+ // into a disk write, which fails for a valid file opened outside DSH's
896
+ // workspace-write sandbox. Durable identities remain an optional future
897
+ // save-time enhancement — never a prerequisite for putting context in chat.
898
+ const selection = {
899
+ tag: described.tag,
900
+ path: described.path,
901
+ identity: described.identity,
902
+ textHint: described.textHint,
903
+ }
904
+
905
+ const response = await fetch(api('/api/context'), {
906
+ method: 'POST',
907
+ headers: { 'Content-Type': 'application/json' },
908
+ body: JSON.stringify({ selections: [selection] }),
909
+ })
910
+ const packet = await readJson(response)
911
+ if (!response.ok) throw new Error(packet.message || packet.error)
912
+ publishContext(packet)
913
+ flashContextElement(element)
914
+ showContextToast(`已添加 ${packet.selections[0]?.selector || described.label} 到对话`)
915
+ setStatus('已添加到对话上下文', 'saved')
916
+ } catch (error) {
917
+ showContextToast(error.message || '生成上下文失败,请重新选择。', 'error')
918
+ setStatus('添加到对话失败', 'error')
919
+ } finally {
920
+ contextSending = false
921
+ }
922
+ }
923
+
924
+ // The workbench runs inside an iframe in the DSH panel. Hand the packet to the
925
+ // host when there is one; fall back to the clipboard for a standalone browser.
926
+ function publishContext(packet) {
927
+ const first = packet.selections[0] || {}
928
+ const message = {
929
+ type: 'html-workbench:context',
930
+ filePath: packet.filePath,
931
+ fileName: packet.fileName,
932
+ revision: packet.revision,
933
+ // A stable key lets the host de-duplicate: clicking the same element twice
934
+ // should not stack two identical chips in the composer.
935
+ key: first.identity ? `${first.identity.name}=${first.identity.value}` : `path:${(first.path || []).join('-')}`,
936
+ label: first.selector || first.tag || 'element',
937
+ textHint: first.text || '',
938
+ markdown: packet.markdown,
939
+ }
940
+ if (window.parent !== window) {
941
+ window.parent.postMessage(message, '*')
942
+ return
943
+ }
944
+ navigator.clipboard?.writeText(packet.markdown).catch(() => {})
945
+ }
946
+
683
947
  function initEditor() {
684
948
  editor = grapesjs.init({
685
949
  container: '#gjs',
@@ -710,14 +974,47 @@ function initEditor() {
710
974
  canvas: { frameStyle: 'html{background:#f4f4f5}body{min-height:100vh}' },
711
975
  })
712
976
  window.__GRAPESJS_WORKBENCH__ = { editor }
977
+ editor.Commands.add(CONTEXT_TOOLBAR_COMMAND, {
978
+ run(instance, sender, options = {}) {
979
+ const component = options.component || instance.getSelected()
980
+ const element = component?.getEl?.()
981
+ if (element) void sendSelectionToChat(element)
982
+ },
983
+ })
713
984
  editor.on('load', () => void injectSourceAssets())
714
985
  editor.on('update', scheduleSave)
715
986
  // GrapesJS 的 RTE 文本编辑走 storeData → sync:content(noCount:true)路径,
716
987
  // 不触发 editor 的 'update' 事件(changesCount 不增加)。但组件模型的
717
988
  // Backbone change 事件不受 noCount 影响,会转发为 component:update,
718
989
  // 因此补充监听该事件以覆盖文本内容编辑场景。
719
- editor.on('component:update', scheduleSave)
990
+ editor.on('component:update', scheduleSaveForContentChange)
720
991
  editor.on('component:selected', updateAttributePanel)
992
+ editor.on('component:selected', addContextToolbarItem)
993
+ }
994
+
995
+ // GrapesJS builds a component's toolbar lazily on first selection, so extend it
996
+ // here rather than at init: the array already holds move/copy/delete and we only
997
+ // prepend one item. Guarded by a marker command name so re-selecting the same
998
+ // component never stacks duplicates.
999
+ const CONTEXT_TOOLBAR_COMMAND = 'wb-context-add'
1000
+
1001
+ function addContextToolbarItem(component) {
1002
+ if (!component || typeof component.get !== 'function') return
1003
+ const toolbar = component.get('toolbar')
1004
+ if (!Array.isArray(toolbar)) return
1005
+ if (toolbar.some((item) => item.command === CONTEXT_TOOLBAR_COMMAND)) return
1006
+ // `silent` matters: a normal `set` emits `component:update`, which is wired to
1007
+ // `scheduleSave`. Without it, merely SELECTING an element would mark the
1008
+ // document dirty and write GrapesJS's own minted attributes (e.g. `id="iftsf"`,
1009
+ // created for its style rules) into the user's source file.
1010
+ component.set('toolbar', [{
1011
+ id: CONTEXT_TOOLBAR_COMMAND,
1012
+ command: CONTEXT_TOOLBAR_COMMAND,
1013
+ attributes: { title: '添加到对话上下文', 'aria-label': '将选中元素添加到对话上下文' },
1014
+ // A speech bubble reads as “conversation” at toolbar size; the small plus
1015
+ // distinguishes “add this selection to chat” from a generic comment action.
1016
+ label: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px"><path d="M20 11.3a7.2 7.2 0 0 1-7.5 7.2 8.2 8.2 0 0 1-3.3-.7L4 20l1.8-4.3A6.8 6.8 0 0 1 5 12a7.2 7.2 0 0 1 7.5-7.2 7.2 7.2 0 0 1 7.5 6.5Z"/><path d="M15.8 7.4v4.2M13.7 9.5h4.2"/></svg>',
1017
+ }, ...toolbar], { silent: true })
721
1018
  }
722
1019
 
723
1020
  function updateAttributePanel(component) {
@@ -734,6 +1031,22 @@ function updateAttributePanel(component) {
734
1031
  hrefInput.value = attributes.href || ''
735
1032
  }
736
1033
 
1034
+ // Editor-state properties that GrapesJS stores on the component model but which
1035
+ // say nothing about the document: `open` is the layer tree's expand state,
1036
+ // `toolbar`/`status`/`selected` are selection chrome. They fire the same
1037
+ // `component:update` event as a real content edit, and letting them reach
1038
+ // autosave means merely CLICKING an element rewrites the user's file — which is
1039
+ // how GrapesJS's internally-minted `id="i4nxl"` style-rule attributes ended up
1040
+ // persisted in the source.
1041
+ const NON_CONTENT_PROPERTIES = new Set(['open', 'status', 'toolbar', 'selected', 'hovered', 'highlightable', 'draggable'])
1042
+
1043
+ function scheduleSaveForContentChange(component) {
1044
+ const changed = Object.keys(component?.changed || {})
1045
+ // An empty `changed` is a bulk/unknown update; be conservative and save.
1046
+ if (changed.length && changed.every((key) => NON_CONTENT_PROPERTIES.has(key))) return
1047
+ scheduleSave()
1048
+ }
1049
+
737
1050
  function scheduleSave() {
738
1051
  if (applyingDocument || workbenchMode !== 'edit' || !activeFile) return
739
1052
  dirty = true
@@ -757,7 +1070,7 @@ async function save() {
757
1070
  bodyScripts,
758
1071
  }),
759
1072
  })
760
- const result = await response.json()
1073
+ const result = await readJson(response)
761
1074
  if (response.status === 409) {
762
1075
  setStatus('磁盘有更新,已停止覆盖', 'conflict')
763
1076
  dirty = false
@@ -780,6 +1093,8 @@ async function applyDocument(document) {
780
1093
  revision = document.revision
781
1094
  bodyScripts = document.bodyScripts || []
782
1095
  sourceCss = document.sourceCss || ''
1096
+ sourceBodyHtml = document.bodyHtml || ''
1097
+ sourceIdentityCache = null
783
1098
  links = document.links || []
784
1099
  headScripts = document.headScripts || []
785
1100
  htmlAttributes = document.htmlAttributes || {}
@@ -808,7 +1123,7 @@ async function loadFile(file = activeFile) {
808
1123
  try {
809
1124
  activeFile = file
810
1125
  const response = await fetch(api('/api/document'))
811
- const document = await response.json()
1126
+ const document = await readJson(response)
812
1127
  if (!response.ok) throw new Error(document.message || document.error)
813
1128
  await applyDocument(document)
814
1129
  dirty = false
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "@vibe-x/dsh-html-workbench",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "main": "./src/index.js",
5
5
  "description": "在 DeepSeek Harness 右侧栏预览并可视化编辑 agent 生成的 HTML:复用/拉起本地 html-workbench 服务,在 iframe 中内嵌 GrapesJS 编辑器,插件卸载时停掉自己启动的服务。",
6
6
  "engines": {