@vibe-x/dsh-html-workbench 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -88,7 +88,10 @@ body[data-hwb-dragging] { user-select: none; }
88
88
  .hwb-item-btn { border: 1px solid var(--dsw-alias-border-l2); background: transparent; color: var(--dsw-alias-label-secondary); border-radius: 6px; padding: 2px 9px; cursor: pointer; font-size: 12px; flex: none; }
89
89
  .hwb-item-btn:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
90
90
  .hwb-dockempty { padding: 14px; color: var(--dsw-alias-label-tertiary); text-align: center; font-size: 12px; }
91
- .hwb-trigger { position: fixed; top: 10px; right: 12px; z-index: 10000; width: 36px; height: 36px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; pointer-events: auto; }
91
+ /* Keep the trigger below the DSH session header's title row (~44px tall: 12px
92
+ padding + 32px min-height) so it never covers the right-aligned header
93
+ utilities — e.g. the "Session Log" button — when a session is open. */
94
+ .hwb-trigger { position: fixed; top: 56px; right: 12px; z-index: 10000; width: 36px; height: 36px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; pointer-events: auto; }
92
95
  .hwb-trigger:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
93
96
  `)
94
97
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-x/dsh-html-workbench",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "DeepSeek Harness 插件:在右侧栏预览并可视化编辑 agent 生成的 HTML(复用/拉起本地 html-workbench 服务)。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,6 +9,7 @@ import hashlib
9
9
  import html
10
10
  import io
11
11
  import json
12
+ import logging
12
13
  import mimetypes
13
14
  import os
14
15
  import re
@@ -22,6 +23,7 @@ import urllib.parse
22
23
  import urllib.request
23
24
  from dataclasses import dataclass, field
24
25
  from html.parser import HTMLParser
26
+ from logging.handlers import RotatingFileHandler
25
27
  from http import HTTPStatus
26
28
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
27
29
  from pathlib import Path
@@ -74,6 +76,60 @@ def default_vendor_cache() -> Path:
74
76
  return base / "html-workbench" / "vendor" / "grapesjs" / GRAPESJS_VERSION
75
77
 
76
78
 
79
+ LOG_MAX_BYTES = 1_000_000
80
+ LOG_BACKUP_COUNT = 5
81
+
82
+
83
+ def default_log_dir() -> Path:
84
+ """Fixed per-user log directory (stable across runs; survives tmp cleanup)."""
85
+ if os.name == "nt":
86
+ base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
87
+ else:
88
+ base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
89
+ return base / "html-workbench" / "logs"
90
+
91
+
92
+ _logger: logging.Logger | None = None
93
+
94
+
95
+ def get_logger() -> logging.Logger:
96
+ """Return the module logger, lazily initialised with a no-op handler.
97
+
98
+ Library callers and tests that never run `command_serve` still get a safe
99
+ logger; `setup_logging` attaches the rotating file handler when the service
100
+ actually starts.
101
+ """
102
+ global _logger
103
+ if _logger is None:
104
+ _logger = logging.getLogger(SERVICE_NAME)
105
+ _logger.setLevel(logging.INFO)
106
+ _logger.addHandler(logging.NullHandler())
107
+ return _logger
108
+
109
+
110
+ def setup_logging(log_dir: Path | str, port: int | None = None) -> logging.Logger:
111
+ """Attach the rotating file handler and return the module logger."""
112
+ global _logger
113
+ directory = Path(log_dir).expanduser()
114
+ directory.mkdir(parents=True, exist_ok=True)
115
+ 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
+ logger = get_logger()
124
+ for existing in list(logger.handlers):
125
+ if isinstance(existing, RotatingFileHandler):
126
+ existing.close()
127
+ logger.removeHandler(existing)
128
+ logger.addHandler(handler)
129
+ logger.setLevel(logging.INFO)
130
+ return logger
131
+
132
+
77
133
  def sha256_bytes(content: bytes) -> str:
78
134
  return hashlib.sha256(content).hexdigest()
79
135
 
@@ -409,15 +465,7 @@ def revision_of(content: str) -> str:
409
465
  return hashlib.sha256(content.encode("utf-8")).hexdigest()
410
466
 
411
467
 
412
- def inside_root(target: Path, root: Path) -> bool:
413
- try:
414
- target.relative_to(root)
415
- return True
416
- except ValueError:
417
- return False
418
-
419
-
420
- def resolve_html_file(raw_file: str, editor_root: Path) -> Path:
468
+ def resolve_html_file(raw_file: str) -> Path:
421
469
  if not raw_file.strip():
422
470
  raise WorkbenchError("FILE_REQUIRED", "请通过 file 参数指定 HTML 文件。", 400)
423
471
  requested = Path(raw_file).expanduser()
@@ -427,8 +475,6 @@ def resolve_html_file(raw_file: str, editor_root: Path) -> Path:
427
475
  raise WorkbenchError("FILE_NOT_FOUND", f"找不到文件:{requested.resolve()}", 404) from caught
428
476
  if target.suffix.lower() != ".html":
429
477
  raise WorkbenchError("INVALID_DOCUMENT", "只支持本地 .html 文件。", 400)
430
- if not inside_root(target, editor_root):
431
- raise WorkbenchError("OUTSIDE_EDITOR_ROOT", f"文件不在允许目录中:{target}", 403)
432
478
  return target
433
479
 
434
480
 
@@ -532,7 +578,9 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
532
578
  protocol_version = "HTTP/1.1"
533
579
 
534
580
  def log_message(self, format_string: str, *args: Any) -> None:
535
- sys.stderr.write(f"{self.log_date_time_string()} {format_string % args}\n")
581
+ message = format_string % args
582
+ get_logger().info("%s %s", self.log_date_time_string(), message)
583
+ sys.stderr.write(f"{self.log_date_time_string()} {message}\n")
536
584
 
537
585
  def send_bytes(self, status: int, content: bytes, content_type: str, cache: str = "no-store") -> None:
538
586
  self.send_response(status)
@@ -554,12 +602,14 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
554
602
 
555
603
  def send_error_payload(self, caught: Exception) -> None:
556
604
  if isinstance(caught, WorkbenchError):
605
+ get_logger().warning("request failed: %s (%s)", caught.code, caught)
557
606
  self.send_json(caught.status, {"error": caught.code, "message": str(caught), **caught.extra})
558
607
  else:
608
+ get_logger().exception("unhandled request error")
559
609
  self.send_json(500, {"error": "SERVER_ERROR", "message": str(caught)})
560
610
 
561
611
  def query_file(self, query: dict[str, list[str]]) -> Path:
562
- return resolve_html_file(query.get("file", [""])[0], self.server.editor_root)
612
+ return resolve_html_file(query.get("file", [""])[0])
563
613
 
564
614
  def do_GET(self) -> None:
565
615
  parsed = urllib.parse.urlsplit(self.path)
@@ -617,11 +667,9 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
617
667
  if len(parts) < 4:
618
668
  raise WorkbenchError("INVALID_ASSET", "资源地址无效。", 400)
619
669
  token = parts[2]
620
- source_file = resolve_html_file(decode_asset_token(token), self.server.editor_root)
670
+ source_file = resolve_html_file(decode_asset_token(token))
621
671
  relative = urllib.parse.unquote(parts[3])
622
672
  resource = (source_file.parent / relative).resolve(strict=True)
623
- if not inside_root(resource, self.server.editor_root):
624
- raise WorkbenchError("OUTSIDE_EDITOR_ROOT", "资源超出允许目录。", 403)
625
673
  content_type = mimetypes.guess_type(resource.name)[0] or "application/octet-stream"
626
674
  self.send_bytes(200, resource.read_bytes(), content_type, "private, max-age=60")
627
675
 
@@ -662,9 +710,11 @@ def health(port: int, timeout: float = 0.5) -> dict[str, Any] | None:
662
710
  return None
663
711
 
664
712
 
665
- def start_detached(script_file: Path, port: int, editor_root: Path, asset_file: Path | None, vendor_cache: Path) -> tuple[int, Path]:
666
- log_file = Path(tempfile.gettempdir()) / f"html-workbench-{port}.log"
667
- command = [sys.executable, str(script_file), "serve", "--port", str(port), "--editor-root", str(editor_root), "--vendor-cache", str(vendor_cache)]
713
+ 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
+ directory = Path(log_dir).expanduser()
715
+ directory.mkdir(parents=True, exist_ok=True)
716
+ log_file = directory / f"html-workbench-{port}.log"
717
+ command = [sys.executable, str(script_file), "serve", "--port", str(port), "--editor-root", str(editor_root), "--vendor-cache", str(vendor_cache), "--log-dir", str(directory)]
668
718
  if asset_file is not None:
669
719
  command.extend(["--asset", str(asset_file)])
670
720
  flags = 0
@@ -679,10 +729,12 @@ def start_detached(script_file: Path, port: int, editor_root: Path, asset_file:
679
729
 
680
730
 
681
731
  def command_serve(args: argparse.Namespace, script_file: Path) -> int:
732
+ logger = setup_logging(args.log_dir, args.port)
682
733
  asset_file = Path(args.asset).expanduser().resolve(strict=True) if args.asset else default_asset_file(script_file)
683
734
  editor_root = Path(args.editor_root).expanduser().resolve(strict=True)
684
735
  vendor_files = ensure_vendor_assets(Path(args.vendor_cache).expanduser())
685
736
  server = WorkbenchServer(("127.0.0.1", args.port), asset_file, editor_root, vendor_files)
737
+ logger.info("service started: port=%s editorRoot=%s logDir=%s", args.port, editor_root, Path(args.log_dir).expanduser())
686
738
  print(json.dumps({"ok": True, "url": f"http://127.0.0.1:{args.port}", "editorRoot": str(editor_root)}, ensure_ascii=False), flush=True)
687
739
  try:
688
740
  server.serve_forever(poll_interval=0.25)
@@ -690,20 +742,21 @@ def command_serve(args: argparse.Namespace, script_file: Path) -> int:
690
742
  pass
691
743
  finally:
692
744
  server.server_close()
745
+ logger.info("service stopped: port=%s", args.port)
693
746
  return 0
694
747
 
695
748
 
696
749
  def command_open(args: argparse.Namespace, script_file: Path) -> int:
697
750
  target = Path(args.file).expanduser().resolve(strict=True)
698
751
  editor_root = Path(args.editor_root).expanduser().resolve(strict=True)
699
- resolve_html_file(str(target), editor_root)
752
+ resolve_html_file(str(target))
700
753
  existing = health(args.port)
701
754
  reused = existing is not None
702
755
  pid: int | None = None
703
756
  log_file: Path | None = None
704
757
  if not reused:
705
758
  asset_file = Path(args.asset).expanduser().resolve(strict=True) if args.asset else None
706
- pid, log_file = start_detached(script_file, args.port, editor_root, asset_file, Path(args.vendor_cache).expanduser())
759
+ pid, log_file = start_detached(script_file, args.port, editor_root, asset_file, Path(args.vendor_cache).expanduser(), Path(args.log_dir).expanduser())
707
760
  deadline = time.monotonic() + args.wait
708
761
  while time.monotonic() < deadline:
709
762
  if health(args.port):
@@ -730,6 +783,7 @@ def build_parser() -> argparse.ArgumentParser:
730
783
  serve.add_argument("--editor-root", default=str(Path.home()))
731
784
  serve.add_argument("--asset", help="override the bundled workbench.html path")
732
785
  serve.add_argument("--vendor-cache", default=str(default_vendor_cache()), help="directory for verified GrapesJS files")
786
+ serve.add_argument("--log-dir", default=str(default_log_dir()), help="directory for rotating service logs")
733
787
 
734
788
  open_command = subparsers.add_parser("open", help="start or reuse the service and print an editor URL")
735
789
  open_command.add_argument("file")
@@ -737,6 +791,7 @@ def build_parser() -> argparse.ArgumentParser:
737
791
  open_command.add_argument("--editor-root", default=str(Path.home()))
738
792
  open_command.add_argument("--asset", help="override the bundled workbench.html path")
739
793
  open_command.add_argument("--vendor-cache", default=str(default_vendor_cache()), help="directory for verified GrapesJS files")
794
+ open_command.add_argument("--log-dir", default=str(default_log_dir()), help="directory for rotating service logs")
740
795
  open_command.add_argument("--wait", type=float, default=8.0)
741
796
 
742
797
  health_command = subparsers.add_parser("health", help="check the local service")
package/src/client.js CHANGED
@@ -58,7 +58,10 @@ body[data-hwb-dragging] { user-select: none; }
58
58
  .hwb-item-btn { border: 1px solid var(--dsw-alias-border-l2); background: transparent; color: var(--dsw-alias-label-secondary); border-radius: 6px; padding: 2px 9px; cursor: pointer; font-size: 12px; flex: none; }
59
59
  .hwb-item-btn:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
60
60
  .hwb-dockempty { padding: 14px; color: var(--dsw-alias-label-tertiary); text-align: center; font-size: 12px; }
61
- .hwb-trigger { position: fixed; top: 10px; right: 12px; z-index: 10000; width: 36px; height: 36px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; pointer-events: auto; }
61
+ /* Keep the trigger below the DSH session header's title row (~44px tall: 12px
62
+ padding + 32px min-height) so it never covers the right-aligned header
63
+ utilities — e.g. the "Session Log" button — when a session is open. */
64
+ .hwb-trigger { position: fixed; top: 56px; right: 12px; z-index: 10000; width: 36px; height: 36px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; pointer-events: auto; }
62
65
  .hwb-trigger:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
63
66
  `)
64
67