@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.
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  import base64
8
+ import bisect
8
9
  import hashlib
9
10
  import html
10
11
  import io
@@ -13,10 +14,12 @@ import logging
13
14
  import mimetypes
14
15
  import os
15
16
  import re
17
+ import signal
16
18
  import subprocess
17
19
  import sys
18
20
  import tarfile
19
21
  import tempfile
22
+ import threading
20
23
  import time
21
24
  import urllib.error
22
25
  import urllib.parse
@@ -31,10 +34,44 @@ from typing import Any
31
34
 
32
35
 
33
36
  SERVICE_NAME = "html-workbench"
34
- SERVICE_VERSION = "2.0.0"
37
+ SERVICE_VERSION = "2.1.0"
38
+
35
39
  DEFAULT_PORT = 4317
40
+ # Capability names advertised by /api/health. `command_open` refuses to reuse a
41
+ # service that lacks any of these: a long-lived process keeps running the code it
42
+ # was started with, while the HTML/JS assets are re-read from disk on every
43
+ # request. Reusing a stale process therefore serves a NEW frontend against an OLD
44
+ # API, and the missing route answers 501 with an HTML error page — which the
45
+ # frontend then fails to parse as JSON. Version-gating the reuse turns that
46
+ # confusing symptom into a silent, automatic restart.
47
+ SERVICE_CAPABILITIES = (
48
+ "grapesjs-canvas",
49
+ "autosave",
50
+ "file-query",
51
+ "file-watch",
52
+ "stdlib-python",
53
+ "visual-context",
54
+ )
36
55
  MAX_REQUEST_BYTES = 8 * 1024 * 1024
37
56
  SCRIPT_PATTERN = re.compile(r"^<script\b[\s\S]*</script\s*>$", re.IGNORECASE)
57
+
58
+ # ── Visual selection context ────────────────────────────────────────────────
59
+ # Anchors are resolved against the on-disk source, never against the GrapesJS
60
+ # canvas DOM: the agent edits the file, so every coordinate it receives must be
61
+ # a real offset in that file. Identity attributes are ordered by how durable
62
+ # they are across an agent rewrite. `data-wb-id` comes FIRST because we mint it
63
+ # ourselves and it is human-readable ("card", "pricing-pro"); a page's `id` may
64
+ # instead be a random token GrapesJS generated for its own style rules, which
65
+ # tells the agent nothing about what the user picked.
66
+ IDENTITY_ATTRIBUTES = ("data-wb-id", "id", "data-mode")
67
+ # Wrappers that carry no meaning on their own; a rectangle hit that lands on
68
+ # one of these is usually the user pointing at its parent.
69
+ TRANSPARENT_TAGS = {"span", "em", "strong", "b", "i", "u", "small", "svg", "path", "g", "circle", "rect", "use", "defs", "line", "polyline", "polygon", "tspan", "br"}
70
+ MAX_CONTEXT_SELECTIONS = 12
71
+ MAX_SNIPPET_CHARS = 1600
72
+ MAX_CONTEXT_BYTES = 24_000
73
+ WB_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
74
+
38
75
  GRAPESJS_VERSION = "0.23.4"
