@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.
- package/assets/workbench.html +318 -3
- package/dsh.plugin.json +1 -1
- package/lib/client.js +442 -6
- package/package.json +1 -1
- package/scripts/workbench.py +865 -7
- package/src/client.js +422 -5
- package/src/host.js +327 -34
- package/src/index.js +13 -4
package/scripts/workbench.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|