@vibe-x/dsh-html-workbench 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -2
- package/assets/workbench.html +318 -3
- package/dsh.plugin.json +1 -1
- package/lib/client.js +514 -7
- package/package.json +1 -1
- package/scripts/workbench.py +1091 -37
- package/src/client.js +480 -6
- package/src/host.js +586 -41
- package/src/index.js +74 -5
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.2.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": {
|
|
@@ -89,6 +126,135 @@ def default_log_dir() -> Path:
|
|
|
89
126
|
return base / "html-workbench" / "logs"
|
|
90
127
|
|
|
91
128
|
|
|
129
|
+
# Name of the runtime-state folder this process keeps beside its working
|
|
130
|
+
# directory. The DSH plugin resolves the same name host-side, so both halves
|
|
131
|
+
# agree on one location instead of scattering `.html-workbench` and
|
|
132
|
+
# `html-workbench-dsh` through the user's workspace.
|
|
133
|
+
RUNTIME_DIR_NAME = ".html-workbench"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def runtime_directory() -> Path:
|
|
137
|
+
"""Return this process's runtime-state root: `<cwd>/<RUNTIME_DIR_NAME>`.
|
|
138
|
+
|
|
139
|
+
Under DSH the working directory is the session workspace, which is one of the
|
|
140
|
+
only two trees the subprocess sandbox grants write access to — the other is a
|
|
141
|
+
private temp directory it hands the child through TEMP/TMP. Everywhere else
|
|
142
|
+
the working directory is simply a sensible, self-cleaning location. Logs and
|
|
143
|
+
the GrapesJS cache are disposable, so either is acceptable; what is NOT
|
|
144
|
+
acceptable is a path the sandbox refuses (see `pick_writable_dir`).
|
|
145
|
+
"""
|
|
146
|
+
return Path.cwd() / RUNTIME_DIR_NAME
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def make_temp_file(directory: Path, prefix: str, suffix: str = "") -> tuple[int, Path]:
|
|
150
|
+
"""Create a uniquely named temporary file in `directory`; return (fd, path).
|
|
151
|
+
|
|
152
|
+
Deliberately NOT `tempfile.mkstemp`. On Windows CPython's `_mkstemp_inner`
|
|
153
|
+
treats a `PermissionError` as "that name is already taken": it re-checks the
|
|
154
|
+
directory with `os.access(dir, os.W_OK)` and then retries with a NEW random
|
|
155
|
+
name, up to TMP_MAX (10 000) times. A real DACL always looks writable to
|
|
156
|
+
`os.access`, which cannot see the restricted token DSH's sandbox runs the
|
|
157
|
+
child under — so a directory the sandbox refuses makes `mkstemp` spin for
|
|
158
|
+
minutes (one denied `os.open` per iteration, ~4 ms each) instead of failing
|
|
159
|
+
in milliseconds. A single O_EXCL open turns that into an immediate error the
|
|
160
|
+
caller can catch and recover from.
|
|
161
|
+
"""
|
|
162
|
+
for attempt in range(64):
|
|
163
|
+
candidate = Path(directory) / f"{prefix}{os.getpid()}-{attempt}-{os.urandom(6).hex()}{suffix}"
|
|
164
|
+
try:
|
|
165
|
+
descriptor = os.open(
|
|
166
|
+
candidate,
|
|
167
|
+
os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_BINARY", 0),
|
|
168
|
+
0o600,
|
|
169
|
+
)
|
|
170
|
+
except FileExistsError:
|
|
171
|
+
continue
|
|
172
|
+
return descriptor, candidate
|
|
173
|
+
raise FileExistsError(f"无法在 {directory} 创建临时文件:连续的文件名冲突")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def self_ignore(directory: Path) -> None:
|
|
177
|
+
"""Make a runtime directory invisible to git, best effort.
|
|
178
|
+
|
|
179
|
+
The runtime root usually lands inside the session workspace, which for most
|
|
180
|
+
users is a repository: without this, every start dirties `git status` with a
|
|
181
|
+
folder holding nothing but logs and a re-downloadable cache, and the noise
|
|
182
|
+
lands in the user's own project rather than ours. A `.gitignore` containing
|
|
183
|
+
`*` excludes the tree including that file, so nothing has to be added to the
|
|
184
|
+
repository's own ignore rules.
|
|
185
|
+
|
|
186
|
+
Only the runtime root is marked — never an arbitrary directory the caller
|
|
187
|
+
happened to pass, which may be the user's source tree (`--log-dir .`).
|
|
188
|
+
"""
|
|
189
|
+
root = next((parent for parent in (directory, *directory.parents) if parent.name == RUNTIME_DIR_NAME), None)
|
|
190
|
+
if root is None:
|
|
191
|
+
return
|
|
192
|
+
marker = root / ".gitignore"
|
|
193
|
+
if marker.exists():
|
|
194
|
+
return
|
|
195
|
+
try:
|
|
196
|
+
marker.write_text("# Created by html-workbench: disposable logs and cache.\n*\n", encoding="utf-8")
|
|
197
|
+
except OSError:
|
|
198
|
+
pass
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def pick_writable_dir(*candidates: Path) -> Path:
|
|
202
|
+
"""Return the first candidate directory this process can really write into.
|
|
203
|
+
|
|
204
|
+
Existence proves nothing: the sandbox DACL looks writable on paper while the
|
|
205
|
+
restricted token is denied, and a folder the unconfined parent created may
|
|
206
|
+
reject every file creation here with EACCES / WinError 5. The only reliable
|
|
207
|
+
test is an actual create, which `make_temp_file` performs and fails fast.
|
|
208
|
+
Falls back to the first candidate so a caller with nothing writable at all
|
|
209
|
+
still gets a deterministic path (and a clear error at the real write).
|
|
210
|
+
"""
|
|
211
|
+
fallback: Path | None = None
|
|
212
|
+
for candidate in candidates:
|
|
213
|
+
if candidate is None:
|
|
214
|
+
continue
|
|
215
|
+
if fallback is None:
|
|
216
|
+
fallback = candidate
|
|
217
|
+
try:
|
|
218
|
+
candidate.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
except OSError:
|
|
220
|
+
continue
|
|
221
|
+
try:
|
|
222
|
+
descriptor, probe = make_temp_file(candidate, prefix=".wb-write-probe.")
|
|
223
|
+
except OSError:
|
|
224
|
+
continue
|
|
225
|
+
os.close(descriptor)
|
|
226
|
+
try:
|
|
227
|
+
probe.unlink()
|
|
228
|
+
except OSError:
|
|
229
|
+
pass
|
|
230
|
+
self_ignore(candidate)
|
|
231
|
+
return candidate
|
|
232
|
+
return fallback if fallback is not None else Path.cwd()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def log_candidates(log_dir: Path | str) -> tuple[Path, ...]:
|
|
236
|
+
"""Log directories to try, best first: the caller's, then writable fallbacks."""
|
|
237
|
+
return (
|
|
238
|
+
Path(log_dir).expanduser(),
|
|
239
|
+
runtime_directory() / "logs",
|
|
240
|
+
Path(tempfile.gettempdir()) / RUNTIME_DIR_NAME / "logs",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def vendor_candidates(vendor_cache: Path | str, log_dir: Path) -> tuple[Path, ...]:
|
|
245
|
+
"""Vendor-cache directories to try, best first.
|
|
246
|
+
|
|
247
|
+
The cache sits beside whichever directory accepted the log: both are
|
|
248
|
+
re-creatable, and the log's parent already proved it is writable.
|
|
249
|
+
"""
|
|
250
|
+
return (
|
|
251
|
+
Path(vendor_cache).expanduser(),
|
|
252
|
+
Path(log_dir).parent / "vendor",
|
|
253
|
+
runtime_directory() / "vendor",
|
|
254
|
+
Path(tempfile.gettempdir()) / RUNTIME_DIR_NAME / "vendor",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
92
258
|
_logger: logging.Logger | None = None
|
|
93
259
|
|
|
94
260
|
|
|
@@ -107,27 +273,40 @@ def get_logger() -> logging.Logger:
|
|
|
107
273
|
return _logger
|
|
108
274
|
|
|
109
275
|
|
|
110
|
-
def setup_logging(log_dir: Path | str, port: int | None = None) -> logging.Logger:
|
|
111
|
-
"""Attach the rotating file handler
|
|
276
|
+
def setup_logging(log_dir: Path | str, port: int | None = None) -> tuple[logging.Logger, Path]:
|
|
277
|
+
"""Attach the rotating file handler; return the logger and the directory used.
|
|
278
|
+
|
|
279
|
+
The requested `--log-dir` can be unusable even when it already exists — the
|
|
280
|
+
sandbox denies writes to a folder whose DACL looks writable (see
|
|
281
|
+
`pick_writable_dir`). The directory is therefore probed and handed back to
|
|
282
|
+
the caller, so the host can report where the log really is instead of echoing
|
|
283
|
+
the path it merely asked for. A missing service log is never worth failing
|
|
284
|
+
the whole start: when EVERY candidate refuses writes, `pick_writable_dir`
|
|
285
|
+
returns a deterministic path that the handler cannot open, so the failure is
|
|
286
|
+
swallowed and the service runs without a file log rather than never binding
|
|
287
|
+
its port.
|
|
288
|
+
"""
|
|
112
289
|
global _logger
|
|
113
|
-
directory =
|
|
114
|
-
directory.mkdir(parents=True, exist_ok=True)
|
|
290
|
+
directory = pick_writable_dir(*log_candidates(log_dir))
|
|
115
291
|
filename = f"workbench-{port}.log" if port is not None else "workbench.log"
|
|
116
|
-
handler = RotatingFileHandler(
|
|
117
|
-
directory / filename,
|
|
118
|
-
maxBytes=LOG_MAX_BYTES,
|
|
119
|
-
backupCount=LOG_BACKUP_COUNT,
|
|
120
|
-
encoding="utf-8",
|
|
121
|
-
)
|
|
122
|
-
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
|
123
292
|
logger = get_logger()
|
|
124
293
|
for existing in list(logger.handlers):
|
|
125
294
|
if isinstance(existing, RotatingFileHandler):
|
|
126
295
|
existing.close()
|
|
127
296
|
logger.removeHandler(existing)
|
|
128
|
-
logger.addHandler(handler)
|
|
129
297
|
logger.setLevel(logging.INFO)
|
|
130
|
-
|
|
298
|
+
try:
|
|
299
|
+
handler = RotatingFileHandler(
|
|
300
|
+
directory / filename,
|
|
301
|
+
maxBytes=LOG_MAX_BYTES,
|
|
302
|
+
backupCount=LOG_BACKUP_COUNT,
|
|
303
|
+
encoding="utf-8",
|
|
304
|
+
)
|
|
305
|
+
except OSError:
|
|
306
|
+
return logger, directory
|
|
307
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
|
308
|
+
logger.addHandler(handler)
|
|
309
|
+
return logger, directory
|
|
131
310
|
|
|
132
311
|
|
|
133
312
|
def sha256_bytes(content: bytes) -> str:
|
|
@@ -181,7 +360,7 @@ def vendor_from_cdn(base_url: str) -> dict[str, bytes]:
|
|
|
181
360
|
def write_vendor_cache(cache_dir: Path, files: dict[str, bytes]) -> None:
|
|
182
361
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
183
362
|
for name, content in files.items():
|
|
184
|
-
descriptor, temporary_name =
|
|
363
|
+
descriptor, temporary_name = make_temp_file(cache_dir, prefix=f".{name}.")
|
|
185
364
|
try:
|
|
186
365
|
with os.fdopen(descriptor, "wb") as output:
|
|
187
366
|
output.write(content)
|
|
@@ -465,10 +644,657 @@ def revision_of(content: str) -> str:
|
|
|
465
644
|
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
466
645
|
|
|
467
646
|
|
|
647
|
+
# ── Visual selection anchors ────────────────────────────────────────────────
|
|
648
|
+
#
|
|
649
|
+
# The agent edits the file on disk, so a selection is only useful if it can be
|
|
650
|
+
# translated into *source* coordinates. The canvas DOM is a GrapesJS runtime
|
|
651
|
+
# model and must never be treated as the authority here: the browser reports
|
|
652
|
+
# WHICH node the user picked, and this module re-parses the on-disk source to
|
|
653
|
+
# decide WHERE that node lives.
|
|
654
|
+
#
|
|
655
|
+
# Resolution is deliberately conservative. An anchor is only accepted when it
|
|
656
|
+
# maps to exactly one source node; zero and multiple matches are both refused
|
|
657
|
+
# so an ambiguous anchor can never reach the agent.
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def source_body(parser: SourceHtmlParser) -> HtmlNode | None:
|
|
661
|
+
return next((node for node in parser.nodes if node.tag == "body"), None)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def parse_document_tree(source: str) -> tuple[SourceHtmlParser, HtmlNode]:
|
|
665
|
+
parser = SourceHtmlParser(source)
|
|
666
|
+
try:
|
|
667
|
+
parser.feed(source)
|
|
668
|
+
parser.close()
|
|
669
|
+
except Exception as caught:
|
|
670
|
+
raise WorkbenchError("INVALID_DOCUMENT", f"HTML 解析失败:{caught}", 400) from caught
|
|
671
|
+
body = source_body(parser)
|
|
672
|
+
if body is None:
|
|
673
|
+
raise WorkbenchError("INVALID_DOCUMENT", "目标必须是包含 body 的完整 HTML 文档。", 400)
|
|
674
|
+
return parser, body
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def structural_children(node: HtmlNode) -> list[HtmlNode]:
|
|
678
|
+
"""Element children as the *editor* sees them.
|
|
679
|
+
|
|
680
|
+
`parse_source` strips body `<script>` elements before handing the document
|
|
681
|
+
to GrapesJS, so the canvas child indices skip them. The structural fallback
|
|
682
|
+
path must use the same numbering or every index after an inline script
|
|
683
|
+
would be off by one.
|
|
684
|
+
"""
|
|
685
|
+
return [child for child in node.children if child.tag != "script"]
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def node_path(node: HtmlNode, body: HtmlNode) -> list[int] | None:
|
|
689
|
+
path: list[int] = []
|
|
690
|
+
current = node
|
|
691
|
+
while current is not body:
|
|
692
|
+
parent = current.parent
|
|
693
|
+
if parent is None:
|
|
694
|
+
return None
|
|
695
|
+
siblings = structural_children(parent)
|
|
696
|
+
if current not in siblings:
|
|
697
|
+
return None
|
|
698
|
+
path.append(siblings.index(current))
|
|
699
|
+
current = parent
|
|
700
|
+
path.reverse()
|
|
701
|
+
return path
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def node_at_path(body: HtmlNode, path: list[int]) -> HtmlNode | None:
|
|
705
|
+
current = body
|
|
706
|
+
for index in path:
|
|
707
|
+
siblings = structural_children(current)
|
|
708
|
+
if not isinstance(index, int) or index < 0 or index >= len(siblings):
|
|
709
|
+
return None
|
|
710
|
+
current = siblings[index]
|
|
711
|
+
return current if current is not body else None
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def line_of(parser: SourceHtmlParser, offset: int) -> int:
|
|
715
|
+
return bisect.bisect_right(parser.line_offsets, offset)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def node_text(source: str, node: HtmlNode) -> str:
|
|
719
|
+
if node.end_start is None:
|
|
720
|
+
return ""
|
|
721
|
+
raw = source[node.start_end : node.end_start]
|
|
722
|
+
raw = re.sub(r"<script\b[\s\S]*?</script\s*>", " ", raw, flags=re.IGNORECASE)
|
|
723
|
+
raw = re.sub(r"<[^>]+>", " ", raw)
|
|
724
|
+
return re.sub(r"\s+", " ", html.unescape(raw)).strip()
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def identity_of(node: HtmlNode) -> dict[str, str] | None:
|
|
728
|
+
# Ordering does the work: `data-wb-id` is checked before `id`, so a random
|
|
729
|
+
# GrapesJS token only becomes the label when nothing better exists.
|
|
730
|
+
for name in IDENTITY_ATTRIBUTES:
|
|
731
|
+
value = node.attrs.get(name)
|
|
732
|
+
if value:
|
|
733
|
+
return {"name": name, "value": value}
|
|
734
|
+
return None
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def selector_of(node: HtmlNode) -> str:
|
|
738
|
+
identity = identity_of(node)
|
|
739
|
+
if identity is None:
|
|
740
|
+
classes = [item for item in node.attrs.get("class", "").split() if item]
|
|
741
|
+
return node.tag + "".join(f".{item}" for item in classes[:2])
|
|
742
|
+
if identity["name"] == "id":
|
|
743
|
+
return f"#{identity['value']}"
|
|
744
|
+
return f'[{identity["name"]}="{identity["value"]}"]'
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def normalized_text(value: str) -> str:
|
|
748
|
+
return re.sub(r"\s+", " ", value or "").strip()
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def resolve_anchor(parser: SourceHtmlParser, body: HtmlNode, descriptor: dict[str, Any]) -> HtmlNode:
|
|
752
|
+
"""Map a browser-side selection descriptor onto exactly one source node."""
|
|
753
|
+
if not isinstance(descriptor, dict):
|
|
754
|
+
raise WorkbenchError("INVALID_ANCHOR", "选区描述必须是对象。", 400)
|
|
755
|
+
|
|
756
|
+
expected_tag = str(descriptor.get("tag") or "").lower() or None
|
|
757
|
+
raw_path = descriptor.get("path")
|
|
758
|
+
path = [item for item in raw_path if isinstance(item, int)] if isinstance(raw_path, list) else None
|
|
759
|
+
if isinstance(raw_path, list) and path is not None and len(path) != len(raw_path):
|
|
760
|
+
path = None
|
|
761
|
+
text_hint = normalized_text(str(descriptor.get("textHint") or ""))
|
|
762
|
+
|
|
763
|
+
identity = descriptor.get("identity")
|
|
764
|
+
candidates: list[HtmlNode] = []
|
|
765
|
+
strategy = ""
|
|
766
|
+
|
|
767
|
+
if isinstance(identity, dict) and identity.get("name") in IDENTITY_ATTRIBUTES and identity.get("value"):
|
|
768
|
+
name = str(identity["name"])
|
|
769
|
+
value = str(identity["value"])
|
|
770
|
+
candidates = [
|
|
771
|
+
node for node in parser.nodes
|
|
772
|
+
if node.is_inside(body) and node.attrs.get(name) == value
|
|
773
|
+
]
|
|
774
|
+
strategy = "identity"
|
|
775
|
+
|
|
776
|
+
if len(candidates) != 1 and path is not None:
|
|
777
|
+
# Either the identity was absent or it was ambiguous; the structural
|
|
778
|
+
# path can both stand alone and disambiguate an identity collision.
|
|
779
|
+
located = node_at_path(body, path)
|
|
780
|
+
if located is not None:
|
|
781
|
+
if len(candidates) > 1 and located in candidates:
|
|
782
|
+
candidates = [located]
|
|
783
|
+
strategy = "identity+path"
|
|
784
|
+
elif not candidates:
|
|
785
|
+
candidates = [located]
|
|
786
|
+
strategy = "path"
|
|
787
|
+
|
|
788
|
+
if not candidates:
|
|
789
|
+
raise WorkbenchError(
|
|
790
|
+
"ANCHOR_NOT_FOUND",
|
|
791
|
+
"选中的元素在当前磁盘文件中已不存在,请重新选择。",
|
|
792
|
+
409,
|
|
793
|
+
)
|
|
794
|
+
if len(candidates) > 1:
|
|
795
|
+
raise WorkbenchError(
|
|
796
|
+
"ANCHOR_AMBIGUOUS",
|
|
797
|
+
f"选中的元素在源文件中匹配到 {len(candidates)} 个节点,无法唯一定位。",
|
|
798
|
+
409,
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
node = candidates[0]
|
|
802
|
+
if expected_tag and node.tag != expected_tag:
|
|
803
|
+
raise WorkbenchError(
|
|
804
|
+
"ANCHOR_NOT_FOUND",
|
|
805
|
+
f"定位到的节点是 <{node.tag}>,与选中的 <{expected_tag}> 不一致,请重新选择。",
|
|
806
|
+
409,
|
|
807
|
+
)
|
|
808
|
+
if text_hint and strategy in {"path", "identity+path"}:
|
|
809
|
+
# A path is only as good as the tree it was computed against. When the
|
|
810
|
+
# caller supplied visible text, require it to still overlap.
|
|
811
|
+
#
|
|
812
|
+
# Compare with whitespace REMOVED, not merely collapsed. `node_text`
|
|
813
|
+
# replaces each tag with a space, so `<h3>专业版</h3><p>¥99</p>` becomes
|
|
814
|
+
# "专业版 ¥99", while the browser's `textContent` yields "专业版¥99" — the
|
|
815
|
+
# same content, differing only where markup used to be. Comparing those
|
|
816
|
+
# literally rejects every correct anchor in tightly-written HTML.
|
|
817
|
+
actual = collapse_for_comparison(node_text(parser.source, node))
|
|
818
|
+
probe = collapse_for_comparison(text_hint)[:40]
|
|
819
|
+
if probe and probe not in actual and actual[:40] not in probe:
|
|
820
|
+
raise WorkbenchError(
|
|
821
|
+
"ANCHOR_NOT_FOUND",
|
|
822
|
+
"定位到的节点内容与选中的元素不一致,请重新选择。",
|
|
823
|
+
409,
|
|
824
|
+
)
|
|
825
|
+
return node
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def collapse_for_comparison(value: str) -> str:
|
|
829
|
+
return re.sub(r"\s+", "", value or "")
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def describe_anchor(parser: SourceHtmlParser, body: HtmlNode, node: HtmlNode) -> dict[str, Any]:
|
|
833
|
+
source = parser.source
|
|
834
|
+
end = node.end_end if node.end_end is not None else node.start_end
|
|
835
|
+
snippet = source[node.start : end]
|
|
836
|
+
truncated = len(snippet) > MAX_SNIPPET_CHARS
|
|
837
|
+
if truncated:
|
|
838
|
+
head = snippet[: MAX_SNIPPET_CHARS // 2].rstrip()
|
|
839
|
+
tail = snippet[-(MAX_SNIPPET_CHARS // 4) :].lstrip()
|
|
840
|
+
snippet = f"{head}\n<!-- … 省略 {len(source[node.start:end]) - len(head) - len(tail)} 字符 … -->\n{tail}"
|
|
841
|
+
|
|
842
|
+
ancestors: list[str] = []
|
|
843
|
+
current = node.parent
|
|
844
|
+
while current is not None and current is not body:
|
|
845
|
+
ancestors.append(selector_of(current))
|
|
846
|
+
current = current.parent
|
|
847
|
+
ancestors.reverse()
|
|
848
|
+
|
|
849
|
+
behavior = {
|
|
850
|
+
name: value for name, value in node.attrs.items()
|
|
851
|
+
if name in {"data-action", "data-target"} or name.lower().startswith("on")
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return {
|
|
855
|
+
"tag": node.tag,
|
|
856
|
+
"selector": selector_of(node),
|
|
857
|
+
"identity": identity_of(node),
|
|
858
|
+
"path": node_path(node, body),
|
|
859
|
+
"lineStart": line_of(parser, node.start),
|
|
860
|
+
"lineEnd": line_of(parser, max(node.start, end - 1)),
|
|
861
|
+
"startOffset": node.start,
|
|
862
|
+
"endOffset": end,
|
|
863
|
+
"text": node_text(source, node)[:240],
|
|
864
|
+
"classes": [item for item in node.attrs.get("class", "").split() if item],
|
|
865
|
+
"attributes": {name: value for name, value in node.attrs.items() if name != "class"},
|
|
866
|
+
"behavior": behavior,
|
|
867
|
+
"ancestors": ancestors,
|
|
868
|
+
"snippet": snippet,
|
|
869
|
+
"snippetTruncated": truncated,
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
# ── Related style and behaviour evidence ────────────────────────────────────
|
|
874
|
+
#
|
|
875
|
+
# "Make this purple" only needs the element and its CSS. "Improve this
|
|
876
|
+
# interaction" needs the JavaScript that drives it — and `parse_source` keeps
|
|
877
|
+
# body scripts out of the editable model, so that code has to be recovered from
|
|
878
|
+
# the raw source separately.
|
|
879
|
+
|
|
880
|
+
STYLE_BLOCK_PATTERN = re.compile(r"<style\b([^>]*)>([\s\S]*?)</style\s*>", re.IGNORECASE)
|
|
881
|
+
SCRIPT_BLOCK_PATTERN = re.compile(r"<script\b([^>]*)>([\s\S]*?)</script\s*>", re.IGNORECASE)
|
|
882
|
+
AT_RULE_PATTERN = re.compile(r"@[a-zA-Z-]+")
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def split_css_rules(css: str, base_offset: int) -> list[dict[str, Any]]:
|
|
886
|
+
"""Split a stylesheet into top-level rules, keeping source offsets.
|
|
887
|
+
|
|
888
|
+
A hand-rolled brace scanner rather than a regex: nested at-rules such as
|
|
889
|
+
`@media` contain their own blocks, and a flat regex would cut them in half.
|
|
890
|
+
"""
|
|
891
|
+
rules: list[dict[str, Any]] = []
|
|
892
|
+
depth = 0
|
|
893
|
+
start = 0
|
|
894
|
+
index = 0
|
|
895
|
+
length = len(css)
|
|
896
|
+
while index < length:
|
|
897
|
+
char = css[index]
|
|
898
|
+
if char == "/" and css.startswith("/*", index):
|
|
899
|
+
closing = css.find("*/", index + 2)
|
|
900
|
+
index = length if closing == -1 else closing + 2
|
|
901
|
+
continue
|
|
902
|
+
if char in {'"', "'"}:
|
|
903
|
+
quote = char
|
|
904
|
+
index += 1
|
|
905
|
+
while index < length and css[index] != quote:
|
|
906
|
+
index += 2 if css[index] == "\\" else 1
|
|
907
|
+
index += 1
|
|
908
|
+
continue
|
|
909
|
+
if char == "{":
|
|
910
|
+
if depth == 0:
|
|
911
|
+
prelude = css[start:index]
|
|
912
|
+
depth += 1
|
|
913
|
+
elif char == "}":
|
|
914
|
+
depth -= 1
|
|
915
|
+
if depth <= 0:
|
|
916
|
+
depth = 0
|
|
917
|
+
body = css[start : index + 1]
|
|
918
|
+
selector = normalized_text(re.sub(r"/\*[\s\S]*?\*/", " ", prelude))
|
|
919
|
+
if selector:
|
|
920
|
+
rules.append({
|
|
921
|
+
"selector": selector,
|
|
922
|
+
"text": body.strip(),
|
|
923
|
+
"start": base_offset + start,
|
|
924
|
+
"end": base_offset + index + 1,
|
|
925
|
+
})
|
|
926
|
+
start = index + 1
|
|
927
|
+
index += 1
|
|
928
|
+
return rules
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def document_css_rules(source: str) -> list[dict[str, Any]]:
|
|
932
|
+
rules: list[dict[str, Any]] = []
|
|
933
|
+
for match in STYLE_BLOCK_PATTERN.finditer(source):
|
|
934
|
+
if "data-grapesjs-overrides" in match.group(1):
|
|
935
|
+
continue
|
|
936
|
+
rules.extend(split_css_rules(match.group(2), match.start(2)))
|
|
937
|
+
return rules
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def selector_tokens(node: HtmlNode) -> set[str]:
|
|
941
|
+
tokens = {node.tag}
|
|
942
|
+
for item in node.attrs.get("class", "").split():
|
|
943
|
+
if item:
|
|
944
|
+
tokens.add(f".{item}")
|
|
945
|
+
identity = node.attrs.get("id")
|
|
946
|
+
if identity:
|
|
947
|
+
tokens.add(f"#{identity}")
|
|
948
|
+
# Attribute selectors, i.e. every identity attribute except `id`, which is
|
|
949
|
+
# already covered by the `#` form above.
|
|
950
|
+
for name in IDENTITY_ATTRIBUTES:
|
|
951
|
+
if name != "id" and node.attrs.get(name):
|
|
952
|
+
tokens.add(f"[{name}")
|
|
953
|
+
return tokens
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def related_css(parser: SourceHtmlParser, source: str, nodes: list[HtmlNode], rules: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
957
|
+
"""Rules whose selector mentions a class/id/tag carried by the selection.
|
|
958
|
+
|
|
959
|
+
Deliberately a *superset* heuristic. Missing a rule would make the agent
|
|
960
|
+
invent CSS that already exists; including one extra rule only costs a few
|
|
961
|
+
tokens. Bare tag names are ignored unless the element has no class or id,
|
|
962
|
+
because `p { }` matches almost everything.
|
|
963
|
+
"""
|
|
964
|
+
wanted: set[str] = set()
|
|
965
|
+
tag_only: set[str] = set()
|
|
966
|
+
for node in nodes:
|
|
967
|
+
tokens = selector_tokens(node)
|
|
968
|
+
specific = {token for token in tokens if token.startswith((".", "#", "["))}
|
|
969
|
+
if specific:
|
|
970
|
+
wanted |= specific
|
|
971
|
+
else:
|
|
972
|
+
tag_only.add(node.tag)
|
|
973
|
+
|
|
974
|
+
matched: list[dict[str, Any]] = []
|
|
975
|
+
for rule in rules:
|
|
976
|
+
selector = rule["selector"]
|
|
977
|
+
if AT_RULE_PATTERN.match(selector):
|
|
978
|
+
# Keep at-rules only when their body mentions a wanted token, so a
|
|
979
|
+
# responsive override for the selection still travels with it.
|
|
980
|
+
if not any(token in rule["text"] for token in wanted):
|
|
981
|
+
continue
|
|
982
|
+
elif not any(token in selector for token in wanted):
|
|
983
|
+
bare = {part.strip() for part in re.split(r"[\s,>+~]+", selector) if part.strip()}
|
|
984
|
+
if not (tag_only & bare):
|
|
985
|
+
continue
|
|
986
|
+
matched.append({
|
|
987
|
+
"selector": selector,
|
|
988
|
+
"lineStart": line_of(parser, rule["start"]),
|
|
989
|
+
"lineEnd": line_of(parser, max(rule["start"], rule["end"] - 1)),
|
|
990
|
+
"text": rule["text"] if len(rule["text"]) <= 600 else rule["text"][:600] + "\n /* … */\n}",
|
|
991
|
+
})
|
|
992
|
+
return matched[:14]
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def related_scripts(parser: SourceHtmlParser, source: str, nodes: list[HtmlNode]) -> list[dict[str, Any]]:
|
|
996
|
+
"""Script blocks that reference an identifier carried by the selection."""
|
|
997
|
+
needles: set[str] = set()
|
|
998
|
+
for node in nodes:
|
|
999
|
+
for name in IDENTITY_ATTRIBUTES:
|
|
1000
|
+
value = node.attrs.get(name)
|
|
1001
|
+
if value:
|
|
1002
|
+
needles.add(value)
|
|
1003
|
+
for name in ("data-action", "data-target"):
|
|
1004
|
+
value = node.attrs.get(name)
|
|
1005
|
+
if value:
|
|
1006
|
+
needles.add(value)
|
|
1007
|
+
for item in node.attrs.get("class", "").split():
|
|
1008
|
+
if item:
|
|
1009
|
+
needles.add(item)
|
|
1010
|
+
if not needles:
|
|
1011
|
+
return []
|
|
1012
|
+
|
|
1013
|
+
blocks: list[dict[str, Any]] = []
|
|
1014
|
+
for match in SCRIPT_BLOCK_PATTERN.finditer(source):
|
|
1015
|
+
code = match.group(2)
|
|
1016
|
+
if not code.strip():
|
|
1017
|
+
continue
|
|
1018
|
+
hits = sorted({needle for needle in needles if needle in code})
|
|
1019
|
+
if not hits:
|
|
1020
|
+
continue
|
|
1021
|
+
blocks.append({
|
|
1022
|
+
"lineStart": line_of(parser, match.start()),
|
|
1023
|
+
"lineEnd": line_of(parser, max(match.start(), match.end() - 1)),
|
|
1024
|
+
"matches": hits,
|
|
1025
|
+
"text": code.strip() if len(code.strip()) <= 2400 else code.strip()[:2400] + "\n/* … */",
|
|
1026
|
+
})
|
|
1027
|
+
return blocks[:3]
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
# ── Stable identity promotion ───────────────────────────────────────────────
|
|
1031
|
+
#
|
|
1032
|
+
# Most elements carry neither `id` nor `data-wb-id`, so their only anchor is a
|
|
1033
|
+
# structural path — which dies the moment the agent rewrites that subtree. On
|
|
1034
|
+
# selection the workbench therefore writes a readable `data-wb-id` into the
|
|
1035
|
+
# source. This is not pollution: `data-wb-id` is already the project's own
|
|
1036
|
+
# contract for addressing elements (see the editable-HTML guidelines), so
|
|
1037
|
+
# promoting a selection moves the page *towards* the spec rather than away.
|
|
1038
|
+
|
|
1039
|
+
STOPWORD_SLUGS = {"the", "and", "for", "with", "you", "your", "our", "this", "that", "from", "are", "was"}
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def slugify(value: str, fallback: str) -> str:
|
|
1043
|
+
ascii_value = re.sub(r"[^a-z0-9]+", "-", (value or "").lower()).strip("-")
|
|
1044
|
+
words = [word for word in ascii_value.split("-") if word and word not in STOPWORD_SLUGS]
|
|
1045
|
+
slug = "-".join(words[:4])[:40].strip("-")
|
|
1046
|
+
return slug or fallback
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def suggest_wb_id(node: HtmlNode, taken: set[str]) -> str:
|
|
1050
|
+
classes = [item for item in node.attrs.get("class", "").split() if item]
|
|
1051
|
+
seeds = [
|
|
1052
|
+
slugify(classes[0], "") if classes else "",
|
|
1053
|
+
slugify(node_text_seed(node), ""),
|
|
1054
|
+
node.tag,
|
|
1055
|
+
]
|
|
1056
|
+
base = next((seed for seed in seeds if seed), node.tag)
|
|
1057
|
+
if not base.startswith(node.tag) and base != node.tag:
|
|
1058
|
+
base = f"{base}"
|
|
1059
|
+
candidate = base
|
|
1060
|
+
counter = 2
|
|
1061
|
+
while candidate in taken:
|
|
1062
|
+
candidate = f"{base}-{counter}"
|
|
1063
|
+
counter += 1
|
|
1064
|
+
return candidate
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def node_text_seed(node: HtmlNode) -> str:
|
|
1068
|
+
for name in ("aria-label", "alt", "title", "name", "data-action"):
|
|
1069
|
+
value = node.attrs.get(name)
|
|
1070
|
+
if value:
|
|
1071
|
+
return value
|
|
1072
|
+
return ""
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def existing_wb_ids(parser: SourceHtmlParser) -> set[str]:
|
|
1076
|
+
taken: set[str] = set()
|
|
1077
|
+
for node in parser.nodes:
|
|
1078
|
+
for name in ("id", "data-wb-id"):
|
|
1079
|
+
value = node.attrs.get(name)
|
|
1080
|
+
if value:
|
|
1081
|
+
taken.add(value)
|
|
1082
|
+
return taken
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def promote_identities(target: Path, descriptors: list[dict[str, Any]], base_revision: str) -> dict[str, Any]:
|
|
1086
|
+
"""Give every anchorless selection a durable `data-wb-id`, atomically."""
|
|
1087
|
+
current = read_document(target)
|
|
1088
|
+
if base_revision and current["revision"] != base_revision:
|
|
1089
|
+
raise WorkbenchError("REVISION_CONFLICT", "磁盘文件已被外部修改,请重新加载后再试。", 409, document=current)
|
|
1090
|
+
|
|
1091
|
+
source = current["html"]
|
|
1092
|
+
parser, body = parse_document_tree(source)
|
|
1093
|
+
taken = existing_wb_ids(parser)
|
|
1094
|
+
insertions: list[tuple[int, str]] = []
|
|
1095
|
+
assigned: list[dict[str, Any]] = []
|
|
1096
|
+
|
|
1097
|
+
for descriptor in descriptors:
|
|
1098
|
+
node = resolve_anchor(parser, body, descriptor)
|
|
1099
|
+
identity = identity_of(node)
|
|
1100
|
+
if identity is not None:
|
|
1101
|
+
assigned.append({"identity": identity, "created": False})
|
|
1102
|
+
continue
|
|
1103
|
+
value = suggest_wb_id(node, taken)
|
|
1104
|
+
taken.add(value)
|
|
1105
|
+
# Insert just before the '>' that closes the start tag. Self-closing
|
|
1106
|
+
# tags keep their slash, so target the last character of the raw tag.
|
|
1107
|
+
raw = source[node.start : node.start_end]
|
|
1108
|
+
offset = node.start + len(raw.rstrip()[:-1].rstrip("/").rstrip()) if raw.endswith(">") else node.start_end - 1
|
|
1109
|
+
insertions.append((offset, f' data-wb-id="{html.escape(value, quote=True)}"'))
|
|
1110
|
+
assigned.append({"identity": {"name": "data-wb-id", "value": value}, "created": True})
|
|
1111
|
+
|
|
1112
|
+
if not insertions:
|
|
1113
|
+
return {"revision": current["revision"], "assigned": assigned, "changed": False}
|
|
1114
|
+
|
|
1115
|
+
updated = source
|
|
1116
|
+
for offset, text in sorted(insertions, key=lambda item: item[0], reverse=True):
|
|
1117
|
+
updated = updated[:offset] + text + updated[offset:]
|
|
1118
|
+
|
|
1119
|
+
latest = read_document(target)
|
|
1120
|
+
if latest["revision"] != current["revision"]:
|
|
1121
|
+
raise WorkbenchError("REVISION_CONFLICT", "写入期间文件发生外部修改,未覆盖最新版。", 409, document=latest)
|
|
1122
|
+
write_atomic(target, updated)
|
|
1123
|
+
return {"revision": revision_of(updated), "assigned": assigned, "changed": True}
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def write_atomic(target: Path, content: str) -> None:
|
|
1127
|
+
temporary_name: Path | None = None
|
|
1128
|
+
try:
|
|
1129
|
+
descriptor, temporary_name = make_temp_file(target.parent, prefix=f".{target.name}.workbench-", suffix=".tmp")
|
|
1130
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as output:
|
|
1131
|
+
output.write(content)
|
|
1132
|
+
output.flush()
|
|
1133
|
+
os.fsync(output.fileno())
|
|
1134
|
+
os.chmod(temporary_name, target.stat().st_mode)
|
|
1135
|
+
os.replace(temporary_name, target)
|
|
1136
|
+
finally:
|
|
1137
|
+
if temporary_name and os.path.exists(temporary_name):
|
|
1138
|
+
os.unlink(temporary_name)
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
# ── Context packet ──────────────────────────────────────────────────────────
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def build_selection_context(target: Path, descriptors: list[dict[str, Any]]) -> dict[str, Any]:
|
|
1145
|
+
if not isinstance(descriptors, list) or not descriptors:
|
|
1146
|
+
raise WorkbenchError("INVALID_ANCHOR", "请至少选择一个元素。", 400)
|
|
1147
|
+
if len(descriptors) > MAX_CONTEXT_SELECTIONS:
|
|
1148
|
+
raise WorkbenchError("INVALID_ANCHOR", f"一次最多添加 {MAX_CONTEXT_SELECTIONS} 个元素。", 400)
|
|
1149
|
+
|
|
1150
|
+
document = read_document(target)
|
|
1151
|
+
source = document["html"]
|
|
1152
|
+
parser, body = parse_document_tree(source)
|
|
1153
|
+
|
|
1154
|
+
nodes = [resolve_anchor(parser, body, descriptor) for descriptor in descriptors]
|
|
1155
|
+
# Drop selections already contained in another selection: sending both a
|
|
1156
|
+
# section and its heading duplicates the source and blurs the instruction.
|
|
1157
|
+
kept: list[HtmlNode] = []
|
|
1158
|
+
for node in nodes:
|
|
1159
|
+
if any(node is not other and node.is_inside(other) for other in nodes):
|
|
1160
|
+
continue
|
|
1161
|
+
if any(node is other for other in kept):
|
|
1162
|
+
continue
|
|
1163
|
+
kept.append(node)
|
|
1164
|
+
|
|
1165
|
+
selections = [describe_anchor(parser, body, node) for node in kept]
|
|
1166
|
+
rules = document_css_rules(source)
|
|
1167
|
+
payload = {
|
|
1168
|
+
"filePath": document["filePath"],
|
|
1169
|
+
"fileName": document["fileName"],
|
|
1170
|
+
"revision": document["revision"],
|
|
1171
|
+
"selections": selections,
|
|
1172
|
+
"css": related_css(parser, source, kept, rules),
|
|
1173
|
+
"scripts": related_scripts(parser, source, kept),
|
|
1174
|
+
"collapsed": len(nodes) - len(kept),
|
|
1175
|
+
}
|
|
1176
|
+
payload["markdown"] = render_context_markdown(payload)
|
|
1177
|
+
return payload
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def render_context_markdown(payload: dict[str, Any]) -> str:
|
|
1181
|
+
"""Render the packet the agent actually reads.
|
|
1182
|
+
|
|
1183
|
+
Optimised for an agent that cannot see the page: every selection leads with
|
|
1184
|
+
how to find it again (anchor + line range) and only then describes what it
|
|
1185
|
+
is. The literal source snippet is included so a string-replacing edit tool
|
|
1186
|
+
has an exact `old_string` to match.
|
|
1187
|
+
"""
|
|
1188
|
+
lines: list[str] = []
|
|
1189
|
+
lines.append("## 视觉选区(HTML Workbench)")
|
|
1190
|
+
lines.append("")
|
|
1191
|
+
lines.append(f"- 文件:`{payload['filePath']}`")
|
|
1192
|
+
lines.append(f"- revision:`{payload['revision'][:12]}`")
|
|
1193
|
+
lines.append(f"- 选中元素:{len(payload['selections'])} 个(编辑态框选)")
|
|
1194
|
+
lines.append("")
|
|
1195
|
+
lines.append("用户在页面上直接选中了下列元素。后续指令中的「这个 / 这里 / 这块」均指这些元素。")
|
|
1196
|
+
lines.append("")
|
|
1197
|
+
|
|
1198
|
+
for index, item in enumerate(payload["selections"], start=1):
|
|
1199
|
+
marker = f"### {index}. `{item['selector']}`"
|
|
1200
|
+
lines.append(marker)
|
|
1201
|
+
lines.append("")
|
|
1202
|
+
identity = item.get("identity")
|
|
1203
|
+
if identity:
|
|
1204
|
+
lines.append(f"- 稳定锚点:`[{identity['name']}=\"{identity['value']}\"]`")
|
|
1205
|
+
else:
|
|
1206
|
+
lines.append("- 稳定锚点:无(该元素没有 id / data-wb-id)")
|
|
1207
|
+
lines.append(f"- 源码位置:第 {item['lineStart']}–{item['lineEnd']} 行")
|
|
1208
|
+
if item["ancestors"]:
|
|
1209
|
+
lines.append(f"- 层级:`{' > '.join(item['ancestors'][-4:])} > {item['selector']}`")
|
|
1210
|
+
if item["text"]:
|
|
1211
|
+
lines.append(f"- 可见文本:{item['text']}")
|
|
1212
|
+
if item["behavior"]:
|
|
1213
|
+
behavior = " ".join(f'{name}="{value}"' for name, value in item["behavior"].items())
|
|
1214
|
+
lines.append(f"- 行为属性:`{behavior}`")
|
|
1215
|
+
lines.append("")
|
|
1216
|
+
lines.append("```html")
|
|
1217
|
+
lines.append(item["snippet"])
|
|
1218
|
+
lines.append("```")
|
|
1219
|
+
lines.append("")
|
|
1220
|
+
|
|
1221
|
+
if payload["css"]:
|
|
1222
|
+
lines.append("### 关联样式规则")
|
|
1223
|
+
lines.append("")
|
|
1224
|
+
for rule in payload["css"]:
|
|
1225
|
+
lines.append(f"`{rule['selector']}` — 第 {rule['lineStart']}–{rule['lineEnd']} 行")
|
|
1226
|
+
lines.append("")
|
|
1227
|
+
lines.append("```css")
|
|
1228
|
+
lines.append(rule["text"])
|
|
1229
|
+
lines.append("```")
|
|
1230
|
+
lines.append("")
|
|
1231
|
+
|
|
1232
|
+
if payload["scripts"]:
|
|
1233
|
+
lines.append("### 关联脚本")
|
|
1234
|
+
lines.append("")
|
|
1235
|
+
for block in payload["scripts"]:
|
|
1236
|
+
hits = "、".join(f"`{item}`" for item in block["matches"][:6])
|
|
1237
|
+
lines.append(f"第 {block['lineStart']}–{block['lineEnd']} 行,引用了 {hits}")
|
|
1238
|
+
lines.append("")
|
|
1239
|
+
lines.append("```js")
|
|
1240
|
+
lines.append(block["text"])
|
|
1241
|
+
lines.append("```")
|
|
1242
|
+
lines.append("")
|
|
1243
|
+
|
|
1244
|
+
lines.append("### 修改约束")
|
|
1245
|
+
lines.append("")
|
|
1246
|
+
lines.append("- 只修改上述选区及其关联样式 / 脚本区间,页面其余部分保持不变。")
|
|
1247
|
+
lines.append("- 优先用稳定锚点定位;不要依赖 `nth-child`、兄弟顺序或行号硬编码。")
|
|
1248
|
+
lines.append("- 编辑前请重新读取该文件:用户可能在此期间继续做了可视化修改。")
|
|
1249
|
+
lines.append("")
|
|
1250
|
+
|
|
1251
|
+
markdown = "\n".join(lines)
|
|
1252
|
+
if len(markdown.encode("utf-8")) > MAX_CONTEXT_BYTES:
|
|
1253
|
+
encoded = markdown.encode("utf-8")[:MAX_CONTEXT_BYTES]
|
|
1254
|
+
markdown = encoded.decode("utf-8", "ignore") + "\n\n<!-- 上下文过长,已截断 -->\n"
|
|
1255
|
+
return markdown
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def normalize_file_reference(raw_file: str) -> str:
|
|
1262
|
+
"""Turn a browser `file://` URL into the platform's local path string.
|
|
1263
|
+
|
|
1264
|
+
The address bar accepts both a normal filesystem path and a URL copied from
|
|
1265
|
+
Finder/Explorer/browser. `Path('file:///…%E4…')` is not a URL parser: it
|
|
1266
|
+
keeps the scheme and percent escapes literally, which made a valid existing
|
|
1267
|
+
file look missing. Decode it here, at the service boundary shared by every
|
|
1268
|
+
API and CLI command.
|
|
1269
|
+
"""
|
|
1270
|
+
value = raw_file.strip()
|
|
1271
|
+
parsed = urllib.parse.urlsplit(value)
|
|
1272
|
+
if parsed.scheme.lower() != "file":
|
|
1273
|
+
return value
|
|
1274
|
+
if parsed.query or parsed.fragment:
|
|
1275
|
+
raise WorkbenchError("INVALID_FILE_URL", "file:// 地址不能包含 query 或 hash。", 400)
|
|
1276
|
+
|
|
1277
|
+
host = parsed.netloc.lower()
|
|
1278
|
+
path = urllib.parse.unquote(parsed.path)
|
|
1279
|
+
if host not in {"", "localhost"}:
|
|
1280
|
+
if os.name != "nt":
|
|
1281
|
+
raise WorkbenchError("INVALID_FILE_URL", "当前系统只支持本机 file:// 地址。", 400)
|
|
1282
|
+
# file://server/share/page.html → \\server\share\page.html on Windows.
|
|
1283
|
+
path = f"//{parsed.netloc}{path}"
|
|
1284
|
+
elif os.name == "nt" and re.match(r"^/[A-Za-z]:/", path):
|
|
1285
|
+
# URL syntax requires a leading slash before a Windows drive: file:///C:/…
|
|
1286
|
+
path = path[1:]
|
|
1287
|
+
|
|
1288
|
+
if not path:
|
|
1289
|
+
raise WorkbenchError("INVALID_FILE_URL", "file:// 地址未包含文件路径。", 400)
|
|
1290
|
+
return path
|
|
1291
|
+
|
|
1292
|
+
|
|
468
1293
|
def resolve_html_file(raw_file: str) -> Path:
|
|
469
1294
|
if not raw_file.strip():
|
|
470
1295
|
raise WorkbenchError("FILE_REQUIRED", "请通过 file 参数指定 HTML 文件。", 400)
|
|
471
|
-
|
|
1296
|
+
normalized = normalize_file_reference(raw_file)
|
|
1297
|
+
requested = Path(normalized).expanduser()
|
|
472
1298
|
try:
|
|
473
1299
|
target = requested.resolve(strict=True)
|
|
474
1300
|
except FileNotFoundError as caught:
|
|
@@ -547,7 +1373,7 @@ def save_document(target: Path, body: dict[str, Any]) -> dict[str, Any]:
|
|
|
547
1373
|
if latest["revision"] != base_revision:
|
|
548
1374
|
raise WorkbenchError("REVISION_CONFLICT", "保存期间文件发生外部修改,未覆盖最新版。", 409, document=latest)
|
|
549
1375
|
|
|
550
|
-
descriptor, temporary_name =
|
|
1376
|
+
descriptor, temporary_name = make_temp_file(target.parent, prefix=f".{target.name}.workbench-", suffix=".tmp")
|
|
551
1377
|
try:
|
|
552
1378
|
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as output:
|
|
553
1379
|
output.write(source)
|
|
@@ -565,11 +1391,24 @@ class WorkbenchServer(ThreadingHTTPServer):
|
|
|
565
1391
|
daemon_threads = True
|
|
566
1392
|
allow_reuse_address = True
|
|
567
1393
|
|
|
568
|
-
def __init__(
|
|
1394
|
+
def __init__(
|
|
1395
|
+
self,
|
|
1396
|
+
address: tuple[str, int],
|
|
1397
|
+
asset_file: Path,
|
|
1398
|
+
editor_root: Path,
|
|
1399
|
+
vendor_files: dict[str, Path],
|
|
1400
|
+
log_dir: Path | None = None,
|
|
1401
|
+
vendor_cache: Path | None = None,
|
|
1402
|
+
) -> None:
|
|
569
1403
|
self.asset_file = asset_file.resolve(strict=True)
|
|
570
1404
|
self.editor_root = editor_root.resolve(strict=True)
|
|
571
1405
|
self.vendor_files = {name: path.resolve(strict=True) for name, path in vendor_files.items()}
|
|
572
1406
|
self.vendor_routes = {str(metadata["route"]): name for name, metadata in VENDOR_ASSETS.items()}
|
|
1407
|
+
# Advertised by /api/health so the host can report the directories actually
|
|
1408
|
+
# in use — including when it REUSES a service it did not start and has no
|
|
1409
|
+
# startup output to read them from.
|
|
1410
|
+
self.log_dir = Path(log_dir) if log_dir is not None else None
|
|
1411
|
+
self.vendor_cache = Path(vendor_cache) if vendor_cache is not None else None
|
|
573
1412
|
super().__init__(address, WorkbenchHandler)
|
|
574
1413
|
|
|
575
1414
|
|
|
@@ -616,14 +1455,23 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
|
|
|
616
1455
|
query = urllib.parse.parse_qs(parsed.query)
|
|
617
1456
|
try:
|
|
618
1457
|
if parsed.path == "/api/health":
|
|
619
|
-
|
|
1458
|
+
payload: dict[str, Any] = {
|
|
620
1459
|
"ok": True,
|
|
621
1460
|
"service": SERVICE_NAME,
|
|
622
1461
|
"version": SERVICE_VERSION,
|
|
623
1462
|
"port": self.server.server_port,
|
|
624
1463
|
"editorRoot": str(self.server.editor_root),
|
|
625
|
-
"capabilities":
|
|
626
|
-
}
|
|
1464
|
+
"capabilities": list(SERVICE_CAPABILITIES),
|
|
1465
|
+
}
|
|
1466
|
+
# Report where this process really writes. The host shows these in
|
|
1467
|
+
# its diagnostics, so a REUSED service describes its own
|
|
1468
|
+
# directories instead of the ones the caller asked for — which may
|
|
1469
|
+
# be exactly what the sandbox refused.
|
|
1470
|
+
if self.server.log_dir is not None:
|
|
1471
|
+
payload["logDir"] = str(self.server.log_dir)
|
|
1472
|
+
if self.server.vendor_cache is not None:
|
|
1473
|
+
payload["vendorCache"] = str(self.server.vendor_cache)
|
|
1474
|
+
self.send_json(200, payload)
|
|
627
1475
|
return
|
|
628
1476
|
if parsed.path == "/api/document":
|
|
629
1477
|
self.send_json(200, read_document(self.query_file(query)))
|
|
@@ -651,10 +1499,7 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
|
|
|
651
1499
|
self.send_json(404, {"error": "NOT_FOUND", "message": "接口不存在。"})
|
|
652
1500
|
return
|
|
653
1501
|
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"))
|
|
1502
|
+
body = self.read_json_body()
|
|
658
1503
|
query = urllib.parse.parse_qs(parsed.query)
|
|
659
1504
|
self.send_json(200, save_document(self.query_file(query), body))
|
|
660
1505
|
except json.JSONDecodeError as caught:
|
|
@@ -662,6 +1507,46 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
|
|
|
662
1507
|
except Exception as caught:
|
|
663
1508
|
self.send_error_payload(caught)
|
|
664
1509
|
|
|
1510
|
+
def do_POST(self) -> None:
|
|
1511
|
+
parsed = urllib.parse.urlsplit(self.path)
|
|
1512
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
1513
|
+
try:
|
|
1514
|
+
if parsed.path == "/api/shutdown":
|
|
1515
|
+
# Lets a newer `open` retire this process cleanly. The service
|
|
1516
|
+
# holds no state of its own, so exiting is always safe.
|
|
1517
|
+
self.send_json(200, {"ok": True, "stopping": True})
|
|
1518
|
+
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
1519
|
+
return
|
|
1520
|
+
if parsed.path not in {"/api/context", "/api/anchor"}:
|
|
1521
|
+
self.send_json(404, {"error": "NOT_FOUND", "message": "接口不存在。"})
|
|
1522
|
+
return
|
|
1523
|
+
body = self.read_json_body()
|
|
1524
|
+
target = self.query_file(query)
|
|
1525
|
+
selections = body.get("selections")
|
|
1526
|
+
if parsed.path == "/api/anchor":
|
|
1527
|
+
# Identity promotion mutates the file, so it is a separate,
|
|
1528
|
+
# explicitly revision-checked step ahead of context building.
|
|
1529
|
+
if not isinstance(selections, list) or not selections:
|
|
1530
|
+
raise WorkbenchError("INVALID_ANCHOR", "请至少选择一个元素。", 400)
|
|
1531
|
+
if len(selections) > MAX_CONTEXT_SELECTIONS:
|
|
1532
|
+
raise WorkbenchError("INVALID_ANCHOR", f"一次最多添加 {MAX_CONTEXT_SELECTIONS} 个元素。", 400)
|
|
1533
|
+
self.send_json(200, promote_identities(target, selections, str(body.get("baseRevision") or "")))
|
|
1534
|
+
return
|
|
1535
|
+
self.send_json(200, build_selection_context(target, selections))
|
|
1536
|
+
except json.JSONDecodeError as caught:
|
|
1537
|
+
self.send_error_payload(WorkbenchError("INVALID_ANCHOR", f"请求不是有效 JSON:{caught}", 400))
|
|
1538
|
+
except Exception as caught:
|
|
1539
|
+
self.send_error_payload(caught)
|
|
1540
|
+
|
|
1541
|
+
def read_json_body(self) -> dict[str, Any]:
|
|
1542
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
1543
|
+
if length <= 0 or length > MAX_REQUEST_BYTES:
|
|
1544
|
+
raise WorkbenchError("INVALID_SAVE", "请求大小无效。", 400)
|
|
1545
|
+
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
|
1546
|
+
if not isinstance(payload, dict):
|
|
1547
|
+
raise WorkbenchError("INVALID_SAVE", "请求体必须是 JSON 对象。", 400)
|
|
1548
|
+
return payload
|
|
1549
|
+
|
|
665
1550
|
def send_asset(self, route: str) -> None:
|
|
666
1551
|
parts = route.split("/", 3)
|
|
667
1552
|
if len(parts) < 4:
|
|
@@ -710,10 +1595,120 @@ def health(port: int, timeout: float = 0.5) -> dict[str, Any] | None:
|
|
|
710
1595
|
return None
|
|
711
1596
|
|
|
712
1597
|
|
|
713
|
-
def
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1598
|
+
def is_reusable(payload: dict[str, Any] | None) -> bool:
|
|
1599
|
+
"""Whether a live service speaks the same API as this script.
|
|
1600
|
+
|
|
1601
|
+
A process serves the code it was started with, but serves assets from disk.
|
|
1602
|
+
So an old process + new assets is the worst combination: the UI offers
|
|
1603
|
+
features whose routes answer 501. Require every capability this script
|
|
1604
|
+
advertises before reusing.
|
|
1605
|
+
"""
|
|
1606
|
+
if not payload:
|
|
1607
|
+
return False
|
|
1608
|
+
advertised = payload.get("capabilities")
|
|
1609
|
+
if not isinstance(advertised, list):
|
|
1610
|
+
return False
|
|
1611
|
+
return set(SERVICE_CAPABILITIES).issubset({str(item) for item in advertised})
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
def stop_service(port: int, timeout: float = 6.0) -> bool:
|
|
1615
|
+
"""Ask an outdated service to exit, then free its TCP listener.
|
|
1616
|
+
|
|
1617
|
+
Before 2.1.0 there was no shutdown route. Also, a failed/foreign listener
|
|
1618
|
+
can hold the port without answering health: a health failure is *not* proof
|
|
1619
|
+
that binding will succeed. We therefore wait for the actual listener to go
|
|
1620
|
+
away, and use the native process terminator on each platform as a fallback.
|
|
1621
|
+
"""
|
|
1622
|
+
try:
|
|
1623
|
+
request = urllib.request.Request(f"http://127.0.0.1:{port}/api/shutdown", method="POST")
|
|
1624
|
+
urllib.request.urlopen(request, timeout=timeout).close()
|
|
1625
|
+
except (OSError, ValueError, urllib.error.URLError):
|
|
1626
|
+
pass
|
|
1627
|
+
if wait_for_port_free(port, 2.0):
|
|
1628
|
+
return True
|
|
1629
|
+
for pid in listeners_on_port(port):
|
|
1630
|
+
terminate_process(pid)
|
|
1631
|
+
return wait_for_port_free(port, timeout)
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
def wait_for_port_free(port: int, timeout: float) -> bool:
|
|
1635
|
+
"""Wait until no process is listening, including unhealthy listeners."""
|
|
1636
|
+
deadline = time.monotonic() + timeout
|
|
1637
|
+
while time.monotonic() < deadline:
|
|
1638
|
+
if not listeners_on_port(port):
|
|
1639
|
+
return True
|
|
1640
|
+
time.sleep(0.1)
|
|
1641
|
+
return not listeners_on_port(port)
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def listeners_on_port(port: int) -> list[int]:
|
|
1645
|
+
"""Return PIDs listening on a local TCP port, best-effort on every OS.
|
|
1646
|
+
|
|
1647
|
+
macOS/Linux use lsof. Windows ships `netstat` and exposes the PID in its
|
|
1648
|
+
final column, avoiding an optional Python dependency such as psutil.
|
|
1649
|
+
"""
|
|
1650
|
+
try:
|
|
1651
|
+
if os.name == "nt":
|
|
1652
|
+
result = subprocess.run(
|
|
1653
|
+
["netstat", "-ano", "-p", "tcp"],
|
|
1654
|
+
capture_output=True, text=True, timeout=5,
|
|
1655
|
+
)
|
|
1656
|
+
pids = []
|
|
1657
|
+
for line in result.stdout.splitlines():
|
|
1658
|
+
columns = line.split()
|
|
1659
|
+
# TCP 127.0.0.1:4317 0.0.0.0:0 LISTENING 1234
|
|
1660
|
+
if len(columns) < 5 or columns[0].upper() != "TCP" or columns[3].upper() != "LISTENING":
|
|
1661
|
+
continue
|
|
1662
|
+
local = columns[1].rsplit(":", 1)
|
|
1663
|
+
if len(local) != 2 or local[1] != str(port):
|
|
1664
|
+
continue
|
|
1665
|
+
try:
|
|
1666
|
+
pid = int(columns[-1])
|
|
1667
|
+
except ValueError:
|
|
1668
|
+
continue
|
|
1669
|
+
if pid != os.getpid():
|
|
1670
|
+
pids.append(pid)
|
|
1671
|
+
return pids
|
|
1672
|
+
result = subprocess.run(
|
|
1673
|
+
["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
|
|
1674
|
+
capture_output=True, text=True, timeout=5,
|
|
1675
|
+
)
|
|
1676
|
+
except (OSError, subprocess.SubprocessError):
|
|
1677
|
+
return []
|
|
1678
|
+
pids = []
|
|
1679
|
+
for line in result.stdout.split():
|
|
1680
|
+
try:
|
|
1681
|
+
pid = int(line)
|
|
1682
|
+
except ValueError:
|
|
1683
|
+
continue
|
|
1684
|
+
if pid != os.getpid():
|
|
1685
|
+
pids.append(pid)
|
|
1686
|
+
return pids
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
def terminate_process(pid: int) -> None:
|
|
1690
|
+
"""Best-effort process-tree termination on the current platform."""
|
|
1691
|
+
try:
|
|
1692
|
+
if os.name == "nt":
|
|
1693
|
+
subprocess.run(
|
|
1694
|
+
["taskkill", "/PID", str(pid), "/T", "/F"],
|
|
1695
|
+
capture_output=True, text=True, timeout=5,
|
|
1696
|
+
)
|
|
1697
|
+
else:
|
|
1698
|
+
os.kill(pid, signal.SIGTERM)
|
|
1699
|
+
except (OSError, subprocess.SubprocessError):
|
|
1700
|
+
pass
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
def start_detached(script_file: Path, port: int, editor_root: Path, asset_file: Path | None, vendor_cache: Path, log_dir: Path) -> tuple[int, Path | None]:
|
|
1704
|
+
"""Spawn a detached `serve` and return (pid, path of its captured output).
|
|
1705
|
+
|
|
1706
|
+
The path is None when no candidate directory accepted the file: a service
|
|
1707
|
+
whose console output cannot be captured must still start, so the output is
|
|
1708
|
+
discarded rather than the spawn abandoned (see `setup_logging`).
|
|
1709
|
+
"""
|
|
1710
|
+
directory = pick_writable_dir(*log_candidates(log_dir))
|
|
1711
|
+
log_file: Path | None = directory / f"html-workbench-{port}.log"
|
|
717
1712
|
command = [sys.executable, str(script_file), "serve", "--port", str(port), "--editor-root", str(editor_root), "--vendor-cache", str(vendor_cache), "--log-dir", str(directory)]
|
|
718
1713
|
if asset_file is not None:
|
|
719
1714
|
command.extend(["--asset", str(asset_file)])
|
|
@@ -723,19 +1718,45 @@ def start_detached(script_file: Path, port: int, editor_root: Path, asset_file:
|
|
|
723
1718
|
flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS # type: ignore[attr-defined]
|
|
724
1719
|
kwargs["creationflags"] = flags
|
|
725
1720
|
kwargs.pop("start_new_session", None)
|
|
726
|
-
|
|
727
|
-
|
|
1721
|
+
try:
|
|
1722
|
+
log = log_file.open("ab")
|
|
1723
|
+
except OSError:
|
|
1724
|
+
log_file = None
|
|
1725
|
+
log = None
|
|
1726
|
+
try:
|
|
1727
|
+
process = subprocess.Popen(
|
|
1728
|
+
command,
|
|
1729
|
+
stdout=log if log is not None else subprocess.DEVNULL,
|
|
1730
|
+
stderr=subprocess.STDOUT,
|
|
1731
|
+
close_fds=True,
|
|
1732
|
+
**kwargs,
|
|
1733
|
+
)
|
|
1734
|
+
finally:
|
|
1735
|
+
if log is not None:
|
|
1736
|
+
log.close()
|
|
728
1737
|
return process.pid, log_file
|
|
729
1738
|
|
|
730
1739
|
|
|
731
1740
|
def command_serve(args: argparse.Namespace, script_file: Path) -> int:
|
|
732
|
-
logger = setup_logging(args.log_dir, args.port)
|
|
1741
|
+
logger, log_directory = setup_logging(args.log_dir, args.port)
|
|
733
1742
|
asset_file = Path(args.asset).expanduser().resolve(strict=True) if args.asset else default_asset_file(script_file)
|
|
734
1743
|
editor_root = Path(args.editor_root).expanduser().resolve(strict=True)
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
1744
|
+
# Logs and cache are both disposable, so they follow the writable location the
|
|
1745
|
+
# sandbox really granted instead of the path the parent requested.
|
|
1746
|
+
vendor_cache = pick_writable_dir(*vendor_candidates(args.vendor_cache, log_directory))
|
|
1747
|
+
vendor_files = ensure_vendor_assets(vendor_cache)
|
|
1748
|
+
server = WorkbenchServer(("127.0.0.1", args.port), asset_file, editor_root, vendor_files, log_directory, vendor_cache)
|
|
1749
|
+
logger.info("service started: port=%s editorRoot=%s logDir=%s vendorCache=%s", args.port, editor_root, log_directory, vendor_cache)
|
|
1750
|
+
# The host surfaces these two paths in its diagnostics, so the panel shows the
|
|
1751
|
+
# directories actually in use rather than echoing the ones it asked for — the
|
|
1752
|
+
# requested ones may be exactly what the sandbox refused.
|
|
1753
|
+
print(json.dumps({
|
|
1754
|
+
"ok": True,
|
|
1755
|
+
"url": f"http://127.0.0.1:{args.port}",
|
|
1756
|
+
"editorRoot": str(editor_root),
|
|
1757
|
+
"logDir": str(log_directory),
|
|
1758
|
+
"vendorCache": str(vendor_cache),
|
|
1759
|
+
}, ensure_ascii=False), flush=True)
|
|
739
1760
|
try:
|
|
740
1761
|
server.serve_forever(poll_interval=0.25)
|
|
741
1762
|
except KeyboardInterrupt:
|
|
@@ -751,6 +1772,17 @@ def command_open(args: argparse.Namespace, script_file: Path) -> int:
|
|
|
751
1772
|
editor_root = Path(args.editor_root).expanduser().resolve(strict=True)
|
|
752
1773
|
resolve_html_file(str(target))
|
|
753
1774
|
existing = health(args.port)
|
|
1775
|
+
if existing is not None and not is_reusable(existing):
|
|
1776
|
+
# Same port, older code: retire it rather than serve a new UI against a
|
|
1777
|
+
# stale API. Nothing is lost — the service keeps no state of its own.
|
|
1778
|
+
stop_service(args.port)
|
|
1779
|
+
existing = health(args.port)
|
|
1780
|
+
if existing is not None:
|
|
1781
|
+
raise WorkbenchError(
|
|
1782
|
+
"SERVER_OUTDATED",
|
|
1783
|
+
f"端口 {args.port} 上有旧版服务且无法自动停止,请手动结束该进程后重试。",
|
|
1784
|
+
500,
|
|
1785
|
+
)
|
|
754
1786
|
reused = existing is not None
|
|
755
1787
|
pid: int | None = None
|
|
756
1788
|
log_file: Path | None = None
|
|
@@ -763,7 +1795,8 @@ def command_open(args: argparse.Namespace, script_file: Path) -> int:
|
|
|
763
1795
|
break
|
|
764
1796
|
time.sleep(0.1)
|
|
765
1797
|
else:
|
|
766
|
-
|
|
1798
|
+
where = f",请查看日志:{log_file}" if log_file is not None else "(没有可写的日志目录,输出已丢弃)"
|
|
1799
|
+
raise WorkbenchError("SERVER_UNAVAILABLE", f"服务启动失败{where}", 500)
|
|
767
1800
|
query = urllib.parse.urlencode({"file": str(target)})
|
|
768
1801
|
print(json.dumps({
|
|
769
1802
|
"ok": True,
|
|
@@ -796,9 +1829,28 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
796
1829
|
|
|
797
1830
|
health_command = subparsers.add_parser("health", help="check the local service")
|
|
798
1831
|
health_command.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
1832
|
+
|
|
1833
|
+
stop_command = subparsers.add_parser("stop", help="retire whatever service holds the port")
|
|
1834
|
+
stop_command.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
799
1835
|
return parser
|
|
800
1836
|
|
|
801
1837
|
|
|
1838
|
+
def command_stop(args: argparse.Namespace) -> int:
|
|
1839
|
+
"""Free the port so a fresh service can bind it.
|
|
1840
|
+
|
|
1841
|
+
Callers use this to recover from `PORT_IN_USE`, where the listener is a
|
|
1842
|
+
process they do not own (a leftover from an earlier run, or an outdated
|
|
1843
|
+
version). Succeeding when nothing is listening keeps it safe to call blindly.
|
|
1844
|
+
"""
|
|
1845
|
+
if health(args.port) is None and not listeners_on_port(args.port):
|
|
1846
|
+
print(json.dumps({"ok": True, "port": args.port, "stopped": False, "reason": "NOTHING_LISTENING"}, ensure_ascii=False))
|
|
1847
|
+
return 0
|
|
1848
|
+
if stop_service(args.port):
|
|
1849
|
+
print(json.dumps({"ok": True, "port": args.port, "stopped": True}, ensure_ascii=False))
|
|
1850
|
+
return 0
|
|
1851
|
+
raise WorkbenchError("SERVER_OUTDATED", f"端口 {args.port} 上的进程无法停止,请手动结束后重试。", 500)
|
|
1852
|
+
|
|
1853
|
+
|
|
802
1854
|
def main() -> int:
|
|
803
1855
|
if sys.version_info < (3, 9):
|
|
804
1856
|
print(json.dumps({"ok": False, "error": "PYTHON_TOO_OLD", "message": "需要 Python 3.9 或更高版本。"}, ensure_ascii=False), file=sys.stderr)
|
|
@@ -810,6 +1862,8 @@ def main() -> int:
|
|
|
810
1862
|
return command_serve(args, script_file)
|
|
811
1863
|
if args.command == "open":
|
|
812
1864
|
return command_open(args, script_file)
|
|
1865
|
+
if args.command == "stop":
|
|
1866
|
+
return command_stop(args)
|
|
813
1867
|
payload = health(args.port)
|
|
814
1868
|
print(json.dumps(payload or {"ok": False, "error": "SERVER_UNAVAILABLE"}, ensure_ascii=False))
|
|
815
1869
|
return 0 if payload else 1
|