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.
- package/.agents/skills/trip-moments/scripts/agent_transport.py +37 -11
- package/.agents/skills/trip-moments/scripts/agent_ui.py +3 -2
- package/.agents/skills/trip-moments/scripts/clawbridge.py +208 -105
- package/.agents/skills/trip-moments/scripts/imquality.py +140 -27
- package/.agents/skills/trip-moments/scripts/selftest.py +229 -0
- package/.agents/skills/trip-moments/scripts/studio_ui.py +46 -15
- package/bin/install.js +1 -1
- package/package.json +1 -1
|
@@ -355,7 +355,9 @@ def _claw_module():
|
|
|
355
355
|
|
|
356
356
|
|
|
357
357
|
class OpenClawTransport:
|
|
358
|
-
"""Persistent-session transport over
|
|
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
|
-
|
|
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("
|
|
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
|
|
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
|
|
395
|
-
#
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
|
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
|
|
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
|
|
14
|
-
to
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 "
|
|
326
|
-
if not info.get("enabled"):
|
|
327
|
-
raise RuntimeError(info.get("why") or "
|
|
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"
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
279
|
+
picked = consider(ImageFont.truetype(path, size))
|
|
181
280
|
except OSError:
|
|
182
|
-
|
|
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
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
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