trip-moments-skill 1.2.0 → 1.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.
@@ -355,7 +355,9 @@ def _claw_module():
355
355
 
356
356
 
357
357
  class OpenClawTransport:
358
- """Persistent-session transport over ClawHive's chat-completions endpoint.
358
+ """Persistent-session transport over an OpenClaw gateway's chat-completions
359
+ endpoint — either ClawHive's embedded gateway or the OpenClaw desktop app's
360
+ WSL-backed one (both are `openclaw`, discovered by clawbridge).
359
361
 
360
362
  The gateway serves ONE turn per session lane (#235): `start()` is a full
361
363
  blocking agent turn, not a run-id handle. We still model it as
@@ -366,6 +368,13 @@ class OpenClawTransport:
366
368
  """
367
369
 
368
370
  provider = "openclaw"
371
+ # The WSL gateway's session book is SQLite inside the distro and the wsl$
372
+ # UNC paths are unreachable from Windows, so there is no "the conversation
373
+ # the user is looking at" to bind to there. This explicit key gives the
374
+ # workbench one stable thread instead: the gateway routes it to a session of
375
+ # its own (only `subagent:`/`cron:`/`acp:` are reserved) and repeated turns
376
+ # continue that conversation rather than starting fresh each time.
377
+ WORKBENCH_SESSION = "studio-workbench"
369
378
 
370
379
  def __init__(self, cwd=""):
371
380
  self.claw = _claw_module()
@@ -378,28 +387,45 @@ class OpenClawTransport:
378
387
  def probe(self):
379
388
  info = self.claw.discover(force=True)
380
389
  if not info.get("base"):
381
- raise AgentError("未找到运行中的 ClawHive gateway:" + (info.get("why") or ""))
390
+ # A gateway that answered but has the opt-in endpoint disabled is NOT
391
+ # the same as no gateway: say which one it was and what to turn on,
392
+ # otherwise the only message the user gets is 「未找到」 while their
393
+ # OpenClaw is sitting right there on 18789.
394
+ why = info.get("why") or ""
395
+ if info.get("via"):
396
+ raise AgentError(f"{info.get('name') or 'OpenClaw'} 通道不可用:{why}")
397
+ raise AgentError("未找到运行中的 OpenClaw gateway:" + why)
382
398
  if not info.get("enabled"):
383
- raise AgentError("ClawHive gateway 的 OpenAI 兼容端点未启用:" + (info.get("why") or ""))
399
+ raise AgentError("OpenClaw gateway 的 OpenAI 兼容端点未启用:" + (info.get("why") or ""))
384
400
  return {"ready": True, "provider": self.provider, "native_sessions": True,
385
401
  "streaming": False, "steer": False, "approvals": False,
386
- "port": info.get("port"), "agents": info.get("agents") or []}
402
+ "port": info.get("port"), "agents": info.get("agents") or [],
403
+ "gateway": info.get("name") or "", "via": info.get("via") or ""}
387
404
 
388
405
  def sessions(self):
389
- # The desktop conversation list belongs to ClawHive's own UI; offering
406
+ # The desktop conversation list belongs to that app's own UI; offering
390
407
  # it here would invite binding to a session nobody is looking at.
391
408
  return []
392
409
 
393
410
  def create(self, title, cwd):
394
- # There is no session-creation endpoint: we bind to the conversation
395
- # the user has open right now (see bind).
411
+ # There is no session-creation endpoint on this API surface; the bind
412
+ # below decides which thread turns will land in.
396
413
  return ""
397
414
 
398
415
  def bind(self, sid):
399
- key = sid or self.claw.active_session()
400
- if not key:
401
- raise AgentError("没有找到正在使用的 ClawHive 会话;先在 ClawHive 里打开一个对话")
402
- return key
416
+ if sid:
417
+ return sid
418
+ key = self.claw.active_session()
419
+ if key:
420
+ return key # ClawHive: the open conversation
421
+ info = self.claw.discover()
422
+ if info.get("via"):
423
+ # Keyed off `via`, not `base`: a gateway that ANSWERED but has the
424
+ # endpoint disabled is still a gateway (that is the default state),
425
+ # and probe() already refused with the exact config key before we get
426
+ # here. Binding then succeeds and the first turn reports the truth.
427
+ return self.WORKBENCH_SESSION # WSL gateway: our own thread
428
+ raise AgentError("没有找到正在使用的 ClawHive 会话;先在 ClawHive 里打开一个对话")
403
429
 
404
430
  # -- turns ---------------------------------------------------------------
405
431
  def start(self, sid, text, key):
@@ -83,7 +83,7 @@ AGENT_SHELL = r"""
83
83
  <div class="ap-body" id="ap-body">
84
84
  <details class="ap-setup" id="ap-setup" open><summary>连接与会话绑定</summary>
85
85
  <label for="ap-kind">Agent 通道</label><select id="ap-kind" onchange="agentProviderHint()">
86
- <option value="hermes">Hermes · 原生持续会话</option><option value="codex">Codex App Server · 工作台专用会话</option><option value="openclaw">OpenClaw (ClawHive) · 活跃会话直连</option></select>
86
+ <option value="hermes">Hermes · 原生持续会话</option><option value="codex">Codex App Server · 工作台专用会话</option><option value="openclaw">OpenClaw gateway · 整轮直连(ClawHive 或 WSL)</option></select>
87
87
  <div id="ap-hermes-fields"><label for="ap-url">本机 Hermes API 地址</label><input id="ap-url" value="http://127.0.0.1:8642">
88
88
  <label for="ap-key">API 密钥(留空读取本机 Hermes 配置)</label><input id="ap-key" type="password" autocomplete="off"></div>
89
89
  <p class="note" id="ap-hint" style="margin-top:8px"></p>
@@ -130,6 +130,7 @@ function deckControlsHtml() {
130
130
  <p class="note" id="wf-mode-note">${agent
131
131
  ? "用已绑定 Agent 的模型、工具与会话上下文;检查、处理、追问都在本卡完成。"
132
132
  : "用配置的模型网关;AI 分析会发送照片预览,不进入 Agent 会话。"}</p>
133
+ <p class="note" id="claw-note" style="display:none"></p>
133
134
  </div>`;
134
135
  }
135
136
  /* One input box serves both 「本步要求」 and 「继续追问」, so the session buttons
@@ -275,7 +276,7 @@ function agentProviderHint() {
275
276
  ? "绑定 Hermes 原生会话,后续回复与步骤按钮都延续该会话。连接密钥仅由本机服务使用。"
276
277
  : kind === "codex"
277
278
  ? "使用本机已登录的 Codex CLI,创建工作台专用的持续任务;不接管你当前 Codex 桌面聊天。工具批准仍需你确认。"
278
- : "直连 ClawHive 内嵌的 OpenClaw gateway,绑定你正在看的那个会话;整轮执行,无中间批准。需在 openclaw.json 启用 chatCompletions 端点。";
279
+ : "直连 OpenClaw gateway —— ClawHive 内嵌的那个,或 OpenClaw 桌面版在 WSL 里跑的那个。整轮执行,无中间批准;需在该 gateway 的 openclaw.json 里启用 chatCompletions 端点。ClawHive 上会绑定你正在看的会话;WSL gateway 读不到会话簿,改用工作台专用会话。";
279
280
  }
280
281
  async function pollAgent() {
281
282
  if (AP.polling) return;
@@ -10,20 +10,33 @@ So a click inside Studio can turn into a real agent turn, and the agent's own
10
10
  words can be shown back in the workbench — which is the whole point of the demo:
11
11
  showing curation *driven by* an agent, not a silent local script.
12
12
 
13
- Discovery is by filesystem, not hardcoded ports: ClawHive writes the live port
14
- to `…/openclaw/state/gateway-port.json` and the bearer token next to it as
15
- `gateway-token`. Account/tenant ids vary per install, so we glob.
16
-
17
- Everything degrades gracefully: if ClawHive is not running or the endpoint is
18
- disabled, `discover()` returns None and Studio simply hides the panel.
19
- """
20
- from __future__ import annotations
21
-
22
- import glob
23
- import json
24
- import os
25
- import sys
26
- import time
13
+ Discovery is by filesystem, not hardcoded ports — and there are TWO families of
14
+ gateway to find, because "OpenClaw" ships in two very different shapes:
15
+
16
+ 1. ClawHive's embedded gateway. ClawHive writes the live port to
17
+ `…/openclaw/state/gateway-port.json` and the bearer token next to it as
18
+ `gateway-token`. Account/tenant ids vary per install, so we glob.
19
+ 2. The OpenClaw desktop app (`OpenClawTray`), which provisions its own WSL distro
20
+ (default `OpenClawGateway`) and runs `openclaw gateway --port 18789` inside it.
21
+ Nothing lands in a Windows-visible `~/.openclaw/state`; the app publishes the
22
+ gateway URL and shared token in `%APPDATA%/OpenClawTray/gateways.json`, and
23
+ Windows reaches the WSL listener through WSL2 localhost forwarding. Both
24
+ families multiplex WS + HTTP on the SAME port, and both serve the same
25
+ OpenAI-compatible surface — but only when it is enabled in that gateway's
26
+ `openclaw.json` (`gateway.http.endpoints.chatCompletions.enabled`).
27
+
28
+ Everything degrades gracefully: if no gateway is running, or the endpoint is
29
+ disabled, `discover()` returns a not-found info carrying an honest `why` and
30
+ Studio simply hides the panel.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import glob
35
+ import json
36
+ import os
37
+ import re
38
+ import sys
39
+ import time
27
40
  from pathlib import Path
28
41
 
29
42
  if sys.platform == "win32":
@@ -33,20 +46,106 @@ if sys.platform == "win32":
33
46
  except Exception:
34
47
  pass
35
48
 
36
- _APPDATA = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
37
- _STATE_GLOBS = [
38
- # ClawHive desktop (per account / per tenant)
39
- os.path.join(_APPDATA, "ClawHiveApp", "accounts", "*", "tenants", "*",
40
- "openclaw", "state"),
41
- # standalone OpenClaw installs
42
- os.path.join(Path.home(), ".openclaw", "state"),
43
- os.path.join(Path.home(), ".config", "openclaw", "state"),
44
- ]
45
-
46
- _CACHE: dict = {"at": 0.0, "info": None}
47
- _CACHE_TTL = 20.0
48
-
49
-
49
+ _APPDATA = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
50
+ _STATE_GLOBS = [
51
+ # ClawHive desktop (per account / per tenant)
52
+ os.path.join(_APPDATA, "ClawHiveApp", "accounts", "*", "tenants", "*",
53
+ "openclaw", "state"),
54
+ # standalone OpenClaw installs
55
+ os.path.join(Path.home(), ".openclaw", "state"),
56
+ os.path.join(Path.home(), ".config", "openclaw", "state"),
57
+ ]
58
+ _OPENCLAW_TRAY = os.path.join(_APPDATA, "OpenClawTray", "gateways.json")
59
+ _LOCAL_HOST = re.compile(r"^(?:127\.0\.0\.1|localhost|\[::1\]):(\d{2,5})$")
60
+
61
+ _CACHE: dict = {"at": 0.0, "info": None}
62
+ _CACHE_TTL = 20.0
63
+
64
+
65
+ def tray_gateways() -> list[dict]:
66
+ """Gateways published by the OpenClaw desktop app (the WSL-backed one).
67
+
68
+ `openclaw` 2026.x installs as a Windows tray app that creates its own WSL
69
+ distro and runs the gateway inside it (verified on this machine 2026-10-04:
70
+ distro `OpenClawGateway`, `node …/openclaw/dist/index.js gateway --port
71
+ 18789`, `gateway.mode: local`, `bind: loopback`, `auth.mode: token`).
72
+ Windows reaches it because WSL2 forwards localhost; the app records the URL
73
+ and the shared token here, which happens to be the same value as
74
+ `gateway.auth.token` inside the distro. We never read the token out of WSL.
75
+
76
+ Only loopback URLs are accepted: a remote `sshTunnel`/LAN gateway in this
77
+ file is not something the workbench should silently send photos to.
78
+ """
79
+ try:
80
+ raw = json.loads(Path(_OPENCLAW_TRAY).read_text(encoding="utf-8"))
81
+ except (OSError, ValueError):
82
+ return []
83
+ out = []
84
+ for gw in (raw.get("gateways") or []):
85
+ if not isinstance(gw, dict):
86
+ continue
87
+ token = str(gw.get("sharedGatewayToken") or "").strip()
88
+ url = str(gw.get("url") or "").strip()
89
+ m = re.match(r"^wss?://([^/]+)/?$", url)
90
+ if not token or not m:
91
+ continue
92
+ host = m.group(1)
93
+ port = _LOCAL_HOST.match(host)
94
+ if not port:
95
+ continue # not loopback -> never used
96
+ out.append({"base": f"http://{host}", "token": token,
97
+ "port": int(port.group(1)),
98
+ "name": str(gw.get("friendlyName") or "OpenClaw"),
99
+ "distro": str(gw.get("setupManagedDistroName") or "")})
100
+ return out
101
+
102
+
103
+ def _probe_endpoint(entry: dict) -> dict:
104
+ """Fill `agents`/`endpoint`/`enabled` for an entry that already has base+token.
105
+
106
+ Both gateway families answer `/health` and multiplex HTTP on the WS port, so
107
+ one probe serves both. `/v1/models` is the cheapest possible check for the
108
+ opt-in OpenAI surface: it costs zero agent tokens when it is on, and returns
109
+ 404 when it is off — which is NOT a failure to report as "no gateway found".
110
+ """
111
+ token = entry["token"]
112
+ try:
113
+ import requests
114
+
115
+ r = requests.get(f"{entry['base']}/health", timeout=2,
116
+ proxies={"http": None, "https": None})
117
+ if r.status_code != 200:
118
+ entry["why"] = f"gateway /health -> HTTP {r.status_code}"
119
+ return entry
120
+ except Exception as exc:
121
+ entry["why"] = f"gateway 无响应: {type(exc).__name__}"
122
+ return entry
123
+
124
+ try:
125
+ import requests
126
+
127
+ r = requests.get(f"{entry['base']}/v1/models",
128
+ headers={"Authorization": f"Bearer {token}"},
129
+ timeout=4,
130
+ proxies={"http": None, "https": None})
131
+ ct = (r.headers.get("Content-Type") or "")
132
+ if r.status_code == 200 and "json" in ct:
133
+ data = r.json().get("data") or []
134
+ entry["agents"] = [m.get("id") for m in data if isinstance(m, dict) and m.get("id")]
135
+ entry["endpoint"] = bool(entry["agents"])
136
+ if not entry["endpoint"]:
137
+ entry["why"] = "gateway 已启用端点,但 /v1/models 没列出任何 agent"
138
+ else:
139
+ entry["why"] = ("已找到 gateway(" + (entry.get("name") or entry["base"])
140
+ + "),但 OpenAI 兼容端点未启用"
141
+ ":在该 gateway 的 openclaw.json 里设置 "
142
+ "gateway.http.endpoints.chatCompletions.enabled = true")
143
+ except Exception as exc:
144
+ entry["why"] = f"端点探测失败: {type(exc).__name__}"
145
+ entry["enabled"] = bool(entry["endpoint"])
146
+ return entry
147
+
148
+
50
149
  def _state_dirs() -> list[Path]:
51
150
  dirs = []
52
151
  for pat in _STATE_GLOBS:
@@ -60,8 +159,9 @@ def _state_dirs() -> list[Path]:
60
159
  def _probe_state(state: Path) -> dict:
61
160
  """Probe ONE ClawHive state dir. Returns an entry with `enabled` plus an
62
161
  honest `why` when it is not usable — the caller keeps trying the others."""
63
- entry = {"base": "", "token": "", "port": 0, "state_dir": str(state),
64
- "agents": [], "endpoint": False, "enabled": False, "why": ""}
162
+ entry = {"base": "", "token": "", "port": 0, "state_dir": str(state),
163
+ "agents": [], "endpoint": False, "enabled": False, "why": "",
164
+ "via": "clawhive", "name": "ClawHive"}
65
165
  try:
66
166
  port = int(json.loads((state / "gateway-port.json").read_text(
67
167
  encoding="utf-8"))["port"])
@@ -80,82 +180,83 @@ def _probe_state(state: Path) -> dict:
80
180
  if not token:
81
181
  entry["why"] = "gateway-token 为空"
82
182
  return entry
83
- entry.update({"base": f"http://127.0.0.1:{port}", "token": token, "port": port})
84
-
85
- try:
86
- import requests
87
-
88
- r = requests.get(f"{entry['base']}/health", timeout=2,
89
- proxies={"http": None, "https": None})
90
- if r.status_code != 200:
91
- entry["why"] = f"gateway /health -> HTTP {r.status_code}"
92
- return entry
93
- except Exception as exc:
94
- entry["why"] = f"gateway 无响应: {type(exc).__name__}"
95
- return entry
96
-
97
- # the OpenAI-compatible surface is opt-in; /v1/models tells us whether
98
- # it is on without spending a single agent token
99
- try:
100
- import requests
101
-
102
- r = requests.get(f"{entry['base']}/v1/models",
103
- headers={"Authorization": f"Bearer {token}"},
104
- timeout=4,
105
- proxies={"http": None, "https": None})
106
- ct = (r.headers.get("Content-Type") or "")
107
- if r.status_code == 200 and "json" in ct:
108
- data = r.json().get("data") or []
109
- entry["agents"] = [m.get("id") for m in data if m.get("id")]
110
- entry["endpoint"] = bool(entry["agents"])
111
- else:
112
- entry["why"] = ("已找到 gateway,但 OpenAI 兼容端点未启用"
113
- "(gateway.http.endpoints.chatCompletions.enabled)")
114
- except Exception as exc:
115
- entry["why"] = f"端点探测失败: {type(exc).__name__}"
116
- entry["enabled"] = bool(entry["endpoint"])
117
- return entry
183
+ entry.update({"base": f"http://127.0.0.1:{port}", "token": token, "port": port})
184
+ return _probe_endpoint(entry)
118
185
 
119
186
 
120
187
  def discover(force: bool = False) -> dict | None:
121
- """Locate a live ClawHive gateway across ALL candidate tenants.
122
-
123
- The first state dir that has a port+token used to win the loop outright —
124
- a dead first tenant permanently shadowed a healthy second one. Now every
125
- candidate is probed (cheap: /health + /v1/models) until one is actually
126
- healthy, and the failures are carried in `tried` for diagnosis.
127
-
128
- Returns {"base", "token", "port", "agents", "endpoint": bool, "why": str,
129
- "tried": [...]} or a not-found info when nothing is reachable. Cached
130
- briefly: this is called on every state refresh and probing each time would
131
- be wasteful. `force=True` bypasses the TTL — that is for the user's
132
- explicit refresh, not for background polling.
188
+ """Locate a live gateway across ALL candidate installs.
189
+
190
+ Two families are searched: ClawHive's embedded gateway (state dirs with
191
+ gateway-port.json) and the OpenClaw desktop app's WSL-backed gateway
192
+ (gateways.json). ClawHive is tried first — on it we can also target the
193
+ conversation the user is looking at, which the standalone gateway cannot
194
+ offer through this endpoint.
195
+
196
+ The first state dir that has a port+token used to win the loop outright —
197
+ a dead first tenant permanently shadowed a healthy second one. Now every
198
+ candidate is probed (cheap: /health + /v1/models) until one is actually
199
+ healthy, and the failures are carried in `tried` for diagnosis.
200
+
201
+ Returns {"base", "token", "port", "agents", "endpoint": bool, "why": str,
202
+ "tried": [...]} or a not-found info when nothing is reachable. Cached
203
+ briefly: this is called on every state refresh and probing each time would
204
+ be wasteful. `force=True` bypasses the TTL — that is for the user's
205
+ explicit refresh, not for background polling.
133
206
  """
134
207
  now = time.time()
135
208
  if not force and _CACHE["info"] is not None and now - _CACHE["at"] < _CACHE_TTL:
136
209
  return _CACHE["info"]
137
210
 
138
- healthy = None
139
- tried = []
140
- for state in _state_dirs():
141
- entry = _probe_state(state)
142
- tried.append({"state_dir": entry["state_dir"], "port": entry["port"],
143
- "enabled": entry["enabled"], "why": entry["why"]})
144
- if entry["enabled"]:
145
- healthy = entry
146
- break
147
-
148
- if healthy is not None:
149
- info = dict(healthy)
150
- info["tried"] = tried
151
- else:
152
- whys = [t["why"] for t in tried if t.get("why")]
153
- info = {"base": "", "token": "", "port": 0, "agents": [], "endpoint": False,
154
- "enabled": False, "state_dir": "",
155
- "why": (";".join(whys)[:300] if whys
156
- else "未找到运行中的 ClawHive gateway"),
157
- "tried": tried}
158
- _CACHE.update({"at": now, "info": info})
211
+ healthy = None
212
+ tried = []
213
+ for state in _state_dirs():
214
+ entry = _probe_state(state)
215
+ tried.append({"state_dir": entry["state_dir"], "port": entry["port"],
216
+ "enabled": entry["enabled"], "why": entry["why"],
217
+ "via": entry.get("via") or "clawhive",
218
+ "name": entry.get("name") or "ClawHive"})
219
+ if entry["enabled"]:
220
+ healthy = entry
221
+ break
222
+
223
+ # The OpenClaw desktop app's own gateway. Its OpenAI surface has to be
224
+ # enabled in ITS config, so a disabled one is reported with the exact key
225
+ # rather than being mistaken for "no gateway found" — the two look the same
226
+ # from the page otherwise, and the user has to know which knob to turn.
227
+ if healthy is None:
228
+ for gw in tray_gateways():
229
+ entry = _probe_endpoint(dict(gw, state_dir="", agents=[], endpoint=False,
230
+ enabled=False, why="", via="openclaw-tray"))
231
+ tried.append({"state_dir": entry["base"], "port": entry["port"],
232
+ "enabled": entry["enabled"], "why": entry["why"],
233
+ "via": "openclaw-tray",
234
+ "name": entry.get("name") or "OpenClaw"})
235
+ if entry["enabled"]:
236
+ healthy = entry
237
+ break
238
+
239
+ if healthy is not None:
240
+ info = dict(healthy)
241
+ info["tried"] = tried
242
+ else:
243
+ whys = [t["why"] for t in tried if t.get("why")]
244
+ info = {"base": "", "token": "", "port": 0, "agents": [], "endpoint": False,
245
+ "enabled": False, "state_dir": "", "via": "", "name": "",
246
+ "why": (";".join(whys)[:400] if whys
247
+ else "未找到运行中的 ClawHive / OpenClaw gateway"),
248
+ "tried": tried}
249
+ # A gateway that ANSWERED but has the endpoint off is the common case (it
250
+ # is off by default) and it is not "nothing found". Carry its identity
251
+ # through so the page can name it: 「Local (OpenClawGateway) 通道未就绪
252
+ # (…chatCompletions.enabled…)」 is actionable, 「未找到 gateway」 is not.
253
+ for t in reversed(tried):
254
+ if t.get("port"):
255
+ info.update({"port": t["port"], "via": t.get("via") or "",
256
+ "state_dir": t.get("state_dir") or "",
257
+ "name": (t.get("name") or "")})
258
+ break
259
+ _CACHE.update({"at": now, "info": info})
159
260
  return info
160
261
 
161
262
 
@@ -172,6 +273,8 @@ def status(force: bool = False) -> dict:
172
273
  "agents": info.get("agents") or [],
173
274
  "why": info.get("why") or "",
174
275
  "state_dir": info.get("state_dir") or "",
276
+ "via": info.get("via") or "",
277
+ "name": info.get("name") or "",
175
278
  "tried": info.get("tried") or [],
176
279
  # the page needs to know this *before* the click: delegating into a
177
280
  # conversation that is mid-turn cannot be served (see session_busy)
@@ -320,11 +423,11 @@ def ask(message: str, agent: str = "", session: str = "trip-moments-studio",
320
423
  resolving sessions against "main" while talking to another agent used to
321
424
  post into a conversation nobody was looking at.
322
425
  """
323
- info = discover()
324
- if not info.get("base"):
325
- raise RuntimeError(info.get("why") or "ClawHive gateway 不可用")
326
- if not info.get("enabled"):
327
- raise RuntimeError(info.get("why") or "ClawHive 的 OpenAI 兼容端点未启用")
426
+ info = discover()
427
+ if not info.get("base"):
428
+ raise RuntimeError(info.get("why") or "OpenClaw gateway 不可用")
429
+ if not info.get("enabled"):
430
+ raise RuntimeError(info.get("why") or "OpenClaw 的 OpenAI 兼容端点未启用")
328
431
 
329
432
  target = pick_agent(agent)
330
433
  payload = {
@@ -364,8 +467,8 @@ def ask(message: str, agent: str = "", session: str = "trip-moments-studio",
364
467
  headers=headers, json=payload, timeout=timeout,
365
468
  proxies={"http": None, "https": None})
366
469
  took = time.time() - t0
367
- if r.status_code != 200:
368
- raise RuntimeError(f"ClawHive 返回 HTTP {r.status_code}: {r.text[:200]}")
470
+ if r.status_code != 200:
471
+ raise RuntimeError(f"gateway 返回 HTTP {r.status_code}: {r.text[:200]}")
369
472
  data = r.json()
370
473
  try:
371
474
  text = data["choices"][0]["message"]["content"]
@@ -8,11 +8,13 @@ from __future__ import annotations
8
8
  import base64
9
9
  import io
10
10
  import os
11
+ import re
11
12
  import sys
13
+ from functools import lru_cache
12
14
  from pathlib import Path
13
15
 
14
16
  import numpy as np
15
- from PIL import Image, ImageFilter, ImageOps, ImageFont
17
+ from PIL import Image, ImageDraw, ImageFilter, ImageOps, ImageFont
16
18
 
17
19
  if sys.platform == "win32":
18
20
  try:
@@ -149,41 +151,152 @@ def palette(img: Image.Image, n: int = 5) -> list[tuple[int, int, int]]:
149
151
 
150
152
 
151
153
  # --- fonts (Cross-platform font paths; graceful fallback) ---------------------
152
- FONT_ZH_BOLD = ["msyhbd.ttc", "simhei.ttf", "msyh.ttc", "simsun.ttc", "NotoSansSC-Bold.otf", "PingFang.ttc"]
153
- FONT_ZH = ["msyh.ttc", "simsun.ttc", "simhei.ttf", "NotoSansSC-Regular.otf", "PingFang.ttc", "STHeiti.ttc"]
154
+ # The names differ per platform because the *products* differ: Windows/macOS ship
155
+ # their own CJK faces, while Debian/Ubuntu install the Noto **CJK** superfamily
156
+ # (opentype/noto/NotoSansCJK-*.ttc) and Fedora/Arch often the SC subset instead.
157
+ # Listing only the Windows names (plus NotoSansSC) meant a Linux server rendered
158
+ # every Chinese glyph as the same .notdef box — measured in a python:3.11-slim
159
+ # container: 「朋友圈旅行」 and 「敦煌戈壁滩」 produced byte-identical bitmaps both
160
+ # before AND after `apt install fonts-noto-cjk`, because /usr/share/fonts was only
161
+ # searched one level deep and the .ttc name was not a candidate at all.
162
+ FONT_ZH_BOLD = [
163
+ "msyhbd.ttc", "simhei.ttf", "msyh.ttc", "simsun.ttc", "PingFang.ttc",
164
+ "NotoSansCJK-Bold.ttc", "NotoSansCJK-Regular.ttc", "NotoSansCJKsc-Bold.otf",
165
+ "NotoSansSC-Bold.otf", "SourceHanSansSC-Bold.otf", "wqy-zenhei.ttc",
166
+ ]
167
+ FONT_ZH = [
168
+ "msyh.ttc", "simsun.ttc", "simhei.ttf", "PingFang.ttc", "STHeiti.ttc",
169
+ "NotoSansCJK-Regular.ttc", "NotoSansCJKsc-Regular.otf",
170
+ "NotoSansSC-Regular.otf", "SourceHanSansSC-Regular.otf", "NotoSerifCJK-Regular.ttc",
171
+ "wqy-zenhei.ttc",
172
+ ]
154
173
  FONT_EN = ["georgia.ttf", "times.ttf", "arial.ttf", "DejaVuSerif.ttf", "Helvetica.ttc"]
155
174
 
175
+ # Any font on the box whose name looks CJK-capable is a valid last resort, which
176
+ # is what makes this work on a distro whose naming we never anticipated.
177
+ _CJK_NAME = re.compile(
178
+ r"(noto.{0,12}cjk|notosanssc|notoserifsc|sourcehan|wqy|droid.{0,8}fallback"
179
+ r"|msyh|simhei|simsun|pingfang|heiti|hiragino|uming|ukai)", re.IGNORECASE)
180
+ _FONT_SUFFIXES = {".ttf", ".ttc", ".otf", ".otc"}
156
181
 
157
- def find_font(candidates: list[str], size: int) -> ImageFont.FreeTypeFont:
158
- """Locate a usable font file across Windows, Linux, and macOS directories."""
159
- search_dirs = [
160
- Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts",
161
- Path(os.environ.get("LOCALAPPDATA", "")) / "Microsoft" / "Windows" / "Fonts",
162
- Path("/usr/share/fonts"),
163
- Path("/usr/local/share/fonts"),
164
- Path.home() / ".fonts",
165
- Path.home() / ".local/share/fonts",
166
- Path("/System/Library/Fonts"),
167
- Path("/Library/Fonts"),
168
- ]
169
- for name in candidates:
170
- for sdir in search_dirs:
171
- if not sdir.exists():
182
+
183
+ def _font_search_dirs() -> list[Path]:
184
+ """Font directories to scan, most specific first.
185
+
186
+ `TRIP_FONT_DIRS` (os.pathsep-separated) REPLACES the list when set: that is
187
+ the seam the selftest uses to isolate a Debian-like layout from whatever
188
+ fonts this machine happens to own. (A prepend would not isolate anything —
189
+ the machine's own CJK font would still win and hide the bug being tested.)
190
+ """
191
+ override = os.environ.get("TRIP_FONT_DIRS")
192
+ if override:
193
+ return [Path(p.strip()) for p in override.split(os.pathsep) if p.strip()]
194
+ dirs: list[Path] = []
195
+ win = os.environ.get("WINDIR") or "C:/Windows"
196
+ local = os.environ.get("LOCALAPPDATA")
197
+ dirs.append(Path(win) / "Fonts")
198
+ if local:
199
+ dirs.append(Path(local) / "Microsoft" / "Windows" / "Fonts")
200
+ dirs += [Path("/usr/share/fonts"), Path("/usr/local/share/fonts"),
201
+ Path.home() / ".fonts", Path.home() / ".local/share/fonts",
202
+ Path("/System/Library/Fonts"), Path("/Library/Fonts")]
203
+ return dirs
204
+
205
+
206
+ @lru_cache(maxsize=8)
207
+ def _font_index(dirs: tuple[str, ...]) -> dict[str, str]:
208
+ """name -> path for every font file under those dirs, searched RECURSIVELY.
209
+
210
+ Recursion is the whole point: Debian keeps Noto at
211
+ /usr/share/fonts/opentype/noto/..., so a one-level lookup finds nothing.
212
+ """
213
+ index: dict[str, str] = {}
214
+ for entry in dirs:
215
+ root = Path(entry)
216
+ try:
217
+ if not root.is_dir():
172
218
  continue
173
- p = sdir / name
174
- if p.exists():
175
- try:
176
- return ImageFont.truetype(str(p), size)
177
- except OSError:
178
- continue
219
+ for path in root.rglob("*"):
220
+ if path.suffix.lower() in _FONT_SUFFIXES and path.is_file():
221
+ index.setdefault(path.name, str(path))
222
+ except OSError:
223
+ continue
224
+ return index
225
+
226
+
227
+ def _renders_cjk(font) -> bool:
228
+ """Does this font actually have CJK glyphs, or would it just draw tofu?
229
+
230
+ A font without CJK coverage maps every ideograph to the same .notdef box, so
231
+ two unrelated characters come out byte-identical. That is the check — the
232
+ same one that caught the Linux bug, kept here so it cannot come back.
233
+ """
234
+ def ink(text: str) -> bytes:
235
+ img = Image.new("L", (72, 72), 255)
236
+ ImageDraw.Draw(img).text((3, 3), text, font=font, fill=0)
237
+ return img.tobytes()
238
+
239
+ try:
240
+ return ink("朋") != ink("北")
241
+ except Exception:
242
+ return False
243
+
244
+
245
+ def find_font(candidates: list[str], size: int) -> ImageFont.FreeTypeFont:
246
+ """Locate a usable font file across Windows, Linux, and macOS directories.
247
+
248
+ Prefers a font that can actually draw Chinese; falls back to a Latin face and
249
+ then to Pillow's own default (which has no CJK at all). The scanned dirs come
250
+ first and bare names are the last resort — bare names still cover setups our
251
+ scan cannot see (Windows' own font dir is on Pillow's search path, and
252
+ fontconfig-only Linux installs may hide faces behind aliases).
253
+
254
+ When `TRIP_FONT_DIRS` is set the search set is EXACTLY those directories and
255
+ no bare-name lookup happens, which is what lets the selftest reproduce a
256
+ Debian-like box instead of this machine's fonts.
257
+ """
258
+ override = bool(os.environ.get("TRIP_FONT_DIRS"))
259
+ dirs = tuple(str(d) for d in _font_search_dirs())
260
+ index = _font_index(dirs)
261
+ latin_fallback = None
262
+ # Any CJK-looking font present in the search set is a better answer than a
263
+ # Latin fallback, however the distro happens to name it.
264
+ names = list(candidates) + [n for n in index if _CJK_NAME.search(n)]
265
+
266
+ def consider(font):
267
+ nonlocal latin_fallback
268
+ if _renders_cjk(font):
269
+ return font
270
+ if latin_fallback is None:
271
+ latin_fallback = font
272
+ return None
273
+
274
+ for name in names: # 1) the scanned dirs
275
+ path = index.get(name)
276
+ if not path:
277
+ continue
179
278
  try:
180
- return ImageFont.truetype(name, size)
279
+ picked = consider(ImageFont.truetype(path, size))
181
280
  except OSError:
182
- pass
281
+ continue
282
+ if picked is not None:
283
+ return picked
284
+
285
+ if not override: # 2) Pillow's own search
286
+ for name in names:
287
+ try:
288
+ picked = consider(ImageFont.truetype(name, size))
289
+ except OSError:
290
+ continue
291
+ if picked is not None:
292
+ return picked
183
293
 
294
+ if latin_fallback is not None: # 3) no CJK anywhere
295
+ return latin_fallback
184
296
  for fallback in ["arial.ttf", "DejaVuSans.ttf"]:
297
+ path = index.get(fallback)
185
298
  try:
186
- return ImageFont.truetype(fallback, size)
299
+ return ImageFont.truetype(path or fallback, size)
187
300
  except OSError:
188
301
  pass
189
302
  return ImageFont.load_default()
@@ -2952,6 +2952,12 @@ def t_claw_grade_candidates_and_output_visibility():
2952
2952
  assert "inline=1" in html and "function saveUrl" in html, \
2953
2953
  "页面没有 fetch+blob 的保存路径(attachment 被浏览器拦时会静默失败)"
2954
2954
  assert "data.build" in html, "页面没有随服务端构建号自刷新的逻辑"
2955
+ # 通道状态必须能被看见:旧的 #exec-bar 从六步重构起就一直是 display:none,
2956
+ # 于是「ClawHive/OpenClaw 通道未就绪(为什么)」从来没显示过。现在同一行也
2957
+ # 写进操作台(#claw-note),否则 OpenClaw WSL gateway 的“端点没开”提示
2958
+ # 等于没写。
2959
+ assert 'id="claw-note"' in html, "操作台没有通道状态行(未就绪/忙碌说明无处可显)"
2960
+ assert '$("claw-note")' in html, "通道状态行没有被写入"
2955
2961
 
2956
2962
 
2957
2963
  def t_agent_model_grade_candidates():
@@ -3160,10 +3166,233 @@ def t_agent_model_grade_candidates():
3160
3166
  "agent 只把文案写在会话里时页面不说明"
3161
3167
 
3162
3168
 
3169
+ def t_tray_gateway_discovery():
3170
+ """OpenClaw 桌面版(WSL)自己的 gateway 也要能被发现,而且要能区分
3171
+ 「没找到 gateway」与「找到了但端点没开」—— 后者才是用户实际踩到的状态。
3172
+
3173
+ 2026-10-04 实机:用户的 OpenClaw 不是 ClawHive 内嵌的那个,而是 OpenClaw Tray
3174
+ 装的 WSL 发行版(OpenClawGateway),`openclaw gateway --port 18789`,
3175
+ `auth.mode=token`,gateway URL 与共享令牌写在
3176
+ %APPDATA%/OpenClawTray/gateways.json;WS 与 HTTP 同端口多路复用,
3177
+ /health 活着但 /v1/models -> 404,因为 OpenAI 兼容端点默认关闭。
3178
+ 旧实现只看 ClawHive 的 gateway-port.json,于是把这种情况误报成“没找到”。
3179
+
3180
+ 这里起真实 HTTP 假 gateway,把 gateways.json 指向它,走真实 discover()。
3181
+ """
3182
+ import http.server
3183
+ import json as _json
3184
+ import tempfile as _tf
3185
+ import threading as _th
3186
+ from pathlib import Path as _P
3187
+
3188
+ import clawbridge
3189
+
3190
+ state = {"models": 404, "hits": []}
3191
+
3192
+ class Fake(http.server.BaseHTTPRequestHandler):
3193
+ def log_message(self, *a):
3194
+ pass
3195
+
3196
+ def _send(self, code, payload):
3197
+ body = _json.dumps(payload).encode("utf-8")
3198
+ self.send_response(code)
3199
+ self.send_header("Content-Type", "application/json")
3200
+ self.send_header("Content-Length", str(len(body)))
3201
+ self.end_headers()
3202
+ self.wfile.write(body)
3203
+
3204
+ def do_GET(self):
3205
+ state["hits"].append((self.path,
3206
+ (self.headers.get("Authorization") or "")))
3207
+ if self.path == "/health":
3208
+ return self._send(200, {"ok": True, "status": "live"})
3209
+ if self.path == "/v1/models":
3210
+ if state["models"] == 404:
3211
+ self.send_response(404)
3212
+ self.send_header("Content-Type", "text/plain")
3213
+ self.end_headers()
3214
+ self.wfile.write(b"Not Found")
3215
+ return
3216
+ return self._send(200, {"data": [{"id": "openclaw/default"}]})
3217
+ self._send(404, {"error": "nope"})
3218
+
3219
+ srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Fake)
3220
+ port = srv.server_address[1]
3221
+ _th.Thread(target=srv.serve_forever, daemon=True).start()
3222
+
3223
+ old_path = clawbridge._OPENCLAW_TRAY
3224
+ old_cache = dict(clawbridge._CACHE)
3225
+ with _tf.TemporaryDirectory() as tmp:
3226
+ cfg = _P(tmp) / "gateways.json"
3227
+ cfg.write_text(_json.dumps({"gateways": [
3228
+ {"friendlyName": "Local (OpenClawGateway)",
3229
+ "url": f"ws://127.0.0.1:{port}",
3230
+ "sharedGatewayToken": "tok-abc",
3231
+ "setupManagedDistroName": "OpenClawGateway"},
3232
+ # 局域网/远程 gateway 不得被静默使用(照片只会发到本机 gateway)
3233
+ {"friendlyName": "Office box", "url": "ws://192.168.1.5:18789",
3234
+ "sharedGatewayToken": "tok-lan"},
3235
+ ]}), encoding="utf-8")
3236
+ clawbridge._OPENCLAW_TRAY = str(cfg)
3237
+ clawbridge._CACHE.update({"at": 0.0, "info": None})
3238
+ try:
3239
+ found = clawbridge.tray_gateways()
3240
+ assert [g["port"] for g in found] == [port], \
3241
+ f"只应接受 loopback gateway,实际 {[g.get('base') for g in found]}"
3242
+ assert found[0]["name"] == "Local (OpenClawGateway)", found[0]
3243
+
3244
+ # ① 端点没开:必须报「找到了但端点未启用」+ 具体的配置键,
3245
+ # 而不是含糊的「没找到 gateway」;同时要带上是谁没就绪
3246
+ clawbridge._CACHE.update({"at": 0.0, "info": None})
3247
+ info = clawbridge.discover(force=True)
3248
+ assert info["enabled"] is False, info
3249
+ assert info["via"] == "openclaw-tray", info
3250
+ assert info["port"] == port, info
3251
+ assert "OpenClawGateway" in (info["name"] or ""), info
3252
+ assert "chatCompletions" in info["why"], \
3253
+ f"没说清要开哪个开关:{info['why']}"
3254
+ assert "未找到" not in info["why"], info["why"]
3255
+ # 探测必须带 Bearer 令牌:无鉴权探测在 token 模式下会被拒
3256
+ assert any(h[0] == "/v1/models" and h[1] == "Bearer tok-abc"
3257
+ for h in state["hits"]), state["hits"]
3258
+
3259
+ # ② 端点开了:发现成功并列出 agent 目标
3260
+ state["models"] = 200
3261
+ clawbridge._CACHE.update({"at": 0.0, "info": None})
3262
+ info2 = clawbridge.discover(force=True)
3263
+ assert info2["enabled"] is True, info2
3264
+ assert info2["agents"] == ["openclaw/default"], info2
3265
+ assert clawbridge.pick_agent("") == "openclaw/default", clawbridge.pick_agent("")
3266
+ st = clawbridge.status(True)
3267
+ assert st["ready"] and st["via"] == "openclaw-tray" and st["name"], st
3268
+ assert "tok-abc" not in _json.dumps(st), "status() 不能泄露令牌"
3269
+
3270
+ # ③ 端点没开时,status() 也要能报出「是谁没就绪」
3271
+ state["models"] = 404
3272
+ clawbridge._CACHE.update({"at": 0.0, "info": None})
3273
+ st2 = clawbridge.status(True)
3274
+ assert st2["ready"] is False and st2["via"] == "openclaw-tray", st2
3275
+ assert st2["name"] and "OpenClawGateway" in st2["name"], st2
3276
+ assert "chatCompletions" in st2["why"], st2["why"]
3277
+ finally:
3278
+ clawbridge._OPENCLAW_TRAY = old_path
3279
+ clawbridge._CACHE.update(old_cache)
3280
+ srv.shutdown()
3281
+ srv.server_close()
3282
+
3283
+
3284
+ def t_font_selection_has_cjk():
3285
+ """字体选择必须真的能画中文 —— 在服务器(Debian 布局)上曾经画成方块。
3286
+
3287
+ 2026-10-04 在 python:3.11-slim 容器里实测:装不装 fonts-noto-cjk,「朋友圈旅行」
3288
+ 与「敦煌戈壁滩」都渲染成完全相同的位图(都是 .notdef 方块),因为
3289
+ (a) /usr/share/fonts 只查了一层,而 Debian 把 Noto 放在 opentype/noto/ 下,
3290
+ (b) 候选名单里写的是 NotoSansSC-*,Debian 只提供 NotoSansCJK-*。
3291
+
3292
+ 本用例不依赖具体操作系统:用 TRIP_FONT_DIRS 造一个 Debian 式目录,看选择器能不能
3293
+ 递归找到它、并宁可选一个没列进名单但带中文的字体,也不返回列表更靠前却画不出中文的。
3294
+ """
3295
+ import shutil as _sh
3296
+ import tempfile as _tf
3297
+ from pathlib import Path as _P
3298
+
3299
+ import imquality as iq
3300
+
3301
+ dirs = tuple(str(d) for d in iq._font_search_dirs())
3302
+ index = iq._font_index(dirs)
3303
+ cjk_src = next((p for n, p in index.items() if iq._CJK_NAME.search(n)
3304
+ and iq._renders_cjk(iq.ImageFont.truetype(p, 32))), "")
3305
+ latin_src = ""
3306
+ for name in ("arial.ttf", "DejaVuSans.ttf", "georgia.ttf", "times.ttf", "Helvetica.ttc"):
3307
+ path = index.get(name)
3308
+ if not path:
3309
+ continue
3310
+ try:
3311
+ font = iq.ImageFont.truetype(path, 32)
3312
+ except OSError:
3313
+ continue
3314
+ if not iq._renders_cjk(font):
3315
+ latin_src = path
3316
+ break
3317
+
3318
+ # 反方向:缺字检测必须能认出“画不出中文”的字形 —— 用 Pillow 自带嵌入字体,
3319
+ # 它没有 CJK 字形,且任何机器上都在(不让用例依赖本机凑巧装了某种拉丁字体)。
3320
+ assert not iq._renders_cjk(iq.ImageFont.load_default()), \
3321
+ "无 CJK 字形的字体被判为能画中文 —— 缺字检测失效,服务器上会静默画出方块"
3322
+ if cjk_src:
3323
+ assert iq._renders_cjk(iq.ImageFont.truetype(cjk_src, 32)), \
3324
+ f"已知中文字体 {cjk_src} 被判为画不出中文"
3325
+
3326
+ if not cjk_src:
3327
+ # 本机压根没装中文字体:反向判据已验,不再拿找不到的字体硬凑。
3328
+ print(" (本机无中文字体,只验证了缺字检测不误报)")
3329
+ return
3330
+
3331
+ old_env = os.environ.get("TRIP_FONT_DIRS")
3332
+ with _tf.TemporaryDirectory() as tmp:
3333
+ root = _P(tmp)
3334
+ os.environ["TRIP_FONT_DIRS"] = str(root)
3335
+ iq._font_index.cache_clear()
3336
+ try:
3337
+ # Debian 式布局:嵌套两层 + 名单外的名字,必须被递归找到并选中
3338
+ (root / "opentype" / "noto").mkdir(parents=True)
3339
+ wanted = root / "opentype" / "noto" / "NotoSansCJKjp-Light.ttc"
3340
+ _sh.copyfile(cjk_src, wanted)
3341
+ iq._font_index.cache_clear()
3342
+ picked = iq.find_font(iq.FONT_ZH_BOLD, 32)
3343
+ assert iq._renders_cjk(picked), \
3344
+ f"递归目录里明明有中文字体,却选回了画不出中文的:{getattr(picked, 'path', picked)}"
3345
+ assert str(getattr(picked, "path", "")).endswith("NotoSansCJKjp-Light.ttc"), \
3346
+ ("应该选嵌套两层、名单外的那个中文字体(这才是「不靠硬编码名字」),"
3347
+ f"实际选了 {getattr(picked, 'path', picked)}")
3348
+
3349
+ # 像素级:两个不同汉字必须画出不同位图
3350
+ def ink(text):
3351
+ img = iq.Image.new("L", (80, 80), 255)
3352
+ iq.ImageDraw.Draw(img).text((3, 3), text, font=picked, fill=0)
3353
+ return img.tobytes()
3354
+ assert ink("朋") != ink("北"), "两个不同汉字画出同一张图 —— 就是缺字方块"
3355
+
3356
+ # 搜索集里只有画不出中文的字体时,宁可降级也不能假装能画。
3357
+ # 必须用一个全新的目录:刚才那个临时目录里已经放了中文字体。
3358
+ if latin_src:
3359
+ os.environ["TRIP_FONT_DIRS"] = str(root / "only-latin")
3360
+ (root / "only-latin").mkdir()
3361
+ _sh.copyfile(latin_src, root / "only-latin" / "latin-only.ttf")
3362
+ iq._font_index.cache_clear()
3363
+ only = iq.find_font(iq.FONT_ZH_BOLD, 32)
3364
+ assert not iq._renders_cjk(only), \
3365
+ "搜索集里只有拉丁字体,却被报告为能画中文(选字逻辑在蒙)"
3366
+ else:
3367
+ print(" (本机无拉丁字体文件,略过“只有拉丁字体”的降级子用例)")
3368
+ finally:
3369
+ iq._font_index.cache_clear()
3370
+ if old_env is None:
3371
+ os.environ.pop("TRIP_FONT_DIRS", None)
3372
+ else:
3373
+ os.environ["TRIP_FONT_DIRS"] = old_env
3374
+
3375
+
3376
+ def t_linux_font_layout_declared():
3377
+ """font 目录必须递归搜索:Debian 把 Noto 放在 opentype/noto/ 下。
3378
+
3379
+ 这条不依赖机器上有没有中文字体,只查源码契约:搜素用 rglob 而不是直接拼一层;
3380
+ 候选名单里必须包含各发行版真实使用的 Noto CJK 文件名。
3381
+ """
3382
+ src = (Path(__file__).parent / "imquality.py").read_text(encoding="utf-8")
3383
+ assert "rglob" in src, "字体目录退回单层扫描 —— Debian 的 opentype/noto/ 会找不到"
3384
+ for name in ("NotoSansCJK-Bold.ttc", "NotoSansCJK-Regular.ttc", "NotoSansCJKsc-Regular.otf"):
3385
+ assert name in src, f"候选名单缺少发行版实际使用的字体名:{name}"
3386
+ assert "_renders_cjk" in src and "load_default" in src, "缺少「画不出中文就继续找」的判据"
3387
+
3388
+
3163
3389
  CASES = {
3164
3390
  "打包交付与下载(zip/预览/越权拦截)": t_finish_deliverables_and_download,
3165
3391
  "AI 结果可见性+三套候选(每步输出框/agent轨道)": t_claw_grade_candidates_and_output_visibility,
3166
3392
  "Agent自带模型轨道也给三档候选(预览/采用)": t_agent_model_grade_candidates,
3393
+ "字体选择真的能画中文(递归+缺字检测)": t_font_selection_has_cjk,
3394
+ "字体目录递归与发行版字体名": t_linux_font_layout_declared,
3395
+ "OpenClaw WSL gateway 发现(端点未启用的诚实报告)": t_tray_gateway_discovery,
3167
3396
  "存活探测不误杀冷实例/代理环境": t_liveness_probe_is_cheap_and_proxy_safe,
3168
3397
  "审片页 STUDIO 链接跟随在跑实例": t_review_studio_link_follows_instance,
3169
3398
  "canon 命名归一化": t_canon,
@@ -3098,21 +3098,52 @@ function syncExecutor() {
3098
3098
  b.classList.toggle("on", b.dataset.exec === cur);
3099
3099
  b.classList.toggle("agent", b.dataset.exec === "clawhive" && b.classList.contains("on"));
3100
3100
  });
3101
- const n = $("exec-note");
3102
- if (!n) return;
3103
- if (!st.ready) {
3104
- n.innerHTML = `ClawHive 通道未就绪(${H(st.why || "")}),现在只能用本地网关`;
3105
- } else if (cur === "local") {
3106
- n.innerHTML = "用工作台自己的视觉 API 看片出参数:快,但过程不会进入 ClawHive 会话";
3107
- } else if (st.busy) {
3108
- // A session serves one turn at a time: delegating into a conversation that
3109
- // is mid-turn cannot come back (it queues until a timeout). Say so BEFORE
3110
- // the click instead of letting the button look dead for seven minutes.
3111
- n.innerHTML = `<b>ClawHive 正在跑上一轮</b>,一个会话一次只做一件事:` +
3112
- `现在派回去只会排队到超时。等它空下来,或切到「本地」用本地网关做这一步。`;
3113
- } else {
3114
- const agent = (st.agents || []).find((a) => a.includes("main")) || (st.agents || [])[0] || "agent";
3115
- n.innerHTML = `<b>${H(agent)}</b> 会自己看照片、定参数、直接改工作台;它的回答会出现在你的 ClawHive 会话里`;
3101
+ const n = $("exec-note");
3102
+ if (!n) return;
3103
+ // Two gateway families are discoverable now: ClawHive's embedded one and the
3104
+ // OpenClaw desktop app's WSL-backed one. They differ in what the workbench can
3105
+ // promise, so the note names the family instead of always saying "ClawHive".
3106
+ const tray = st.via === "openclaw-tray";
3107
+ const gwName = H(st.name || (tray ? "OpenClaw" : "ClawHive"));
3108
+ let msg = "";
3109
+ let warn = false;
3110
+ if (st.hidden) {
3111
+ // 「连接已隐藏」 is a user preference, not a fault: stay quiet instead of
3112
+ // printing a not-ready warning at the top of every step.
3113
+ msg = "";
3114
+ } else if (!st.ready) {
3115
+ msg = `${gwName} 通道未就绪(${H(st.why || "")})`;
3116
+ warn = true;
3117
+ } else if (cur === "local") {
3118
+ msg = `用工作台自己的视觉 API 看片出参数:快,但过程不会进入 ${tray ? "OpenClaw" : "ClawHive"} 会话`;
3119
+ } else if (st.busy) {
3120
+ // A session serves one turn at a time: delegating into a conversation that
3121
+ // is mid-turn cannot come back (it queues until a timeout). Say so BEFORE
3122
+ // the click instead of letting the button look dead for seven minutes.
3123
+ msg = `<b>${gwName} 正在跑上一轮</b>,一个会话一次只做一件事:` +
3124
+ `现在派回去只会排队到超时。等它空下来,或切到「本地」用本地网关做这一步。`;
3125
+ warn = true;
3126
+ } else if (tray) {
3127
+ // The WSL gateway keeps its session book in SQLite inside the distro, which
3128
+ // Windows cannot read (the wsl$ UNC paths are not reachable from here), so
3129
+ // the workbench cannot target the conversation the user is looking at — and
3130
+ // it cannot pre-detect a mid-turn session either. Promise less, be honest.
3131
+ const agent = (st.agents || [])[0] || "openclaw/default";
3132
+ msg = `<b>${H(agent)}</b> 会自己看照片、定参数、直接改工作台(OpenClaw WSL gateway);` +
3133
+ `会话由工作台自己建,无法绑定你正在看的那个。`;
3134
+ } else {
3135
+ const agent = (st.agents || []).find((a) => a.includes("main")) || (st.agents || [])[0] || "agent";
3136
+ msg = `<b>${H(agent)}</b> 会自己看照片、定参数、直接改工作台;它的回答会出现在你的 ClawHive 会话里`;
3137
+ }
3138
+ n.innerHTML = msg;
3139
+ // The bar holding #exec-note has been display:none since the six-step rewrite,
3140
+ // so everything above was invisible in practice — including 「通道未就绪」.
3141
+ // The deck is where the user actually looks, so the same line goes there.
3142
+ const deckNote = $("claw-note");
3143
+ if (deckNote) {
3144
+ deckNote.innerHTML = msg;
3145
+ deckNote.style.display = msg ? "" : "none";
3146
+ deckNote.style.color = warn ? "#8a3b2f" : "";
3116
3147
  }
3117
3148
  const btnSel = $("btn-reselect9");
3118
3149
  const hintSel = $("reselect9-hint");
package/bin/install.js CHANGED
@@ -16,7 +16,7 @@ const path = require('path');
16
16
  const os = require('os');
17
17
  const { execSync, spawnSync } = require('child_process');
18
18
 
19
- const VERSION = '1.2.0';
19
+ const VERSION = '1.3.1';
20
20
  const SKILL_NAME = 'trip-moments';
21
21
 
22
22
  const c = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trip-moments-skill",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "AI 朋友圈旅行出图助手 (trip-moments skill) — 工业级全流程旅行摄影智能出图与朋友圈九宫格套件",
5
5
  "main": "bin/install.js",
6
6
  "bin": {