trip-moments-skill 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.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/selftest.py +122 -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"]
|
|
@@ -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,126 @@ 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
|
+
|
|
3163
3284
|
CASES = {
|
|
3164
3285
|
"打包交付与下载(zip/预览/越权拦截)": t_finish_deliverables_and_download,
|
|
3165
3286
|
"AI 结果可见性+三套候选(每步输出框/agent轨道)": t_claw_grade_candidates_and_output_visibility,
|
|
3166
3287
|
"Agent自带模型轨道也给三档候选(预览/采用)": t_agent_model_grade_candidates,
|
|
3288
|
+
"OpenClaw WSL gateway 发现(端点未启用的诚实报告)": t_tray_gateway_discovery,
|
|
3167
3289
|
"存活探测不误杀冷实例/代理环境": t_liveness_probe_is_cheap_and_proxy_safe,
|
|
3168
3290
|
"审片页 STUDIO 链接跟随在跑实例": t_review_studio_link_follows_instance,
|
|
3169
3291
|
"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