trip-moments-skill 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/trip-moments/scripts/agent_session.py +15 -5
- package/.agents/skills/trip-moments/scripts/agent_transport.py +115 -0
- package/.agents/skills/trip-moments/scripts/agent_ui.py +8 -4
- package/.agents/skills/trip-moments/scripts/clawbridge.py +16 -7
- package/.agents/skills/trip-moments/scripts/selftest.py +332 -0
- package/.agents/skills/trip-moments/scripts/studio.py +60 -6
- package/.agents/skills/trip-moments/scripts/studio_ui.py +79 -11
- package/bin/install.js +1 -1
- package/package.json +1 -1
|
@@ -8,7 +8,8 @@ import time
|
|
|
8
8
|
import uuid
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
|
-
from agent_transport import AgentError, CodexTransport, HermesTransport,
|
|
11
|
+
from agent_transport import (AgentError, CodexTransport, HermesTransport,
|
|
12
|
+
OpenClawTransport, local_url)
|
|
12
13
|
|
|
13
14
|
ACTIVE = {"starting", "running", "waiting_for_approval", "waiting_for_input", "stopping", "reconnecting"}
|
|
14
15
|
|
|
@@ -79,17 +80,22 @@ class AgentSession:
|
|
|
79
80
|
if self.status in ACTIVE:
|
|
80
81
|
raise AgentError("请先结束当前运行,再更换 Agent 连接")
|
|
81
82
|
provider = str(body.get("provider") or self.config.get("provider") or "hermes")
|
|
82
|
-
if provider not in {"hermes", "codex"}:
|
|
83
|
-
raise AgentError("请选择 Hermes 或
|
|
83
|
+
if provider not in {"hermes", "codex", "openclaw"}:
|
|
84
|
+
raise AgentError("请选择 Hermes、Codex 或 OpenClaw")
|
|
84
85
|
base = local_url(body.get("base_url") or self.config.get("base_url") or "http://127.0.0.1:8642")
|
|
85
86
|
key = str(body.get("key") or "").strip()
|
|
86
87
|
if not key and self.token_path.is_file():
|
|
87
88
|
key = self.token_path.read_text(encoding="utf-8").strip()
|
|
88
|
-
|
|
89
|
+
if provider == "hermes":
|
|
90
|
+
client = HermesTransport(base, key)
|
|
91
|
+
elif provider == "codex":
|
|
92
|
+
client = CodexTransport(self.ws.photos_dir)
|
|
93
|
+
else:
|
|
94
|
+
client = OpenClawTransport(self.ws.photos_dir)
|
|
89
95
|
try:
|
|
90
96
|
result = client.probe()
|
|
91
97
|
old = self.config
|
|
92
|
-
sid = old.get("session_id", "") if old.get("provider") == provider and (provider
|
|
98
|
+
sid = old.get("session_id", "") if old.get("provider") == provider and (provider in {"codex", "openclaw"} or old.get("base_url") == base) else ""
|
|
93
99
|
if sid:
|
|
94
100
|
if provider == "codex" and not old.get("owned"):
|
|
95
101
|
raise AgentError("不能接管其他 Codex 桌面任务,请创建工作台专用会话")
|
|
@@ -128,6 +134,10 @@ class AgentSession:
|
|
|
128
134
|
if create:
|
|
129
135
|
title = str(body.get("title") or "Trip Moments · " + self.ws.photos_dir.name)[:100]
|
|
130
136
|
sid = self.client.create(title, self.ws.photos_dir)
|
|
137
|
+
if self.config.get("provider") == "openclaw" and not sid:
|
|
138
|
+
# No session-creation endpoint: bind to the conversation
|
|
139
|
+
# the user currently has open in ClawHive instead.
|
|
140
|
+
sid = self.client.bind("")
|
|
131
141
|
else:
|
|
132
142
|
if not sid:
|
|
133
143
|
raise AgentError("请明确选择一个会话")
|
|
@@ -341,3 +341,118 @@ class CodexTransport:
|
|
|
341
341
|
self.process.wait(timeout=8)
|
|
342
342
|
except (OSError, subprocess.TimeoutExpired):
|
|
343
343
|
self.process.terminate()
|
|
344
|
+
|
|
345
|
+
# ---------------------------------------------------------------------------
|
|
346
|
+
# OpenClaw (ClawHive) — the desktop agent's OpenAI-compatible endpoint.
|
|
347
|
+
# Reuses clawbridge discovery; NEVER imports it here (clawbridge imports stay
|
|
348
|
+
# local so transports remain importable without ClawHive installed).
|
|
349
|
+
# ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _claw_module():
|
|
353
|
+
import clawbridge
|
|
354
|
+
return clawbridge
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class OpenClawTransport:
|
|
358
|
+
"""Persistent-session transport over ClawHive's chat-completions endpoint.
|
|
359
|
+
|
|
360
|
+
The gateway serves ONE turn per session lane (#235): `start()` is a full
|
|
361
|
+
blocking agent turn, not a run-id handle. We still model it as
|
|
362
|
+
start/status/events so AgentSession keeps a single code path — events
|
|
363
|
+
replays the single reply, and status walks the turn to completion.
|
|
364
|
+
Approvals/steer do not exist on this endpoint; the whole turn is the
|
|
365
|
+
agent's own.
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
provider = "openclaw"
|
|
369
|
+
|
|
370
|
+
def __init__(self, cwd=""):
|
|
371
|
+
self.claw = _claw_module()
|
|
372
|
+
self.lock = threading.RLock()
|
|
373
|
+
self._thread = [] # [(role, text)] replayed as history
|
|
374
|
+
self._done = threading.Event()
|
|
375
|
+
self._events_queue = queue.Queue()
|
|
376
|
+
|
|
377
|
+
# -- connection ---------------------------------------------------------
|
|
378
|
+
def probe(self):
|
|
379
|
+
info = self.claw.discover(force=True)
|
|
380
|
+
if not info.get("base"):
|
|
381
|
+
raise AgentError("未找到运行中的 ClawHive gateway:" + (info.get("why") or ""))
|
|
382
|
+
if not info.get("enabled"):
|
|
383
|
+
raise AgentError("ClawHive gateway 的 OpenAI 兼容端点未启用:" + (info.get("why") or ""))
|
|
384
|
+
return {"ready": True, "provider": self.provider, "native_sessions": True,
|
|
385
|
+
"streaming": False, "steer": False, "approvals": False,
|
|
386
|
+
"port": info.get("port"), "agents": info.get("agents") or []}
|
|
387
|
+
|
|
388
|
+
def sessions(self):
|
|
389
|
+
# The desktop conversation list belongs to ClawHive's own UI; offering
|
|
390
|
+
# it here would invite binding to a session nobody is looking at.
|
|
391
|
+
return []
|
|
392
|
+
|
|
393
|
+
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).
|
|
396
|
+
return ""
|
|
397
|
+
|
|
398
|
+
def bind(self, sid):
|
|
399
|
+
key = sid or self.claw.active_session()
|
|
400
|
+
if not key:
|
|
401
|
+
raise AgentError("没有找到正在使用的 ClawHive 会话;先在 ClawHive 里打开一个对话")
|
|
402
|
+
return key
|
|
403
|
+
|
|
404
|
+
# -- turns ---------------------------------------------------------------
|
|
405
|
+
def start(self, sid, text, key):
|
|
406
|
+
# One request == one whole turn. Run it on this thread and let
|
|
407
|
+
# AgentSession's status() poll see "running" until _done is set.
|
|
408
|
+
agent = self.claw._agent_dir_name(self.claw.pick_agent(""))
|
|
409
|
+
session_key = self.claw.active_session(agent) if not sid else sid
|
|
410
|
+
if self.claw.session_busy(session_key, agent):
|
|
411
|
+
raise self.claw.ClawBridgeBusy(self.claw.BUSY_HINT)
|
|
412
|
+
self._done.clear()
|
|
413
|
+
threading.Thread(target=self._turn, args=(agent, session_key, text),
|
|
414
|
+
daemon=True).start()
|
|
415
|
+
return session_key or "openclaw"
|
|
416
|
+
|
|
417
|
+
def _turn(self, agent, session_key, text):
|
|
418
|
+
try:
|
|
419
|
+
out = self.claw.ask(text, agent=agent, session_key=session_key,
|
|
420
|
+
timeout=420)
|
|
421
|
+
self._reply = out.get("text") or ""
|
|
422
|
+
self.emit({"type": "assistant.message", "text": self._reply})
|
|
423
|
+
self.emit({"type": "run.completed", "output": self._reply})
|
|
424
|
+
except Exception as exc: # noqa: BLE001 - surfaced verbatim in the UI
|
|
425
|
+
msg = str(exc)[:400]
|
|
426
|
+
self.emit({"type": "run.failed", "error": msg})
|
|
427
|
+
finally:
|
|
428
|
+
self._done.set()
|
|
429
|
+
|
|
430
|
+
def status(self, rid):
|
|
431
|
+
with self.lock:
|
|
432
|
+
finished = self._done.is_set()
|
|
433
|
+
return {"status": "completed" if finished else "running"}
|
|
434
|
+
|
|
435
|
+
def events(self, rid, emit):
|
|
436
|
+
# ClawBridgeBusy must surface, not loop: AgentError propagates like the
|
|
437
|
+
# other transports; the gateway lane check already refused in start().
|
|
438
|
+
while True:
|
|
439
|
+
evt = self._events_queue.get(timeout=30)
|
|
440
|
+
if evt is None:
|
|
441
|
+
if not self._done.is_set():
|
|
442
|
+
continue
|
|
443
|
+
return
|
|
444
|
+
emit(evt)
|
|
445
|
+
if evt.get("type") in {"run.completed", "run.failed", "run.cancelled",
|
|
446
|
+
"run.interrupted"}:
|
|
447
|
+
return
|
|
448
|
+
|
|
449
|
+
def emit(self, event):
|
|
450
|
+
self._events_queue.put(event)
|
|
451
|
+
|
|
452
|
+
# -- not supported by the endpoint ---------------------------------------
|
|
453
|
+
def control(self, sid, rid, action, body):
|
|
454
|
+
raise AgentError("OpenClaw 通道不支持该操作(整轮执行,无可中断/批准的中间态)")
|
|
455
|
+
|
|
456
|
+
def close(self):
|
|
457
|
+
self._done.set()
|
|
458
|
+
self._events_queue.put(None)
|
|
@@ -50,7 +50,7 @@ AGENT_SHELL = r"""
|
|
|
50
50
|
<div class="ap-body" id="ap-body">
|
|
51
51
|
<details class="ap-setup" id="ap-setup" open><summary>连接与会话绑定</summary>
|
|
52
52
|
<label for="ap-kind">Agent 通道</label><select id="ap-kind" onchange="agentProviderHint()">
|
|
53
|
-
<option value="hermes">Hermes · 原生持续会话</option><option value="codex">Codex App Server · 工作台专用会话</option></select>
|
|
53
|
+
<option value="hermes">Hermes · 原生持续会话</option><option value="codex">Codex App Server · 工作台专用会话</option><option value="openclaw">OpenClaw (ClawHive) · 活跃会话直连</option></select>
|
|
54
54
|
<div id="ap-hermes-fields"><label for="ap-url">本机 Hermes API 地址</label><input id="ap-url" value="http://127.0.0.1:8642">
|
|
55
55
|
<label for="ap-key">API 密钥(留空读取本机 Hermes 配置)</label><input id="ap-key" type="password" autocomplete="off"></div>
|
|
56
56
|
<p class="note" id="ap-hint" style="margin-top:8px"></p>
|
|
@@ -223,12 +223,15 @@ function openAgentPanel() {
|
|
|
223
223
|
}
|
|
224
224
|
function closeAgentPanel() { AP.open = false; $("agent-panel").hidden = true; }
|
|
225
225
|
function agentProviderHint() {
|
|
226
|
-
const
|
|
226
|
+
const kind = $("ap-kind").value;
|
|
227
|
+
const hermes = kind === "hermes";
|
|
227
228
|
$("ap-hermes-fields").style.display = hermes ? "" : "none";
|
|
228
229
|
$("ap-existing").style.display = hermes ? "" : "none";
|
|
229
230
|
$("ap-hint").textContent = hermes
|
|
230
231
|
? "绑定 Hermes 原生会话,后续回复与步骤按钮都延续该会话。连接密钥仅由本机服务使用。"
|
|
231
|
-
:
|
|
232
|
+
: kind === "codex"
|
|
233
|
+
? "使用本机已登录的 Codex CLI,创建工作台专用的持续任务;不接管你当前 Codex 桌面聊天。工具批准仍需你确认。"
|
|
234
|
+
: "直连 ClawHive 内嵌的 OpenClaw gateway,绑定你正在看的那个会话;整轮执行,无中间批准。需在 openclaw.json 启用 chatCompletions 端点。";
|
|
232
235
|
}
|
|
233
236
|
async function pollAgent() {
|
|
234
237
|
if (AP.polling) return;
|
|
@@ -256,7 +259,8 @@ function paintAgent(data) {
|
|
|
256
259
|
starting:"正在发起本轮", running:"Agent 正在处理这一轮", waiting_for_approval:"等待你批准当前工具操作",
|
|
257
260
|
waiting_for_input:"Agent 有问题需要你回答", stopping:"正在停止,尚未确认结束",
|
|
258
261
|
reconnecting:"事件连接中断,正在核对真实运行状态", stopped:"本轮已停止", failed:"本轮失败,可继续在同一会话说明要求" };
|
|
259
|
-
const provider = data.provider === "codex" ? "Codex App Server"
|
|
262
|
+
const provider = data.provider === "codex" ? "Codex App Server"
|
|
263
|
+
: data.provider === "openclaw" ? "OpenClaw (ClawHive)" : "Hermes";
|
|
260
264
|
$("ap-provider").textContent = provider + " · 连接与会话绑定";
|
|
261
265
|
$("ap-session").textContent = data.session_id ? "绑定会话:" + data.session_id : "尚未绑定会话";
|
|
262
266
|
$("ap-status").textContent = (states[data.status] || data.status) + (data.error ? " · " + data.error : "");
|
|
@@ -591,13 +591,22 @@ def _studio_instruction_impl(action: str, ctx: dict, state: dict, base: str = ""
|
|
|
591
591
|
" (也可逐格调用 POST /api/framing;ratio 为 null 表示保留原图)\n"
|
|
592
592
|
"4. 只回几行:列出你把哪几格定成了什么画幅、保留了哪几格原图。")
|
|
593
593
|
|
|
594
|
-
if action == "caption":
|
|
595
|
-
return (head + "\n请做:为这组九宫格写朋友圈文案。\n"
|
|
596
|
-
+ _slot_lines(slots) + "\n\n"
|
|
597
|
-
"写 3 版不同语气(文艺诗意 / 纪实行记 / 松弛日常),每版 1-3 句、可直接发。\n"
|
|
598
|
-
"**只写画面里真实存在的东西**;括号里是选片时的标注,必要时核图确认。"
|
|
599
|
-
"如果哪几张的标注和画面不符,在最后用一行提醒我。\n"
|
|
600
|
-
"
|
|
594
|
+
if action == "caption":
|
|
595
|
+
return (head + "\n请做:为这组九宫格写朋友圈文案。\n"
|
|
596
|
+
+ _slot_lines(slots) + "\n\n"
|
|
597
|
+
"写 3 版不同语气(文艺诗意 / 纪实行记 / 松弛日常),每版 1-3 句、可直接发。\n"
|
|
598
|
+
"**只写画面里真实存在的东西**;括号里是选片时的标注,必要时核图确认。"
|
|
599
|
+
"如果哪几张的标注和画面不符,在最后用一行提醒我。\n"
|
|
600
|
+
"写好后**必须登记到工作台**:只说在会话里不算完成,用户在工作台文案面板上"
|
|
601
|
+
"会一直看到「还没有文案」,打包时也拿不到:\n"
|
|
602
|
+
f" POST {base}/api/caption\n"
|
|
603
|
+
' {"narrative": "一句话叙事线(可留空)",\n'
|
|
604
|
+
' "versions": [{"style": "文艺诗意", "text": "…"},\n'
|
|
605
|
+
' {"style": "纪实行记", "text": "…"},\n'
|
|
606
|
+
' {"style": "松弛日常", "text": "…"}],\n'
|
|
607
|
+
' "tags": ["可选标签"]}\n'
|
|
608
|
+
"(versions 至少一版;登记后工作台会立即显示并可逐版复制)\n"
|
|
609
|
+
"最后只回一两句:三版各是什么语气。不要复述全文,用户在工作台上就能看到。")
|
|
601
610
|
|
|
602
611
|
if action == "title":
|
|
603
612
|
return (head + f"\n请做:给第 {slot_no} 格拟标题。\n"
|
|
@@ -1687,6 +1687,130 @@ def t_claw_discovery_routing():
|
|
|
1687
1687
|
httpd.server_close()
|
|
1688
1688
|
|
|
1689
1689
|
|
|
1690
|
+
def t_openclaw_transport_channel():
|
|
1691
|
+
"""第三条 Agent 通道 OpenClaw (ClawHive):transport 契约真实 HTTP 验证。
|
|
1692
|
+
|
|
1693
|
+
连接 = 发现 gateway + /v1/models 端点开启;绑定 = ClawHive 当前活跃会话;
|
|
1694
|
+
start = 整轮 chat-completions(后台线程),events 重放回复并以 run.completed
|
|
1695
|
+
收尾;忙会话在 start 即拒绝(不排队到超时);控制操作(批准/steer)不支持。
|
|
1696
|
+
"""
|
|
1697
|
+
import threading
|
|
1698
|
+
import time as _time
|
|
1699
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
1700
|
+
|
|
1701
|
+
import agent_transport
|
|
1702
|
+
import clawbridge
|
|
1703
|
+
|
|
1704
|
+
class FakeGw(BaseHTTPRequestHandler):
|
|
1705
|
+
hits = {"models": 0, "chat": 0}
|
|
1706
|
+
timeout = 10
|
|
1707
|
+
|
|
1708
|
+
def log_message(self, *a):
|
|
1709
|
+
pass
|
|
1710
|
+
|
|
1711
|
+
def _json(self, obj, code=200):
|
|
1712
|
+
body = json.dumps(obj).encode("utf-8")
|
|
1713
|
+
self.send_response(code)
|
|
1714
|
+
self.send_header("Content-Type", "application/json")
|
|
1715
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1716
|
+
self.end_headers()
|
|
1717
|
+
self.wfile.write(body)
|
|
1718
|
+
|
|
1719
|
+
def do_GET(self):
|
|
1720
|
+
if self.path == "/health":
|
|
1721
|
+
self._json({"ok": True})
|
|
1722
|
+
elif self.path == "/v1/models":
|
|
1723
|
+
FakeGw.hits["models"] += 1
|
|
1724
|
+
self._json({"data": [{"id": "openclaw/main"}]})
|
|
1725
|
+
else:
|
|
1726
|
+
self._json({}, 404)
|
|
1727
|
+
|
|
1728
|
+
def do_POST(self):
|
|
1729
|
+
FakeGw.hits["chat"] += 1
|
|
1730
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
1731
|
+
payload = json.loads(self.rfile.read(length) or b"{}")
|
|
1732
|
+
FakeGw.payload = payload
|
|
1733
|
+
FakeGw.headers_in = dict(self.headers)
|
|
1734
|
+
self._json({"choices": [{"message": {"content": "已把第 3 格调色。"}}],
|
|
1735
|
+
"model": "openclaw/main", "usage": {"total_tokens": 7}})
|
|
1736
|
+
|
|
1737
|
+
httpd = ThreadingHTTPServer(("127.0.0.1", 0), FakeGw)
|
|
1738
|
+
gw_port = httpd.server_address[1]
|
|
1739
|
+
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
1740
|
+
try:
|
|
1741
|
+
import requests as _rq_wait
|
|
1742
|
+
for _ in range(60):
|
|
1743
|
+
try:
|
|
1744
|
+
if _rq_wait.get(f"http://127.0.0.1:{gw_port}/health",
|
|
1745
|
+
timeout=1).status_code == 200:
|
|
1746
|
+
break
|
|
1747
|
+
except Exception:
|
|
1748
|
+
pass
|
|
1749
|
+
_time.sleep(0.05)
|
|
1750
|
+
with tempfile.TemporaryDirectory() as td:
|
|
1751
|
+
state = Path(td) / "openclaw" / "state"
|
|
1752
|
+
state.mkdir(parents=True)
|
|
1753
|
+
(state / "gateway-port.json").write_text(json.dumps({"port": gw_port}), encoding="utf-8")
|
|
1754
|
+
(state / "gateway-token").write_text("tok-oc", encoding="utf-8")
|
|
1755
|
+
sess = state / "agents" / "main" / "sessions"
|
|
1756
|
+
sess.mkdir(parents=True)
|
|
1757
|
+
(sess / "sessions.json").write_text(json.dumps({
|
|
1758
|
+
"agent:main:clawhive:live": {"lastInteractionAt": 999, "status": "done",
|
|
1759
|
+
"sessionId": "live"},
|
|
1760
|
+
}), encoding="utf-8")
|
|
1761
|
+
|
|
1762
|
+
real_globs = clawbridge._STATE_GLOBS
|
|
1763
|
+
clawbridge._STATE_GLOBS = [str(state)]
|
|
1764
|
+
clawbridge._CACHE.update({"at": 0.0, "info": None})
|
|
1765
|
+
try:
|
|
1766
|
+
client = agent_transport.OpenClawTransport("")
|
|
1767
|
+
# 1) probe:端点开启才算就绪
|
|
1768
|
+
caps = client.probe()
|
|
1769
|
+
assert caps["ready"] and caps["provider"] == "openclaw", caps
|
|
1770
|
+
assert "openclaw/main" in caps["agents"], caps
|
|
1771
|
+
# 2) bind:取最近活跃会话;无活跃会话必须报错而不是静默空绑定
|
|
1772
|
+
assert client.bind("") == "agent:main:clawhive:live"
|
|
1773
|
+
# 3) start:整轮执行,回复经 events 回放,run.completed 收尾
|
|
1774
|
+
events = []
|
|
1775
|
+
rid = client.start("agent:main:clawhive:live", "请调色", "k1")
|
|
1776
|
+
client.events(rid, events.append)
|
|
1777
|
+
types = [e.get("type") for e in events]
|
|
1778
|
+
assert "assistant.message" in types and types[-1] == "run.completed", types
|
|
1779
|
+
reply = next(e["text"] for e in events if e.get("type") == "assistant.message")
|
|
1780
|
+
assert reply == "已把第 3 格调色。", reply
|
|
1781
|
+
assert FakeGw.payload["model"] == "openclaw/main", FakeGw.payload
|
|
1782
|
+
# HTTP header names are case-insensitive; urllib3 normalizes
|
|
1783
|
+
# x-openclaw-session-key -> X-Openclaw-Session-Key on the wire.
|
|
1784
|
+
hdrs = {k.lower(): v for k, v in FakeGw.headers_in.items()}
|
|
1785
|
+
assert hdrs.get("x-openclaw-session-key") == "agent:main:clawhive:live", hdrs
|
|
1786
|
+
# 4) 控制操作不支持:整轮执行没有中间态可批准/中断
|
|
1787
|
+
try:
|
|
1788
|
+
client.control("s", "r", "approve", {"request_id": "x"})
|
|
1789
|
+
raise AssertionError("approve 不应被支持")
|
|
1790
|
+
except agent_transport.AgentError:
|
|
1791
|
+
pass
|
|
1792
|
+
client.close()
|
|
1793
|
+
finally:
|
|
1794
|
+
clawbridge._STATE_GLOBS = real_globs
|
|
1795
|
+
clawbridge._CACHE.update({"at": 0.0, "info": None})
|
|
1796
|
+
|
|
1797
|
+
# 5) 白名单:connect 接受 openclaw、拒绝未知 provider
|
|
1798
|
+
import agent_session as _as
|
|
1799
|
+
src = Path(_as.__file__).read_text(encoding="utf-8")
|
|
1800
|
+
assert '"openclaw"' in src and "OpenClawTransport" in src, \
|
|
1801
|
+
"agent_session 未接入 openclaw 通道"
|
|
1802
|
+
|
|
1803
|
+
# 6) UI:下拉必须有第三项,提示文案不泄露密钥
|
|
1804
|
+
import agent_ui as _aui
|
|
1805
|
+
ui = _aui.AGENT_SHELL + _aui.AGENT_SCRIPT
|
|
1806
|
+
assert 'value="openclaw"' in ui, "Agent 通道下拉缺 OpenClaw 选项"
|
|
1807
|
+
assert "OpenClaw (ClawHive)" in ui, "OpenClaw 显示名缺失"
|
|
1808
|
+
assert 'data.provider === "openclaw"' in ui, "provider 显示名分支缺失"
|
|
1809
|
+
finally:
|
|
1810
|
+
httpd.shutdown()
|
|
1811
|
+
httpd.server_close()
|
|
1812
|
+
|
|
1813
|
+
|
|
1690
1814
|
def t_claw_busy_guard():
|
|
1691
1815
|
"""忙着的会话不能被派活:必须毫秒级拒绝,而不是排队到超时。
|
|
1692
1816
|
|
|
@@ -2805,9 +2929,216 @@ def t_claw_grade_candidates_and_output_visibility():
|
|
|
2805
2929
|
assert "data.build" in html, "页面没有随服务端构建号自刷新的逻辑"
|
|
2806
2930
|
|
|
2807
2931
|
|
|
2932
|
+
def t_agent_model_grade_candidates():
|
|
2933
|
+
"""Agent 自带模型轨道:每一步的结果都要落到工作台上,不能只在会话里说。
|
|
2934
|
+
|
|
2935
|
+
2026-10-04 用户实测两处空白:
|
|
2936
|
+
a) 挂着 Agent 会话点「生成调色方案」,页面既没有 克制/标准/浓郁 三行,也没有每行的
|
|
2937
|
+
预览/采用 按钮。三处协同缺口:简报把「工作台会自动生成三套候选」改写掉了、
|
|
2938
|
+
agent_turn 的 work() 不像 clawhive 轨道那样从落地的配方推导候选、前端又在
|
|
2939
|
+
useAgentModel() 下把候选面板整个隐藏。
|
|
2940
|
+
b) 点「生成 3 版文案」后,文案面板仍然回「还没有文案」——因为 Agent 根本没有能
|
|
2941
|
+
登记文案的接口(选片/裁切/调色/修图/出片都有,只有文案没有)。
|
|
2942
|
+
|
|
2943
|
+
这里用真实 HTTP 跑 /api/agent/send,会话的 run() 换成「通过工作台自己的接口落地结果」
|
|
2944
|
+
——真实 agent 就是这么做的——然后断言:三档候选真的随任务结果回到页面;Agent 只说不
|
|
2945
|
+
做时不拿旧配方冒充本轮方案;文案登记后 state 真的带得回来、空数组会被拒。
|
|
2946
|
+
"""
|
|
2947
|
+
import tempfile as _tf
|
|
2948
|
+
import threading
|
|
2949
|
+
import time as _time
|
|
2950
|
+
import urllib.error
|
|
2951
|
+
import urllib.request
|
|
2952
|
+
from pathlib import Path as _P
|
|
2953
|
+
|
|
2954
|
+
import agent_session
|
|
2955
|
+
import clawbridge
|
|
2956
|
+
import studio
|
|
2957
|
+
import studio_ui
|
|
2958
|
+
|
|
2959
|
+
missing = object()
|
|
2960
|
+
real_run = agent_session.AgentSession.run
|
|
2961
|
+
old_ws = getattr(studio.StudioHandler, "ws", missing)
|
|
2962
|
+
old_jobs = getattr(studio.StudioHandler, "jobs", missing)
|
|
2963
|
+
old_started = getattr(studio.StudioHandler, "started", None)
|
|
2964
|
+
box = {}
|
|
2965
|
+
|
|
2966
|
+
def post(path, payload):
|
|
2967
|
+
req = urllib.request.Request(
|
|
2968
|
+
f"http://127.0.0.1:{box['port']}{path}",
|
|
2969
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
2970
|
+
headers={"Content-Type": "application/json"}, method="POST")
|
|
2971
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
2972
|
+
return r.status, json.loads(r.read())
|
|
2973
|
+
|
|
2974
|
+
def fake_run(self, message, progress=None, cancelled=None):
|
|
2975
|
+
# A real agent on this track lands its result through the workbench's own
|
|
2976
|
+
# endpoint, so everything downstream must be derived from APPLIED state.
|
|
2977
|
+
mode = box.get("mode")
|
|
2978
|
+
text = "这一轮我没动工作台。"
|
|
2979
|
+
if mode == "grade":
|
|
2980
|
+
status, _ = post("/api/grade", {"slot": 5, "preset": "cine",
|
|
2981
|
+
"grade": {"exposure": 0.2, "highlights": -20,
|
|
2982
|
+
"shadows": 15, "dehaze": 12}})
|
|
2983
|
+
assert status == 200, f"agent 落地配方失败: HTTP {status}"
|
|
2984
|
+
text = "第 5 格我上了电影青橙。"
|
|
2985
|
+
elif mode == "caption":
|
|
2986
|
+
status, _ = post("/api/caption", {
|
|
2987
|
+
"narrative": "沿着海岸线走了一天",
|
|
2988
|
+
"versions": [{"style": "文艺诗意", "text": "潮声比人声多"},
|
|
2989
|
+
{"style": "纪实行记", "text": "今天走了两万步"},
|
|
2990
|
+
{"style": "松弛日常", "text": "风大,头发乱"}],
|
|
2991
|
+
"tags": ["海边"]})
|
|
2992
|
+
assert status == 200, f"agent 登记文案失败: HTTP {status}"
|
|
2993
|
+
text = "三版写好了,已登记到工作台。"
|
|
2994
|
+
# Mirror how the real run() ends: the terminal event is what takes the
|
|
2995
|
+
# session out of ACTIVE, so the next turn is allowed to start.
|
|
2996
|
+
self.live_text = text
|
|
2997
|
+
self.emit({"type": "run.completed", "output": text})
|
|
2998
|
+
return {"ok": True, "provider": self.config.get("provider", "openclaw"),
|
|
2999
|
+
"session_id": self.config.get("session_id", ""), "run_id": "run-1",
|
|
3000
|
+
"text": text}
|
|
3001
|
+
|
|
3002
|
+
with _tf.TemporaryDirectory() as tmp:
|
|
3003
|
+
tmp = _P(tmp)
|
|
3004
|
+
photos, out = tmp / "photos", tmp / "out"
|
|
3005
|
+
photos.mkdir()
|
|
3006
|
+
out.mkdir()
|
|
3007
|
+
ws = studio.Workspace(photos, out)
|
|
3008
|
+
Image.new("RGB", (900, 1200), (70, 100, 130)).save(photos / "p1.jpg", quality=92)
|
|
3009
|
+
ws.slots["5"] = {"source": "p1.jpg", "ratio": None, "anchor": [0.5, 0.5],
|
|
3010
|
+
"grade": {}, "preset": "none", "history": []}
|
|
3011
|
+
ws.extra["agent_connection"] = {"provider": "openclaw",
|
|
3012
|
+
"base_url": "http://127.0.0.1:42000",
|
|
3013
|
+
"session_id": "sess-1", "owned": True}
|
|
3014
|
+
ws.save()
|
|
3015
|
+
link = agent_session.AgentSession(ws)
|
|
3016
|
+
link.ready, link.status = True, "awaiting_user"
|
|
3017
|
+
link.client = type("DummyClient", (), {"provider": "openclaw",
|
|
3018
|
+
"close": lambda self: None})()
|
|
3019
|
+
ws.agent_session = link
|
|
3020
|
+
|
|
3021
|
+
agent_session.AgentSession.run = fake_run
|
|
3022
|
+
studio.StudioHandler.ws = ws
|
|
3023
|
+
studio.StudioHandler.jobs = studio.JobQueue(workers=1)
|
|
3024
|
+
studio.StudioHandler.started = _time.time()
|
|
3025
|
+
httpd = studio.StudioServer(("127.0.0.1", 0), studio.StudioHandler)
|
|
3026
|
+
port = httpd.server_address[1]
|
|
3027
|
+
box["port"] = port
|
|
3028
|
+
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
3029
|
+
try:
|
|
3030
|
+
def send_turn(step="grade", operation="grade_plan"):
|
|
3031
|
+
req = urllib.request.Request(
|
|
3032
|
+
f"http://127.0.0.1:{port}/api/agent/send",
|
|
3033
|
+
data=json.dumps({"step": step, "action": "work_step",
|
|
3034
|
+
"operation": operation, "slot": 5,
|
|
3035
|
+
"message": "请用你自己的模型处理这一步"}).encode("utf-8"),
|
|
3036
|
+
headers={"Content-Type": "application/json"}, method="POST")
|
|
3037
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
3038
|
+
assert r.status == 202, f"Agent 回合没进任务队列: HTTP {r.status}"
|
|
3039
|
+
return json.loads(r.read())["job"]["id"]
|
|
3040
|
+
|
|
3041
|
+
def wait_job(jid):
|
|
3042
|
+
for _ in range(150):
|
|
3043
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/jobs", timeout=30) as r:
|
|
3044
|
+
jobs = json.loads(r.read())["jobs"]
|
|
3045
|
+
job = next((j for j in jobs if j["id"] == jid), None)
|
|
3046
|
+
if job and job["status"] in ("done", "failed"):
|
|
3047
|
+
return job
|
|
3048
|
+
_time.sleep(0.1)
|
|
3049
|
+
raise AssertionError(f"agent_turn 没跑完: {jid}")
|
|
3050
|
+
|
|
3051
|
+
# ① agent 通过工作台接口落了配方 -> 必须带三档候选
|
|
3052
|
+
box["mode"] = "grade"
|
|
3053
|
+
job = wait_job(send_turn())
|
|
3054
|
+
assert job["status"] == "done", f"agent 回合失败: {job.get('error') or job}"
|
|
3055
|
+
res = (job.get("result") or {}).get("result") or {}
|
|
3056
|
+
cands = res.get("candidates") or []
|
|
3057
|
+
labels = [c.get("label") for c in cands]
|
|
3058
|
+
assert len(cands) == 3, f"Agent 自带模型轨道没给出三套候选: {sorted(res)}"
|
|
3059
|
+
assert "克制" in labels and "浓郁" in labels, f"候选档位不对: {labels}"
|
|
3060
|
+
plan = next(c for c in cands if c["key"] == "plan")
|
|
3061
|
+
soft = next(c for c in cands if c["key"] == "soft")
|
|
3062
|
+
assert int(plan["params"]["highlights"]) == -20, plan["params"]
|
|
3063
|
+
assert abs(float(soft["params"]["highlights"]) - round(-20 * 0.55)) < 1.01, \
|
|
3064
|
+
f"克制档不是按落地参数派生的: {soft['params']}"
|
|
3065
|
+
assert res.get("slot") == 5, "候选没带上是哪一格"
|
|
3066
|
+
assert (res.get("text") or ""), "agent 的话也要在同一个结果里"
|
|
3067
|
+
|
|
3068
|
+
# ② 一轮什么都不改的 agent -> 不得拿旧配方冒充本轮方案
|
|
3069
|
+
box["mode"] = "none"
|
|
3070
|
+
job2 = wait_job(send_turn())
|
|
3071
|
+
res2 = (job2.get("result") or {}).get("result") or {}
|
|
3072
|
+
assert not (res2.get("candidates") or []), \
|
|
3073
|
+
"agent 没动工作台却给了候选(旧配方被冒充成本轮方案)"
|
|
3074
|
+
|
|
3075
|
+
# ③ 文案:agent 登记后必须进 workspace(能刷新回来、算作本步已完成)
|
|
3076
|
+
box["mode"] = "caption"
|
|
3077
|
+
job3 = wait_job(send_turn(step="caption", operation="caption"))
|
|
3078
|
+
assert job3["status"] == "done", f"文案回合失败: {job3.get('error') or job3}"
|
|
3079
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/state", timeout=30) as r:
|
|
3080
|
+
state = json.loads(r.read())
|
|
3081
|
+
caps = (state.get("captions") or {}).get("versions") or []
|
|
3082
|
+
assert len(caps) == 3, f"agent 登记的文案没进 workspace: {state.get('captions')}"
|
|
3083
|
+
assert caps[0]["style"] == "文艺诗意" and caps[0]["text"], caps
|
|
3084
|
+
assert (state.get("captions") or {}).get("narrative") == "沿着海岸线走了一天"
|
|
3085
|
+
|
|
3086
|
+
# ④ 空文案必须被拒:不能把「没写」当成「已登记」
|
|
3087
|
+
try:
|
|
3088
|
+
post("/api/caption", {"versions": []})
|
|
3089
|
+
raise AssertionError("空 versions 不应被接受")
|
|
3090
|
+
except urllib.error.HTTPError as exc:
|
|
3091
|
+
assert exc.code == 400, f"空文案应回 400,实际 {exc.code}"
|
|
3092
|
+
try:
|
|
3093
|
+
post("/api/caption", {"versions": [" "]})
|
|
3094
|
+
raise AssertionError("全空白文案不应被接受")
|
|
3095
|
+
except urllib.error.HTTPError as exc:
|
|
3096
|
+
assert exc.code == 400, f"全空白文案应回 400,实际 {exc.code}"
|
|
3097
|
+
|
|
3098
|
+
# ⑤ 简报必须告诉 agent 登记哪个接口,否则它只会写在会话里
|
|
3099
|
+
brief = clawbridge.studio_instruction(
|
|
3100
|
+
"caption", {"slots": [1]}, state, f"http://127.0.0.1:{port}")
|
|
3101
|
+
assert "/api/caption" in brief, "文案简报没给出登记接口"
|
|
3102
|
+
assert "POST" in brief and "versions" in brief, "文案简报没有可照抄的请求体"
|
|
3103
|
+
finally:
|
|
3104
|
+
agent_session.AgentSession.run = real_run
|
|
3105
|
+
httpd.shutdown()
|
|
3106
|
+
httpd.server_close()
|
|
3107
|
+
for attr, old in (("ws", old_ws), ("jobs", old_jobs)):
|
|
3108
|
+
if old is missing:
|
|
3109
|
+
try:
|
|
3110
|
+
delattr(studio.StudioHandler, attr)
|
|
3111
|
+
except AttributeError:
|
|
3112
|
+
pass
|
|
3113
|
+
else:
|
|
3114
|
+
setattr(studio.StudioHandler, attr, old)
|
|
3115
|
+
if old_started is not None:
|
|
3116
|
+
studio.StudioHandler.started = old_started
|
|
3117
|
+
|
|
3118
|
+
# 前端:候选面板不再按模型来源隐藏,且 agent_turn 完成后真的把候选交给页面
|
|
3119
|
+
js = studio_ui.STUDIO_HTML
|
|
3120
|
+
cand_line = next((l for l in js.splitlines() if "S.aiCandidateSlot === S.sel" in l), "")
|
|
3121
|
+
assert cand_line and "useAgentModel" not in cand_line, \
|
|
3122
|
+
"候选面板仍在 useAgentModel() 下被隐藏(Agent 自带模型轨道看不到三档)"
|
|
3123
|
+
idx = js.index('if (j.kind === "agent_turn")')
|
|
3124
|
+
branch = js[idx:js.index("return;", idx) + 400]
|
|
3125
|
+
assert "S.aiCandidates" in branch, \
|
|
3126
|
+
"agent_turn 完成后没把三档候选交给页面(用户看不到 预览/采用)"
|
|
3127
|
+
assert "renderInspector()" in branch, "agent_turn 完成后没有重画本步面板"
|
|
3128
|
+
assert "没产出可选的调色方案" in branch, \
|
|
3129
|
+
"agent 只说不做时页面又保持沉默(用户分不清是 bug 还是没改动)"
|
|
3130
|
+
assert "S.aiCandidates = null" in branch, \
|
|
3131
|
+
"agent 没落地配方时没有清掉上一轮的候选(旧方案会被当成本轮产物)"
|
|
3132
|
+
assert "akind === \"caption\"" in branch, \
|
|
3133
|
+
"agent_turn 完成后没有处理文案(面板又会一直回「还没有文案」)"
|
|
3134
|
+
assert "没把文案登记到工作台" in branch, \
|
|
3135
|
+
"agent 只把文案写在会话里时页面不说明"
|
|
3136
|
+
|
|
3137
|
+
|
|
2808
3138
|
CASES = {
|
|
2809
3139
|
"打包交付与下载(zip/预览/越权拦截)": t_finish_deliverables_and_download,
|
|
2810
3140
|
"AI 结果可见性+三套候选(每步输出框/agent轨道)": t_claw_grade_candidates_and_output_visibility,
|
|
3141
|
+
"Agent自带模型轨道也给三档候选(预览/采用)": t_agent_model_grade_candidates,
|
|
2811
3142
|
"存活探测不误杀冷实例/代理环境": t_liveness_probe_is_cheap_and_proxy_safe,
|
|
2812
3143
|
"审片页 STUDIO 链接跟随在跑实例": t_review_studio_link_follows_instance,
|
|
2813
3144
|
"canon 命名归一化": t_canon,
|
|
@@ -2850,6 +3181,7 @@ CASES = {
|
|
|
2850
3181
|
"换位元数据随照片走+预览缓存源版本": t_swap_metadata_and_preview_cache,
|
|
2851
3182
|
"任务队列并发上限+修订号+过期保护": t_jobqueue_and_revision,
|
|
2852
3183
|
"ClawHive 发现与会话路由(多tenant/TTL/会话模式)": t_claw_discovery_routing,
|
|
3184
|
+
"OpenClaw第三通道(probe/绑定活跃会话/整轮回放/无中间态)": t_openclaw_transport_channel,
|
|
2853
3185
|
"忙会话拒派活(409/不排队/陈旧锁放行)": t_claw_busy_guard,
|
|
2854
3186
|
"ClawHive 结果如实报告+用户意图入简报": t_claw_applies_once_and_intent,
|
|
2855
3187
|
"选片标准与评分健壮性(偏好/严格解析/锚点)": t_select9_curation_robustness,
|
|
@@ -2146,7 +2146,8 @@ class StudioHandler(BaseHTTPRequestHandler):
|
|
|
2146
2146
|
"/api/reset": self._api_reset,
|
|
2147
2147
|
"/api/undo": self._api_undo,
|
|
2148
2148
|
"/api/edit-source": self._api_edit_source,
|
|
2149
|
-
"/api/edit-result": self._api_edit_result,
|
|
2149
|
+
"/api/edit-result": self._api_edit_result,
|
|
2150
|
+
"/api/caption": self._api_caption,
|
|
2150
2151
|
"/api/edit-scan": self._api_edit_scan,
|
|
2151
2152
|
"/api/art": self._api_art,
|
|
2152
2153
|
"/api/art-preview": self._api_art_preview,
|
|
@@ -2172,8 +2173,8 @@ class StudioHandler(BaseHTTPRequestHandler):
|
|
|
2172
2173
|
if fn is None:
|
|
2173
2174
|
self.send_json({"ok": False, "error": f"未知接口 {path}"}, 404)
|
|
2174
2175
|
return
|
|
2175
|
-
if path in {"/api/framing", "/api/grade", "/api/swap", "/api/replace",
|
|
2176
|
-
"/api/reset", "/api/undo", "/api/edit-source"}:
|
|
2176
|
+
if path in {"/api/framing", "/api/grade", "/api/swap", "/api/replace",
|
|
2177
|
+
"/api/reset", "/api/undo", "/api/edit-source", "/api/caption"}:
|
|
2177
2178
|
# Snapshot + mutation + render must be atomic even when an
|
|
2178
2179
|
# agent and a browser edit the same workspace concurrently.
|
|
2179
2180
|
with self.ws._lock:
|
|
@@ -2301,10 +2302,11 @@ class StudioHandler(BaseHTTPRequestHandler):
|
|
|
2301
2302
|
ctx["built"] = artposter.build(str(src), style, opts, use_vision=False)
|
|
2302
2303
|
ctx["photo_full"] = str(src)
|
|
2303
2304
|
ctx["dst"] = str(self.ws.outdir / "art" / f"art_{artposter.STYLES[style]['suffix']}_{slot:02d}_{src.stem}.jpg")
|
|
2305
|
+
# The brief KEEPS the workbench's promise of 克制/标准/浓郁
|
|
2306
|
+
# variants: this track now derives them from the recipe the
|
|
2307
|
+
# agent actually applied (see the grade branch in work()
|
|
2308
|
+
# below), so the sentence the agent reads is true here too.
|
|
2304
2309
|
contract = clawbridge.studio_instruction(operation, ctx, snapshot, base).replace("ClawHive", "已连接的 Agent")
|
|
2305
|
-
contract = contract.replace(
|
|
2306
|
-
"工作台会自动基于你的配方生成克制/标准/浓郁三套候选供用户微调。",
|
|
2307
|
-
"请说明实际应用的配方与理由,等待用户在本步反馈区继续确认。")
|
|
2308
2310
|
context += contract + "\n"
|
|
2309
2311
|
if operation in {"art", "finish"} and body.get("no_text", self.ws.extra.get("no_text", False)) is True:
|
|
2310
2312
|
context += "本次出片不新增任何文字:不要标题、字幕、日期、页码、署名、水印或装饰字;原照片原有文字不擦除。\n"
|
|
@@ -2320,6 +2322,23 @@ class StudioHandler(BaseHTTPRequestHandler):
|
|
|
2320
2322
|
result = link.run(outbound, lambda text, pct: (setattr(job, "detail", text), setattr(job, "progress", pct)),
|
|
2321
2323
|
cancelled=lambda: job.cancelled)
|
|
2322
2324
|
changed = slot_changes({str(s["slot"]): s for s in snapshot["slots"]}, self.ws.state())
|
|
2325
|
+
# 三套候选 also on the agent-model track. The brief the agent just
|
|
2326
|
+
# read promises 克制/标准/浓郁, and this track used to drop that
|
|
2327
|
+
# promise in two places at once (the sentence was rewritten out of
|
|
2328
|
+
# the brief AND the chooser was hidden in the page), leaving the
|
|
2329
|
+
# user with one opaque recipe they never got to choose. Derive the
|
|
2330
|
+
# three from the params the agent really applied — only when it
|
|
2331
|
+
# changed a slot this turn, so a stale recipe from an earlier edit
|
|
2332
|
+
# is never dressed up as this turn's plan.
|
|
2333
|
+
if operation in ("grade_plan", "grade_all") and changed:
|
|
2334
|
+
n = slot if slot in changed else changed[0]
|
|
2335
|
+
cur = self.ws.slots.get(str(n)) or {}
|
|
2336
|
+
params = grade.normalize(cur.get("grade") or {})
|
|
2337
|
+
if any(abs(float(v or 0)) > 1e-9 for v in params.values()):
|
|
2338
|
+
result["candidates"] = grade.candidates(
|
|
2339
|
+
{"params": params, "preset": cur.get("preset") or None})
|
|
2340
|
+
result["slot"] = n
|
|
2341
|
+
result["summary"] = self.ws.grade_summary(cur)
|
|
2323
2342
|
return {"ok": result["ok"], "result": result, "changed_slots": changed,
|
|
2324
2343
|
"applied": bool(changed)}
|
|
2325
2344
|
try:
|
|
@@ -2930,6 +2949,41 @@ class StudioHandler(BaseHTTPRequestHandler):
|
|
|
2930
2949
|
"summary": self.ws.grade_summary(slot),
|
|
2931
2950
|
"state": self.ws.state()["slots"][n - 1]})
|
|
2932
2951
|
|
|
2952
|
+
def _api_caption(self, body: dict) -> None:
|
|
2953
|
+
"""Persist caption versions produced outside the workbench (both agent tracks).
|
|
2954
|
+
|
|
2955
|
+
The local gateway track has always written captions into the workspace
|
|
2956
|
+
(see _api_ai), but an agent had no equivalent endpoint: it could only say
|
|
2957
|
+
the three versions in the conversation, so the 文案 step kept showing
|
|
2958
|
+
「还没有文案」 — on the very screen where the user had just asked for them —
|
|
2959
|
+
and the set never counted as finished. Registration stores exactly what
|
|
2960
|
+
the caller sends; nothing is invented here.
|
|
2961
|
+
"""
|
|
2962
|
+
raw = body.get("versions")
|
|
2963
|
+
if not isinstance(raw, list):
|
|
2964
|
+
self.send_json({"ok": False, "error": "versions 必须是数组"}, 400)
|
|
2965
|
+
return
|
|
2966
|
+
versions = []
|
|
2967
|
+
for item in raw:
|
|
2968
|
+
if isinstance(item, str):
|
|
2969
|
+
text, style = item.strip(), ""
|
|
2970
|
+
elif isinstance(item, dict):
|
|
2971
|
+
text = str(item.get("text") or "").strip()
|
|
2972
|
+
style = str(item.get("style") or "").strip()
|
|
2973
|
+
else:
|
|
2974
|
+
continue
|
|
2975
|
+
if text:
|
|
2976
|
+
versions.append({"style": style or f"版本 {len(versions) + 1}",
|
|
2977
|
+
"text": text[:4000]})
|
|
2978
|
+
if not versions:
|
|
2979
|
+
self.send_json({"ok": False, "error": "至少需要一版非空文案"}, 400)
|
|
2980
|
+
return
|
|
2981
|
+
tags = [str(t).strip() for t in (body.get("tags") or []) if str(t).strip()][:12]
|
|
2982
|
+
saved = self.ws.save_captions({"narrative": str(body.get("narrative") or "")[:600],
|
|
2983
|
+
"versions": versions, "tags": tags})
|
|
2984
|
+
self.send_json({"ok": True, "captions": saved, "versions": saved["versions"],
|
|
2985
|
+
"revision": self.ws.revision})
|
|
2986
|
+
|
|
2933
2987
|
def _api_edit_result(self, body: dict) -> None:
|
|
2934
2988
|
"""Register a retouch result produced outside the workbench.
|
|
2935
2989
|
|
|
@@ -399,15 +399,15 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border-radius:50%;bac
|
|
|
399
399
|
padding:10px 11px;font-family:var(--mono);font-size:10.5px;line-height:1.6;resize:vertical;
|
|
400
400
|
background:#FCFCFE;color:var(--ink-2)}
|
|
401
401
|
.prompt-box .pb-foot{display:flex;gap:6px;padding:7px 9px;border-top:1px solid #E7EAF2;background:#FAFBFD;flex-wrap:wrap}
|
|
402
|
-
.cand{display:grid;gap:6px;margin-top:4px}
|
|
403
|
-
.cand .c{border:1px solid var(--line);border-radius:7px;padding:8px 10px;background:#fff;cursor:pointer;
|
|
404
|
-
transition:all .18s var(--ease);display:flex;gap:10px;align-items:center}
|
|
405
|
-
.cand .c:hover{border-color:var(--gold);box-shadow:var(--sh-1)}
|
|
406
|
-
.cand .c img{width:44px;height:44px;border-radius:4px;object-fit:cover;flex:none;border:1px solid var(--line)}
|
|
407
|
-
.cand .c .cx{flex:1;min-width:0}
|
|
408
|
-
.cand .c .cx b{font-size:11.5px;display:block}
|
|
409
|
-
.cand .c .cx s{font-size:9.5px;color:var(--sub);text-decoration:none;display:block;
|
|
410
|
-
|
|
402
|
+
.cand{display:grid;gap:6px;margin-top:4px;grid-template-columns:minmax(0,1fr)}
|
|
403
|
+
.cand .c{border:1px solid var(--line);border-radius:7px;padding:8px 10px;background:#fff;cursor:pointer;
|
|
404
|
+
transition:all .18s var(--ease);display:flex;gap:10px;align-items:center;min-width:0;max-width:100%}
|
|
405
|
+
.cand .c:hover{border-color:var(--gold);box-shadow:var(--sh-1)}
|
|
406
|
+
.cand .c img{width:44px;height:44px;border-radius:4px;object-fit:cover;flex:none;border:1px solid var(--line)}
|
|
407
|
+
.cand .c .cx{flex:1;min-width:0}
|
|
408
|
+
.cand .c .cx b{font-size:11.5px;display:block}
|
|
409
|
+
.cand .c .cx s{font-size:9.5px;color:var(--sub);text-decoration:none;display:block;
|
|
410
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
411
411
|
.note{font-size:10.5px;color:var(--sub);line-height:1.6}
|
|
412
412
|
.note.warn{color:var(--warn)}
|
|
413
413
|
.note.err{color:var(--err)}
|
|
@@ -1599,7 +1599,11 @@ function renderInspector(passive = true) {
|
|
|
1599
1599
|
const editPresets = (S.data.ai.edit_presets || []).map((p) =>
|
|
1600
1600
|
`<button class="btn btn-sm" data-edit="${p.key}" title="${H(p.instruction)}">${H(p.label)}</button>`).join("");
|
|
1601
1601
|
|
|
1602
|
-
|
|
1602
|
+
// The candidate chooser is not gateway-only. The agent-model track derives the
|
|
1603
|
+
// same 克制/标准/浓郁 from the recipe its agent applied, so suppressing the
|
|
1604
|
+
// chooser here meant the agent's promise of 「三套候选」 produced no visible
|
|
1605
|
+
// choice and no 预览/采用 entry at all on that track.
|
|
1606
|
+
const cand = S.aiCandidateSlot === S.sel && (!S.aiCandidateSource || S.aiCandidateSource === s.source)
|
|
1603
1607
|
? (S.aiCandidates || []) : [];
|
|
1604
1608
|
const candHtml = cand.length ? `<div class="cand">${cand.map((c) => `
|
|
1605
1609
|
<div class="c">
|
|
@@ -2196,7 +2200,71 @@ function jobStep(j) {
|
|
|
2196
2200
|
}
|
|
2197
2201
|
|
|
2198
2202
|
async function handleJobDone(j) {
|
|
2199
|
-
if (j.kind === "agent_turn") {
|
|
2203
|
+
if (j.kind === "agent_turn") {
|
|
2204
|
+
await refresh(true);
|
|
2205
|
+
if (AP.open || useAgentModel()) await pollAgent();
|
|
2206
|
+
// An agent turn that landed a grading recipe carries the same three derived
|
|
2207
|
+
// candidates the ClawHive track shows; without this branch the page refreshed
|
|
2208
|
+
// and stayed silent, so the user never saw the plan they were promised.
|
|
2209
|
+
const ar = (j.result || {}).result || {};
|
|
2210
|
+
const akind = (j.meta || {}).operation || "";
|
|
2211
|
+
if (["grade_plan", "grade_all"].includes(akind) && (ar.candidates || []).length) {
|
|
2212
|
+
const s = slotByIdx(ar.slot || (j.meta || {}).slot);
|
|
2213
|
+
S.aiCandidates = ar.candidates.map((c) => ({
|
|
2214
|
+
key: c.key, label: c.label, params: c.params,
|
|
2215
|
+
preset: c.preset || "none",
|
|
2216
|
+
summary: c.summary || describeParams(c.params),
|
|
2217
|
+
thumb: s ? s.raw_thumb : "",
|
|
2218
|
+
}));
|
|
2219
|
+
S.aiCandidateSlot = s ? s.slot : 0;
|
|
2220
|
+
S.aiCandidateSource = s?.source || "";
|
|
2221
|
+
S.aiPlan = ar;
|
|
2222
|
+
renderInspector();
|
|
2223
|
+
toast(`Agent 落的这版配好三档:${S.aiCandidates.map((c) => c.label).join(" / ")},点一下换档`,
|
|
2224
|
+
"ai", 6000);
|
|
2225
|
+
} else if (["grade_plan", "grade_all"].includes(akind)) {
|
|
2226
|
+
// Say it out loud instead of staying silent: the agent talked but left the
|
|
2227
|
+
// workbench untouched, so there is no recipe to derive three variants from.
|
|
2228
|
+
// Silence here is what made 「没看到三个选项」 indistinguishable from a bug.
|
|
2229
|
+
// Painted directly: renderAiOut() still refuses to draw on the agent track.
|
|
2230
|
+
const step = (j.meta || {}).step || jobStep(j);
|
|
2231
|
+
const why = j.result?.applied ? "本轮改动没有落到调色参数上" : "Agent 只给了说明,没有改动工作台";
|
|
2232
|
+
// Drop any earlier turn's chooser first: keeping it would present a stale
|
|
2233
|
+
// recipe as if this turn had produced it.
|
|
2234
|
+
if (S.aiCandidates) {
|
|
2235
|
+
S.aiCandidates = null; S.aiCandidateSlot = 0; S.aiCandidateSource = "";
|
|
2236
|
+
renderInspector();
|
|
2237
|
+
}
|
|
2238
|
+
const box = step === S.step ? outBox() : null;
|
|
2239
|
+
if (box) {
|
|
2240
|
+
box.innerHTML = `<div class="note" style="margin-top:10px"><b>Agent 这一轮没产出可选的调色方案</b><br>
|
|
2241
|
+
${H(why)}。回答在下方「本步回复与反馈」里;要三档候选,请让 Agent 通过工作台落地配方
|
|
2242
|
+
(<code>POST /api/grade</code>),或改用「模型网关」直接生成方案。</div>`;
|
|
2243
|
+
}
|
|
2244
|
+
toast("Agent 没改动工作台,所以还没有三档候选可选", "err", 7000);
|
|
2245
|
+
} else if (akind === "caption") {
|
|
2246
|
+
// 文案 is the one step with no adopt concept: it lands in the workspace and
|
|
2247
|
+
// the user copies a version. Before /api/caption existed an agent could
|
|
2248
|
+
// only say the three versions in the conversation, so this very panel kept
|
|
2249
|
+
// answering 「还没有文案」 right after the user asked for them.
|
|
2250
|
+
const caps = (S.data.captions && S.data.captions.versions) || [];
|
|
2251
|
+
if (caps.length) {
|
|
2252
|
+
renderCaptions();
|
|
2253
|
+
toast(`Agent 已登记 ${caps.length} 版文案,在文案面板逐版复制`, "ai", 6000);
|
|
2254
|
+
} else {
|
|
2255
|
+
const step = (j.meta || {}).step || jobStep(j);
|
|
2256
|
+
const box = step === S.step ? outBox() : null;
|
|
2257
|
+
if (box) {
|
|
2258
|
+
box.innerHTML = `<div class="note" style="margin-top:10px"><b>Agent 没把文案登记到工作台</b><br>
|
|
2259
|
+
三版文案可能只写在会话里了,所以本步仍是空的、打包也拿不到。
|
|
2260
|
+
回答在下方「本步回复与反馈」里;可让 Agent 重新调用 <code>POST /api/caption</code>
|
|
2261
|
+
登记,或改用「模型网关」生成。</div>`;
|
|
2262
|
+
}
|
|
2263
|
+
toast("Agent 没有登记文案,文案面板仍是空的", "err", 7000);
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2200
2268
|
if (j.kind === "journey_index") { await loadJourney(); return; }
|
|
2201
2269
|
if (j.result?.result?.proposal && showComposeProposal(j)) return;
|
|
2202
2270
|
const resultStep = jobStep(j);
|
package/bin/install.js
CHANGED