39
76
  VENDOR_ASSETS = {
40
77
  "grapes.min.js": {
@@ -465,10 +502,657 @@ def revision_of(content: str) -> str:
465
502
  return hashlib.sha256(content.encode("utf-8")).hexdigest()
466
503
 
467
504
 
505
+ # ── Visual selection anchors ────────────────────────────────────────────────
506
+ #
507
+ # The agent edits the file on disk, so a selection is only useful if it can be
508
+ # translated into *source* coordinates. The canvas DOM is a GrapesJS runtime
509
+ # model and must never be treated as the authority here: the browser reports
510
+ # WHICH node the user picked, and this module re-parses the on-disk source to
511
+ # decide WHERE that node lives.
512
+ #
513
+ # Resolution is deliberately conservative. An anchor is only accepted when it
514
+ # maps to exactly one source node; zero and multiple matches are both refused
515
+ # so an ambiguous anchor can never reach the agent.
516
+
517
+
518
+ def source_body(parser: SourceHtmlParser) -> HtmlNode | None:
519
+ return next((node for node in parser.nodes if node.tag == "body"), None)
520
+
521
+
522
+ def parse_document_tree(source: str) -> tuple[SourceHtmlParser, HtmlNode]:
523
+ parser = SourceHtmlParser(source)
524
+ try:
525
+ parser.feed(source)
526
+ parser.close()
527
+ except Exception as caught:
528
+ raise WorkbenchError("INVALID_DOCUMENT", f"HTML 解析失败:{caught}", 400) from caught
529
+ body = source_body(parser)
530
+ if body is None:
531
+ raise WorkbenchError("INVALID_DOCUMENT", "目标必须是包含 body 的完整 HTML 文档。", 400)
532
+ return parser, body
533
+
534
+
535
+ def structural_children(node: HtmlNode) -> list[HtmlNode]:
536
+ """Element children as the *editor* sees them.
537
+
538
+ `parse_source` strips body `<script>` elements before handing the document
539
+ to GrapesJS, so the canvas child indices skip them. The structural fallback
540
+ path must use the same numbering or every index after an inline script
541
+ would be off by one.
542
+ """
543
+ return [child for child in node.children if child.tag != "script"]
544
+
545
+
546
+ def node_path(node: HtmlNode, body: HtmlNode) -> list[int] | None:
547
+ path: list[int] = []
548
+ current = node
549
+ while current is not body:
550
+ parent = current.parent
551
+ if parent is None:
552
+ return None
553
+ siblings = structural_children(parent)
554
+ if current not in siblings:
555
+ return None
556
+ path.append(siblings.index(current))
557
+ current = parent
558
+ path.reverse()
559
+ return path
560
+
561
+
562
+ def node_at_path(body: HtmlNode, path: list[int]) -> HtmlNode | None:
563
+ current = body
564
+ for index in path:
565
+ siblings = structural_children(current)
566
+ if not isinstance(index, int) or index < 0 or index >= len(siblings):
567
+ return None
568
+ current = siblings[index]
569
+ return current if current is not body else None
570
+
571
+
572
+ def line_of(parser: SourceHtmlParser, offset: int) -> int:
573
+ return bisect.bisect_right(parser.line_offsets, offset)
574
+
575
+
576
+ def node_text(source: str, node: HtmlNode) -> str:
577
+ if node.end_start is None:
578
+ return ""
579
+ raw = source[node.start_end : node.end_start]
580
+ raw = re.sub(r"<script\b[\s\S]*?</script\s*>", " ", raw, flags=re.IGNORECASE)
581
+ raw = re.sub(r"<[^>]+>", " ", raw)
582
+ return re.sub(r"\s+", " ", html.unescape(raw)).strip()
583
+
584
+
585
+ def identity_of(node: HtmlNode) -> dict[str, str] | None:
586
+ # Ordering does the work: `data-wb-id` is checked before `id`, so a random
587
+ # GrapesJS token only becomes the label when nothing better exists.
588
+ for name in IDENTITY_ATTRIBUTES:
589
+ value = node.attrs.get(name)
590
+ if value:
591
+ return {"name": name, "value": value}
592
+ return None
593
+
594
+
595
+ def selector_of(node: HtmlNode) -> str:
596
+ identity = identity_of(node)
597
+ if identity is None:
598
+ classes = [item for item in node.attrs.get("class", "").split() if item]
599
+ return node.tag + "".join(f".{item}" for item in classes[:2])
600
+ if identity["name"] == "id":
601
+ return f"#{identity['value']}"
602
+ return f'[{identity["name"]}="{identity["value"]}"]'
603
+
604
+
605
+ def normalized_text(value: str) -> str:
606
+ return re.sub(r"\s+", " ", value or "").strip()
607
+
608
+
609
+ def resolve_anchor(parser: SourceHtmlParser, body: HtmlNode, descriptor: dict[str, Any]) -> HtmlNode:
610
+ """Map a browser-side selection descriptor onto exactly one source node."""
611
+ if not isinstance(descriptor, dict):
612
+ raise WorkbenchError("INVALID_ANCHOR", "选区描述必须是对象。", 400)
613
+
614
+ expected_tag = str(descriptor.get("tag") or "").lower() or None
615
+ raw_path = descriptor.get("path")
616
+ path = [item for item in raw_path if isinstance(item, int)] if isinstance(raw_path, list) else None
617
+ if isinstance(raw_path, list) and path is not None and len(path) != len(raw_path):
618
+ path = None
619
+ text_hint = normalized_text(str(descriptor.get("textHint") or ""))
620
+
621
+ identity = descriptor.get("identity")
622
+ candidates: list[HtmlNode] = []
623
+ strategy = ""
624
+
625
+ if isinstance(identity, dict) and identity.get("name") in IDENTITY_ATTRIBUTES and identity.get("value"):
626
+ name = str(identity["name"])
627
+ value = str(identity["value"])
628
+ candidates = [
629
+ node for node in parser.nodes
630
+ if node.is_inside(body) and node.attrs.get(name) == value
631
+ ]
632
+ strategy = "identity"
633
+
634
+ if len(candidates) != 1 and path is not None:
635
+ # Either the identity was absent or it was ambiguous; the structural
636
+ # path can both stand alone and disambiguate an identity collision.
637
+ located = node_at_path(body, path)
638
+ if located is not None:
639
+ if len(candidates) > 1 and located in candidates:
640
+ candidates = [located]
641
+ strategy = "identity+path"
642
+ elif not candidates:
643
+ candidates = [located]
644
+ strategy = "path"
645
+
646
+ if not candidates:
647
+ raise WorkbenchError(
648
+ "ANCHOR_NOT_FOUND",
649
+ "选中的元素在当前磁盘文件中已不存在,请重新选择。",
650
+ 409,
651
+ )
652
+ if len(candidates) > 1:
653
+ raise WorkbenchError(
654
+ "ANCHOR_AMBIGUOUS",
655
+ f"选中的元素在源文件中匹配到 {len(candidates)} 个节点,无法唯一定位。",
656
+ 409,
657
+ )
658
+
659
+ node = candidates[0]
660
+ if expected_tag and node.tag != expected_tag:
661
+ raise WorkbenchError(
662
+ "ANCHOR_NOT_FOUND",
663
+ f"定位到的节点是 <{node.tag}>,与选中的 <{expected_tag}> 不一致,请重新选择。",
664
+ 409,
665
+ )
666
+ if text_hint and strategy in {"path", "identity+path"}:
667
+ # A path is only as good as the tree it was computed against. When the
668
+ # caller supplied visible text, require it to still overlap.
669
+ #
670
+ # Compare with whitespace REMOVED, not merely collapsed. `node_text`
671
+ # replaces each tag with a space, so `<h3>专业版</h3><p>¥99</p>` becomes
672
+ # "专业版 ¥99", while the browser's `textContent` yields "专业版¥99" — the
673
+ # same content, differing only where markup used to be. Comparing those
674
+ # literally rejects every correct anchor in tightly-written HTML.
675
+ actual = collapse_for_comparison(node_text(parser.source, node))
676
+ probe = collapse_for_comparison(text_hint)[:40]
677
+ if probe and probe not in actual and actual[:40] not in probe:
678
+ raise WorkbenchError(
679
+ "ANCHOR_NOT_FOUND",
680
+ "定位到的节点内容与选中的元素不一致,请重新选择。",
681
+ 409,
682
+ )
683
+ return node
684
+
685
+
686
+ def collapse_for_comparison(value: str) -> str:
687
+ return re.sub(r"\s+", "", value or "")
688
+
689
+
690
+ def describe_anchor(parser: SourceHtmlParser, body: HtmlNode, node: HtmlNode) -> dict[str, Any]:
691
+ source = parser.source
692
+ end = node.end_end if node.end_end is not None else node.start_end
693
+ snippet = source[node.start : end]
694
+ truncated = len(snippet) > MAX_SNIPPET_CHARS
695
+ if truncated:
696
+ head = snippet[: MAX_SNIPPET_CHARS // 2].rstrip()
697
+ tail = snippet[-(MAX_SNIPPET_CHARS // 4) :].lstrip()
698
+ snippet = f"{head}\n<!-- … 省略 {len(source[node.start:end]) - len(head) - len(tail)} 字符 … -->\n{tail}"
699
+
700
+ ancestors: list[str] = []
701
+ current = node.parent
702
+ while current is not None and current is not body:
703
+ ancestors.append(selector_of(current))
704
+ current = current.parent
705
+ ancestors.reverse()
706
+
707
+ behavior = {
708
+ name: value for name, value in node.attrs.items()
709
+ if name in {"data-action", "data-target"} or name.lower().startswith("on")
710
+ }
711
+
712
+ return {
713
+ "tag": node.tag,
714
+ "selector": selector_of(node),
715
+ "identity": identity_of(node),
716
+ "path": node_path(node, body),
717
+ "lineStart": line_of(parser, node.start),
718
+ "lineEnd": line_of(parser, max(node.start, end - 1)),
719
+ "startOffset": node.start,
720
+ "endOffset": end,
721
+ "text": node_text(source, node)[:240],
722
+ "classes": [item for item in node.attrs.get("class", "").split() if item],
723
+ "attributes": {name: value for name, value in node.attrs.items() if name != "class"},
724
+ "behavior": behavior,
725
+ "ancestors": ancestors,
726
+ "snippet": snippet,
727
+ "snippetTruncated": truncated,
728
+ }
729
+
730
+
731
+ # ── Related style and behaviour evidence ────────────────────────────────────
732
+ #
733
+ # "Make this purple" only needs the element and its CSS. "Improve this
734
+ # interaction" needs the JavaScript that drives it — and `parse_source` keeps
735
+ # body scripts out of the editable model, so that code has to be recovered from
736
+ # the raw source separately.
737
+
738
+ STYLE_BLOCK_PATTERN = re.compile(r"<style\b([^>]*)>([\s\S]*?)</style\s*>", re.IGNORECASE)
739
+ SCRIPT_BLOCK_PATTERN = re.compile(r"<script\b([^>]*)>([\s\S]*?)</script\s*>", re.IGNORECASE)
740
+ AT_RULE_PATTERN = re.compile(r"@[a-zA-Z-]+")
741
+
742
+
743
+ def split_css_rules(css: str, base_offset: int) -> list[dict[str, Any]]:
744
+ """Split a stylesheet into top-level rules, keeping source offsets.
745
+
746
+ A hand-rolled brace scanner rather than a regex: nested at-rules such as
747
+ `@media` contain their own blocks, and a flat regex would cut them in half.
748
+ """
749
+ rules: list[dict[str, Any]] = []
750
+ depth = 0
751
+ start = 0
752
+ index = 0
753
+ length = len(css)
754
+ while index < length:
755
+ char = css[index]
756
+ if char == "/" and css.startswith("/*", index):
757
+ closing = css.find("*/", index + 2)
758
+ index = length if closing == -1 else closing + 2
759
+ continue
760
+ if char in {'"', "'"}:
761
+ quote = char
762
+ index += 1
763
+ while index < length and css[index] != quote:
764
+ index += 2 if css[index] == "\\" else 1
765
+ index += 1
766
+ continue
767
+ if char == "{":
768
+ if depth == 0:
769
+ prelude = css[start:index]
770
+ depth += 1
771
+ elif char == "}":
772
+ depth -= 1
773
+ if depth <= 0:
774
+ depth = 0
775
+ body = css[start : index + 1]
776
+ selector = normalized_text(re.sub(r"/\*[\s\S]*?\*/", " ", prelude))
777
+ if selector:
778
+ rules.append({
779
+ "selector": selector,
780
+ "text": body.strip(),
781
+ "start": base_offset + start,
782
+ "end": base_offset + index + 1,
783
+ })
784
+ start = index + 1
785
+ index += 1
786
+ return rules
787
+
788
+
789
+ def document_css_rules(source: str) -> list[dict[str, Any]]:
790
+ rules: list[dict[str, Any]] = []
791
+ for match in STYLE_BLOCK_PATTERN.finditer(source):
792
+ if "data-grapesjs-overrides" in match.group(1):
793
+ continue
794
+ rules.extend(split_css_rules(match.group(2), match.start(2)))
795
+ return rules
796
+
797
+
798
+ def selector_tokens(node: HtmlNode) -> set[str]:
799
+ tokens = {node.tag}
800
+ for item in node.attrs.get("class", "").split():
801
+ if item:
802
+ tokens.add(f".{item}")
803
+ identity = node.attrs.get("id")
804
+ if identity:
805
+ tokens.add(f"#{identity}")
806
+ # Attribute selectors, i.e. every identity attribute except `id`, which is
807
+ # already covered by the `#` form above.
808
+ for name in IDENTITY_ATTRIBUTES:
809
+ if name != "id" and node.attrs.get(name):
810
+ tokens.add(f"[{name}")
811
+ return tokens
812
+
813
+
814
+ def related_css(parser: SourceHtmlParser, source: str, nodes: list[HtmlNode], rules: list[dict[str, Any]]) -> list[dict[str, Any]]:
815
+ """Rules whose selector mentions a class/id/tag carried by the selection.
816
+
817
+ Deliberately a *superset* heuristic. Missing a rule would make the agent
818
+ invent CSS that already exists; including one extra rule only costs a few
819
+ tokens. Bare tag names are ignored unless the element has no class or id,
820
+ because `p { }` matches almost everything.
821
+ """
822
+ wanted: set[str] = set()
823
+ tag_only: set[str] = set()
824
+ for node in nodes:
825
+ tokens = selector_tokens(node)
826
+ specific = {token for token in tokens if token.startswith((".", "#", "["))}
827
+ if specific:
828
+ wanted |= specific
829
+ else:
830
+ tag_only.add(node.tag)
831
+
832
+ matched: list[dict[str, Any]] = []
833
+ for rule in rules:
834
+ selector = rule["selector"]
835
+ if AT_RULE_PATTERN.match(selector):
836
+ # Keep at-rules only when their body mentions a wanted token, so a
837
+ # responsive override for the selection still travels with it.
838
+ if not any(token in rule["text"] for token in wanted):
839
+ continue
840
+ elif not any(token in selector for token in wanted):
841
+ bare = {part.strip() for part in re.split(r"[\s,>+~]+", selector) if part.strip()}
842
+ if not (tag_only & bare):
843
+ continue
844
+ matched.append({
845
+ "selector": selector,
846
+ "lineStart": line_of(parser, rule["start"]),
847
+ "lineEnd": line_of(parser, max(rule["start"], rule["end"] - 1)),
848
+ "text": rule["text"] if len(rule["text"]) <= 600 else rule["text"][:600] + "\n /* … */\n}",
849
+ })
850
+ return matched[:14]
851
+
852
+
853
+ def related_scripts(parser: SourceHtmlParser, source: str, nodes: list[HtmlNode]) -> list[dict[str, Any]]:
854
+ """Script blocks that reference an identifier carried by the selection."""
855
+ needles: set[str] = set()
856
+ for node in nodes:
857
+ for name in IDENTITY_ATTRIBUTES:
858
+ value = node.attrs.get(name)
859
+ if value:
860
+ needles.add(value)
861
+ for name in ("data-action", "data-target"):
862
+ value = node.attrs.get(name)
863
+ if value:
864
+ needles.add(value)
865
+ for item in node.attrs.get("class", "").split():
866
+ if item:
867
+ needles.add(item)
868
+ if not needles:
869
+ return []
870
+
871
+ blocks: list[dict[str, Any]] = []
872
+ for match in SCRIPT_BLOCK_PATTERN.finditer(source):
873
+ code = match.group(2)
874
+ if not code.strip():
875
+ continue
876
+ hits = sorted({needle for needle in needles if needle in code})
877
+ if not hits:
878
+ continue
879
+ blocks.append({
880
+ "lineStart": line_of(parser, match.start()),
881
+ "lineEnd": line_of(parser, max(match.start(), match.end() - 1)),
882
+ "matches": hits,
883
+ "text": code.strip() if len(code.strip()) <= 2400 else code.strip()[:2400] + "\n/* … */",
884
+ })
885
+ return blocks[:3]
886
+
887
+
888
+ # ── Stable identity promotion ───────────────────────────────────────────────
889
+ #
890
+ # Most elements carry neither `id` nor `data-wb-id`, so their only anchor is a
891
+ # structural path — which dies the moment the agent rewrites that subtree. On
892
+ # selection the workbench therefore writes a readable `data-wb-id` into the
893
+ # source. This is not pollution: `data-wb-id` is already the project's own
894
+ # contract for addressing elements (see the editable-HTML guidelines), so
895
+ # promoting a selection moves the page *towards* the spec rather than away.
896
+
897
+ STOPWORD_SLUGS = {"the", "and", "for", "with", "you", "your", "our", "this", "that", "from", "are", "was"}
898
+
899
+
900
+ def slugify(value: str, fallback: str) -> str:
901
+ ascii_value = re.sub(r"[^a-z0-9]+", "-", (value or "").lower()).strip("-")
902
+ words = [word for word in ascii_value.split("-") if word and word not in STOPWORD_SLUGS]
903
+ slug = "-".join(words[:4])[:40].strip("-")
904
+ return slug or fallback
905
+
906
+
907
+ def suggest_wb_id(node: HtmlNode, taken: set[str]) -> str:
908
+ classes = [item for item in node.attrs.get("class", "").split() if item]
909
+ seeds = [
910
+ slugify(classes[0], "") if classes else "",
911
+ slugify(node_text_seed(node), ""),
912
+ node.tag,
913
+ ]
914
+ base = next((seed for seed in seeds if seed), node.tag)
915
+ if not base.startswith(node.tag) and base != node.tag:
916
+ base = f"{base}"
917
+ candidate = base
918
+ counter = 2
919
+ while candidate in taken:
920
+ candidate = f"{base}-{counter}"
921
+ counter += 1
922
+ return candidate
923
+
924
+
925
+ def node_text_seed(node: HtmlNode) -> str:
926
+ for name in ("aria-label", "alt", "title", "name", "data-action"):
927
+ value = node.attrs.get(name)
928
+ if value:
929
+ return value
930
+ return ""
931
+
932
+
933
+ def existing_wb_ids(parser: SourceHtmlParser) -> set[str]:
934
+ taken: set[str] = set()
935
+ for node in parser.nodes:
936
+ for name in ("id", "data-wb-id"):
937
+ value = node.attrs.get(name)
938
+ if value:
939
+ taken.add(value)
940
+ return taken
941
+
942
+
943
+ def promote_identities(target: Path, descriptors: list[dict[str, Any]], base_revision: str) -> dict[str, Any]:
944
+ """Give every anchorless selection a durable `data-wb-id`, atomically."""
945
+ current = read_document(target)
946
+ if base_revision and current["revision"] != base_revision:
947
+ raise WorkbenchError("REVISION_CONFLICT", "磁盘文件已被外部修改,请重新加载后再试。", 409, document=current)
948
+
949
+ source = current["html"]
950
+ parser, body = parse_document_tree(source)
951
+ taken = existing_wb_ids(parser)
952
+ insertions: list[tuple[int, str]] = []
953
+ assigned: list[dict[str, Any]] = []
954
+
955
+ for descriptor in descriptors:
956
+ node = resolve_anchor(parser, body, descriptor)
957
+ identity = identity_of(node)
958
+ if identity is not None:
959
+ assigned.append({"identity": identity, "created": False})
960
+ continue
961
+ value = suggest_wb_id(node, taken)
962
+ taken.add(value)
963
+ # Insert just before the '>' that closes the start tag. Self-closing
964
+ # tags keep their slash, so target the last character of the raw tag.
965
+ raw = source[node.start : node.start_end]
966
+ offset = node.start + len(raw.rstrip()[:-1].rstrip("/").rstrip()) if raw.endswith(">") else node.start_end - 1
967
+ insertions.append((offset, f' data-wb-id="{html.escape(value, quote=True)}"'))
968
+ assigned.append({"identity": {"name": "data-wb-id", "value": value}, "created": True})
969
+
970
+ if not insertions:
971
+ return {"revision": current["revision"], "assigned": assigned, "changed": False}
972
+
973
+ updated = source
974
+ for offset, text in sorted(insertions, key=lambda item: item[0], reverse=True):
975
+ updated = updated[:offset] + text + updated[offset:]
976
+
977
+ latest = read_document(target)
978
+ if latest["revision"] != current["revision"]:
979
+ raise WorkbenchError("REVISION_CONFLICT", "写入期间文件发生外部修改,未覆盖最新版。", 409, document=latest)
980
+ write_atomic(target, updated)
981
+ return {"revision": revision_of(updated), "assigned": assigned, "changed": True}
982
+
983
+
984
+ def write_atomic(target: Path, content: str) -> None:
985
+ temporary_name: str | None = None
986
+ try:
987
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.workbench-", suffix=".tmp", dir=target.parent)
988
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as output:
989
+ output.write(content)
990
+ output.flush()
991
+ os.fsync(output.fileno())
992
+ os.chmod(temporary_name, target.stat().st_mode)
993
+ os.replace(temporary_name, target)
994
+ finally:
995
+ if temporary_name and os.path.exists(temporary_name):
996
+ os.unlink(temporary_name)
997
+
998
+
999
+ # ── Context packet ──────────────────────────────────────────────────────────
1000
+
1001
+
1002
+ def build_selection_context(target: Path, descriptors: list[dict[str, Any]]) -> dict[str, Any]:
1003
+ if not isinstance(descriptors, list) or not descriptors:
1004
+ raise WorkbenchError("INVALID_ANCHOR", "请至少选择一个元素。", 400)
1005
+ if len(descriptors) > MAX_CONTEXT_SELECTIONS:
1006
+ raise WorkbenchError("INVALID_ANCHOR", f"一次最多添加 {MAX_CONTEXT_SELECTIONS} 个元素。", 400)
1007
+
1008
+ document = read_document(target)
1009
+ source = document["html"]
1010
+ parser, body = parse_document_tree(source)
1011
+
1012
+ nodes = [resolve_anchor(parser, body, descriptor) for descriptor in descriptors]
1013
+ # Drop selections already contained in another selection: sending both a
1014
+ # section and its heading duplicates the source and blurs the instruction.
1015
+ kept: list[HtmlNode] = []
1016
+ for node in nodes:
1017
+ if any(node is not other and node.is_inside(other) for other in nodes):
1018
+ continue
1019
+ if any(node is other for other in kept):
1020
+ continue
1021
+ kept.append(node)
1022
+
1023
+ selections = [describe_anchor(parser, body, node) for node in kept]
1024
+ rules = document_css_rules(source)
1025
+ payload = {
1026
+ "filePath": document["filePath"],
1027
+ "fileName": document["fileName"],
1028
+ "revision": document["revision"],
1029
+ "selections": selections,
1030
+ "css": related_css(parser, source, kept, rules),
1031
+ "scripts": related_scripts(parser, source, kept),
1032
+ "collapsed": len(nodes) - len(kept),
1033
+ }
1034
+ payload["markdown"] = render_context_markdown(payload)
1035
+ return payload
1036
+
1037
+
1038
+ def render_context_markdown(payload: dict[str, Any]) -> str:
1039
+ """Render the packet the agent actually reads.
1040
+
1041
+ Optimised for an agent that cannot see the page: every selection leads with
1042
+ how to find it again (anchor + line range) and only then describes what it
1043
+ is. The literal source snippet is included so a string-replacing edit tool
1044
+ has an exact `old_string` to match.
1045
+ """
1046
+ lines: list[str] = []
1047
+ lines.append("## 视觉选区(HTML Workbench)")
1048
+ lines.append("")
1049
+ lines.append(f"- 文件:`{payload['filePath']}`")
1050
+ lines.append(f"- revision:`{payload['revision'][:12]}`")
1051
+ lines.append(f"- 选中元素:{len(payload['selections'])} 个(编辑态框选)")
1052
+ lines.append("")
1053
+ lines.append("用户在页面上直接选中了下列元素。后续指令中的「这个 / 这里 / 这块」均指这些元素。")
1054
+ lines.append("")
1055
+
1056
+ for index, item in enumerate(payload["selections"], start=1):
1057
+ marker = f"### {index}. `{item['selector']}`"
1058
+ lines.append(marker)
1059
+ lines.append("")
1060
+ identity = item.get("identity")
1061
+ if identity:
1062
+ lines.append(f"- 稳定锚点:`[{identity['name']}=\"{identity['value']}\"]`")
1063
+ else:
1064
+ lines.append("- 稳定锚点:无(该元素没有 id / data-wb-id)")
1065
+ lines.append(f"- 源码位置:第 {item['lineStart']}–{item['lineEnd']} 行")
1066
+ if item["ancestors"]:
1067
+ lines.append(f"- 层级:`{' > '.join(item['ancestors'][-4:])} > {item['selector']}`")
1068
+ if item["text"]:
1069
+ lines.append(f"- 可见文本:{item['text']}")
1070
+ if item["behavior"]:
1071
+ behavior = " ".join(f'{name}="{value}"' for name, value in item["behavior"].items())
1072
+ lines.append(f"- 行为属性:`{behavior}`")
1073
+ lines.append("")
1074
+ lines.append("```html")
1075
+ lines.append(item["snippet"])
1076
+ lines.append("```")
1077
+ lines.append("")
1078
+
1079
+ if payload["css"]:
1080
+ lines.append("### 关联样式规则")
1081
+ lines.append("")
1082
+ for rule in payload["css"]:
1083
+ lines.append(f"`{rule['selector']}` — 第 {rule['lineStart']}–{rule['lineEnd']} 行")
1084
+ lines.append("")
1085
+ lines.append("```css")
1086
+ lines.append(rule["text"])
1087
+ lines.append("```")
1088
+ lines.append("")
1089
+
1090
+ if payload["scripts"]:
1091
+ lines.append("### 关联脚本")
1092
+ lines.append("")
1093
+ for block in payload["scripts"]:
1094
+ hits = "、".join(f"`{item}`" for item in block["matches"][:6])
1095
+ lines.append(f"第 {block['lineStart']}–{block['lineEnd']} 行,引用了 {hits}")
1096
+ lines.append("")
1097
+ lines.append("```js")
1098
+ lines.append(block["text"])
1099
+ lines.append("```")
1100
+ lines.append("")
1101
+
1102
+ lines.append("### 修改约束")
1103
+ lines.append("")
1104
+ lines.append("- 只修改上述选区及其关联样式 / 脚本区间,页面其余部分保持不变。")
1105
+ lines.append("- 优先用稳定锚点定位;不要依赖 `nth-child`、兄弟顺序或行号硬编码。")
1106
+ lines.append("- 编辑前请重新读取该文件:用户可能在此期间继续做了可视化修改。")
1107
+ lines.append("")
1108
+
1109
+ markdown = "\n".join(lines)
1110
+ if len(markdown.encode("utf-8")) > MAX_CONTEXT_BYTES:
1111
+ encoded = markdown.encode("utf-8")[:MAX_CONTEXT_BYTES]
1112
+ markdown = encoded.decode("utf-8", "ignore") + "\n\n<!-- 上下文过长,已截断 -->\n"
1113
+ return markdown
1114
+
1115
+
1116
+
1117
+
1118
+
1119
+ def normalize_file_reference(raw_file: str) -> str:
1120
+ """Turn a browser `file://` URL into the platform's local path string.
1121
+
1122
+ The address bar accepts both a normal filesystem path and a URL copied from
1123
+ Finder/Explorer/browser. `Path('file:///…%E4…')` is not a URL parser: it
1124
+ keeps the scheme and percent escapes literally, which made a valid existing
1125
+ file look missing. Decode it here, at the service boundary shared by every
1126
+ API and CLI command.
1127
+ """
1128
+ value = raw_file.strip()
1129
+ parsed = urllib.parse.urlsplit(value)
1130
+ if parsed.scheme.lower() != "file":
1131
+ return value
1132
+ if parsed.query or parsed.fragment:
1133
+ raise WorkbenchError("INVALID_FILE_URL", "file:// 地址不能包含 query 或 hash。", 400)
1134
+
1135
+ host = parsed.netloc.lower()
1136
+ path = urllib.parse.unquote(parsed.path)
1137
+ if host not in {"", "localhost"}:
1138
+ if os.name != "nt":
1139
+ raise WorkbenchError("INVALID_FILE_URL", "当前系统只支持本机 file:// 地址。", 400)
1140
+ # file://server/share/page.html → \\server\share\page.html on Windows.
1141
+ path = f"//{parsed.netloc}{path}"
1142
+ elif os.name == "nt" and re.match(r"^/[A-Za-z]:/", path):
1143
+ # URL syntax requires a leading slash before a Windows drive: file:///C:/…
1144
+ path = path[1:]
1145
+
1146
+ if not path:
1147
+ raise WorkbenchError("INVALID_FILE_URL", "file:// 地址未包含文件路径。", 400)
1148
+ return path
1149
+
1150
+
468
1151
  def resolve_html_file(raw_file: str) -> Path:
469
1152
  if not raw_file.strip():
470
1153
  raise WorkbenchError("FILE_REQUIRED", "请通过 file 参数指定 HTML 文件。", 400)
471
- requested = Path(raw_file).expanduser()
1154
+ normalized = normalize_file_reference(raw_file)
1155
+ requested = Path(normalized).expanduser()
472
1156
  try:
473
1157
  target = requested.resolve(strict=True)
474
1158
  except FileNotFoundError as caught:
@@ -622,7 +1306,7 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
622
1306
  "version": SERVICE_VERSION,
623
1307
  "port": self.server.server_port,
624
1308
  "editorRoot": str(self.server.editor_root),
625
- "capabilities": ["grapesjs-canvas", "autosave", "file-query", "file-watch", "stdlib-python"],
1309
+ "capabilities": list(SERVICE_CAPABILITIES),
626
1310
  })
627
1311
  return
628
1312
  if parsed.path == "/api/document":
@@ -651,10 +1335,7 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
651
1335
  self.send_json(404, {"error": "NOT_FOUND", "message": "接口不存在。"})
652
1336
  return
653
1337
  try:
654
- length = int(self.headers.get("Content-Length", "0"))
655
- if length <= 0 or length > MAX_REQUEST_BYTES:
656
- raise WorkbenchError("INVALID_SAVE", "保存请求大小无效。", 400)
657
- body = json.loads(self.rfile.read(length).decode("utf-8"))
1338
+ body = self.read_json_body()
658
1339
  query = urllib.parse.parse_qs(parsed.query)
659
1340
  self.send_json(200, save_document(self.query_file(query), body))
660
1341
  except json.JSONDecodeError as caught:
@@ -662,6 +1343,46 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
662
1343
  except Exception as caught:
663
1344
  self.send_error_payload(caught)
664
1345
 
1346
+ def do_POST(self) -> None:
1347
+ parsed = urllib.parse.urlsplit(self.path)
1348
+ query = urllib.parse.parse_qs(parsed.query)
1349
+ try:
1350
+ if parsed.path == "/api/shutdown":
1351
+ # Lets a newer `open` retire this process cleanly. The service
1352
+ # holds no state of its own, so exiting is always safe.
1353
+ self.send_json(200, {"ok": True, "stopping": True})
1354
+ threading.Thread(target=self.server.shutdown, daemon=True).start()
1355
+ return
1356
+ if parsed.path not in {"/api/context", "/api/anchor"}:
1357
+ self.send_json(404, {"error": "NOT_FOUND", "message": "接口不存在。"})
1358
+ return
1359
+ body = self.read_json_body()
1360
+ target = self.query_file(query)
1361
+ selections = body.get("selections")
1362
+ if parsed.path == "/api/anchor":
1363
+ # Identity promotion mutates the file, so it is a separate,
1364
+ # explicitly revision-checked step ahead of context building.
1365
+ if not isinstance(selections, list) or not selections:
1366
+ raise WorkbenchError("INVALID_ANCHOR", "请至少选择一个元素。", 400)
1367
+ if len(selections) > MAX_CONTEXT_SELECTIONS:
1368
+ raise WorkbenchError("INVALID_ANCHOR", f"一次最多添加 {MAX_CONTEXT_SELECTIONS} 个元素。", 400)
1369
+ self.send_json(200, promote_identities(target, selections, str(body.get("baseRevision") or "")))
1370
+ return
1371
+ self.send_json(200, build_selection_context(target, selections))
1372
+ except json.JSONDecodeError as caught:
1373
+ self.send_error_payload(WorkbenchError("INVALID_ANCHOR", f"请求不是有效 JSON:{caught}", 400))
1374
+ except Exception as caught:
1375
+ self.send_error_payload(caught)
1376
+
1377
+ def read_json_body(self) -> dict[str, Any]:
1378
+ length = int(self.headers.get("Content-Length", "0"))
1379
+ if length <= 0 or length > MAX_REQUEST_BYTES:
1380
+ raise WorkbenchError("INVALID_SAVE", "请求大小无效。", 400)
1381
+ payload = json.loads(self.rfile.read(length).decode("utf-8"))
1382
+ if not isinstance(payload, dict):
1383
+ raise WorkbenchError("INVALID_SAVE", "请求体必须是 JSON 对象。", 400)
1384
+ return payload
1385
+
665
1386
  def send_asset(self, route: str) -> None:
666
1387
  parts = route.split("/", 3)
667
1388
  if len(parts) < 4:
@@ -710,6 +1431,111 @@ def health(port: int, timeout: float = 0.5) -> dict[str, Any] | None:
710
1431
  return None
711
1432
 
712
1433
 
1434
+ def is_reusable(payload: dict[str, Any] | None) -> bool:
1435
+ """Whether a live service speaks the same API as this script.
1436
+
1437
+ A process serves the code it was started with, but serves assets from disk.
1438
+ So an old process + new assets is the worst combination: the UI offers
1439
+ features whose routes answer 501. Require every capability this script
1440
+ advertises before reusing.
1441
+ """
1442
+ if not payload:
1443
+ return False
1444
+ advertised = payload.get("capabilities")
1445
+ if not isinstance(advertised, list):
1446
+ return False
1447
+ return set(SERVICE_CAPABILITIES).issubset({str(item) for item in advertised})
1448
+
1449
+
1450
+ def stop_service(port: int, timeout: float = 6.0) -> bool:
1451
+ """Ask an outdated service to exit, then free its TCP listener.
1452
+
1453
+ Before 2.1.0 there was no shutdown route. Also, a failed/foreign listener
1454
+ can hold the port without answering health: a health failure is *not* proof
1455
+ that binding will succeed. We therefore wait for the actual listener to go
1456
+ away, and use the native process terminator on each platform as a fallback.
1457
+ """
1458
+ try:
1459
+ request = urllib.request.Request(f"http://127.0.0.1:{port}/api/shutdown", method="POST")
1460
+ urllib.request.urlopen(request, timeout=timeout).close()
1461
+ except (OSError, ValueError, urllib.error.URLError):
1462
+ pass
1463
+ if wait_for_port_free(port, 2.0):
1464
+ return True
1465
+ for pid in listeners_on_port(port):
1466
+ terminate_process(pid)
1467
+ return wait_for_port_free(port, timeout)
1468
+
1469
+
1470
+ def wait_for_port_free(port: int, timeout: float) -> bool:
1471
+ """Wait until no process is listening, including unhealthy listeners."""
1472
+ deadline = time.monotonic() + timeout
1473
+ while time.monotonic() < deadline:
1474
+ if not listeners_on_port(port):
1475
+ return True
1476
+ time.sleep(0.1)
1477
+ return not listeners_on_port(port)
1478
+
1479
+
1480
+ def listeners_on_port(port: int) -> list[int]:
1481
+ """Return PIDs listening on a local TCP port, best-effort on every OS.
1482
+
1483
+ macOS/Linux use lsof. Windows ships `netstat` and exposes the PID in its
1484
+ final column, avoiding an optional Python dependency such as psutil.
1485
+ """
1486
+ try:
1487
+ if os.name == "nt":
1488
+ result = subprocess.run(
1489
+ ["netstat", "-ano", "-p", "tcp"],
1490
+ capture_output=True, text=True, timeout=5,
1491
+ )
1492
+ pids = []
1493
+ for line in result.stdout.splitlines():
1494
+ columns = line.split()
1495
+ # TCP 127.0.0.1:4317 0.0.0.0:0 LISTENING 1234
1496
+ if len(columns) < 5 or columns[0].upper() != "TCP" or columns[3].upper() != "LISTENING":
1497
+ continue
1498
+ local = columns[1].rsplit(":", 1)
1499
+ if len(local) != 2 or local[1] != str(port):
1500
+ continue
1501
+ try:
1502
+ pid = int(columns[-1])
1503
+ except ValueError:
1504
+ continue
1505
+ if pid != os.getpid():
1506
+ pids.append(pid)
1507
+ return pids
1508
+ result = subprocess.run(
1509
+ ["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
1510
+ capture_output=True, text=True, timeout=5,
1511
+ )
1512
+ except (OSError, subprocess.SubprocessError):
1513
+ return []
1514
+ pids = []
1515
+ for line in result.stdout.split():
1516
+ try:
1517
+ pid = int(line)
1518
+ except ValueError:
1519
+ continue
1520
+ if pid != os.getpid():
1521
+ pids.append(pid)
1522
+ return pids
1523
+
1524
+
1525
+ def terminate_process(pid: int) -> None:
1526
+ """Best-effort process-tree termination on the current platform."""
1527
+ try:
1528
+ if os.name == "nt":
1529
+ subprocess.run(
1530
+ ["taskkill", "/PID", str(pid), "/T", "/F"],
1531
+ capture_output=True, text=True, timeout=5,
1532
+ )
1533
+ else:
1534
+ os.kill(pid, signal.SIGTERM)
1535
+ except (OSError, subprocess.SubprocessError):
1536
+ pass
1537
+
1538
+
713
1539
  def start_detached(script_file: Path, port: int, editor_root: Path, asset_file: Path | None, vendor_cache: Path, log_dir: Path) -> tuple[int, Path]:
714
1540
  directory = Path(log_dir).expanduser()
715
1541
  directory.mkdir(parents=True, exist_ok=True)
@@ -751,6 +1577,17 @@ def command_open(args: argparse.Namespace, script_file: Path) -> int:
751
1577
  editor_root = Path(args.editor_root).expanduser().resolve(strict=True)
752
1578
  resolve_html_file(str(target))
753
1579
  existing = health(args.port)
1580
+ if existing is not None and not is_reusable(existing):
1581
+ # Same port, older code: retire it rather than serve a new UI against a
1582
+ # stale API. Nothing is lost — the service keeps no state of its own.
1583
+ stop_service(args.port)
1584
+ existing = health(args.port)
1585
+ if existing is not None:
1586
+ raise WorkbenchError(
1587
+ "SERVER_OUTDATED",
1588
+ f"端口 {args.port} 上有旧版服务且无法自动停止,请手动结束该进程后重试。",
1589
+ 500,
1590
+ )
754
1591
  reused = existing is not None
755
1592
  pid: int | None = None
756
1593
  log_file: Path | None = None
@@ -796,9 +1633,28 @@ def build_parser() -> argparse.ArgumentParser:
796
1633
 
797
1634
  health_command = subparsers.add_parser("health", help="check the local service")
798
1635
  health_command.add_argument("--port", type=int, default=DEFAULT_PORT)
1636
+
1637
+ stop_command = subparsers.add_parser("stop", help="retire whatever service holds the port")
1638
+ stop_command.add_argument("--port", type=int, default=DEFAULT_PORT)
799
1639
  return parser
800
1640
 
801
1641
 
1642
+ def command_stop(args: argparse.Namespace) -> int:
1643
+ """Free the port so a fresh service can bind it.
1644
+
1645
+ Callers use this to recover from `PORT_IN_USE`, where the listener is a
1646
+ process they do not own (a leftover from an earlier run, or an outdated
1647
+ version). Succeeding when nothing is listening keeps it safe to call blindly.
1648
+ """
1649
+ if health(args.port) is None and not listeners_on_port(args.port):
1650
+ print(json.dumps({"ok": True, "port": args.port, "stopped": False, "reason": "NOTHING_LISTENING"}, ensure_ascii=False))
1651
+ return 0
1652
+ if stop_service(args.port):
1653
+ print(json.dumps({"ok": True, "port": args.port, "stopped": True}, ensure_ascii=False))
1654
+ return 0
1655
+ raise WorkbenchError("SERVER_OUTDATED", f"端口 {args.port} 上的进程无法停止,请手动结束后重试。", 500)
1656
+
1657
+
802
1658
  def main() -> int:
803
1659
  if sys.version_info < (3, 9):
804
1660
  print(json.dumps({"ok": False, "error": "PYTHON_TOO_OLD", "message": "需要 Python 3.9 或更高版本。"}, ensure_ascii=False), file=sys.stderr)
@@ -810,6 +1666,8 @@ def main() -> int:
810
1666
  return command_serve(args, script_file)
811
1667
  if args.command == "open":
812
1668
  return command_open(args, script_file)
1669
+ if args.command == "stop":
1670
+ return command_stop(args)
813
1671
  payload = health(args.port)
814
1672
  print(json.dumps(payload or {"ok": False, "error": "SERVER_UNAVAILABLE"}, ensure_ascii=False))
815
1673
  return 0 if payload else 1