Skip to content

Commit d2d8246

Browse files
authored
重构 QwenPaw(OpenClaw) 接入链路并更新多语言教程 (Project-N-E-K-O#882)
* 重构 QwenPaw(OpenClaw) 接入链路并更新多语言教程 * 修复 QwenPaw 接入 review 问题并同步文档 - 将 OpenClaw 启动提示改为 i18n 文案,并清理异步分发中的冗余 magic_command 分支\n- 修复 QwenPaw URL 归一化、message.content 回复提取,以及 /new 本地会话先轮换再下发\n- 将 OpenClaw 默认地址统一为 8088,并为历史 8089 配置增加自动迁移\n- 限制 screen/camera 图片缓存数量,避免分析负载持续膨胀\n- 同步修正文档中的 QwenPaw 品牌大小写、英文教程措辞与 REST 接入示例 * 修复 OpenClaw 取消与会话迁移边界问题 - 避免 /stop 取消流程重复发送 openclaw task_update 事件\n- 修正 analyze 最近消息中图片附件误绑定到旧用户文本的问题\n- 补强 openclawUrl 从 8089 迁移到 8088 的 URL 解析与完整路径保留\n- 将 neko_channel 默认端口同步调整为 8088\n- 同步更新 QwenPaw 接入文档中的 /new 会话轮换顺序与魔法命令异步示例
1 parent 2e593b0 commit d2d8246

33 files changed

Lines changed: 2228 additions & 342 deletions

agent_server.py

Lines changed: 263 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,121 @@ def _default_openclaw_task_description() -> str:
283283
return _rp_phrase('openclaw_processing', _rp_lang(None))
284284

285285

286+
def _resolve_openclaw_sender_id(messages: list[dict[str, Any]] | None) -> str:
287+
if not isinstance(messages, list):
288+
return ""
289+
290+
for message in reversed(messages[-10:]):
291+
if not isinstance(message, dict) or message.get("role") != "user":
292+
continue
293+
294+
candidates: list[Any] = [
295+
message.get("sender_id"),
296+
message.get("user_id"),
297+
]
298+
for container_key in ("meta", "metadata", "_ctx"):
299+
container = message.get(container_key)
300+
if isinstance(container, dict):
301+
candidates.extend([
302+
container.get("sender_id"),
303+
container.get("user_id"),
304+
])
305+
306+
for candidate in candidates:
307+
resolved = str(candidate or "").strip()
308+
if resolved:
309+
return resolved
310+
return ""
311+
312+
313+
def _collect_active_openclaw_task_ids(
314+
*,
315+
sender_id: Optional[str] = None,
316+
lanlan_name: Optional[str] = None,
317+
exclude_task_id: Optional[str] = None,
318+
) -> list[str]:
319+
task_ids: list[str] = []
320+
for task_id, info in Modules.task_registry.items():
321+
if task_id == exclude_task_id or not isinstance(info, dict):
322+
continue
323+
if info.get("type") != "openclaw":
324+
continue
325+
if info.get("status") not in {"queued", "running"}:
326+
continue
327+
if sender_id and str(info.get("sender_id") or "").strip() != str(sender_id).strip():
328+
continue
329+
if lanlan_name and str(info.get("lanlan_name") or "").strip() != str(lanlan_name).strip():
330+
continue
331+
task_ids.append(task_id)
332+
return task_ids
333+
334+
335+
async def _cancel_openclaw_tasks_for_stop(
336+
*,
337+
sender_id: Optional[str],
338+
lanlan_name: Optional[str],
339+
exclude_task_id: Optional[str] = None,
340+
) -> list[str]:
341+
cancelled_task_ids: list[str] = []
342+
for task_id in _collect_active_openclaw_task_ids(
343+
sender_id=sender_id,
344+
lanlan_name=lanlan_name,
345+
exclude_task_id=exclude_task_id,
346+
):
347+
info = Modules.task_registry.get(task_id)
348+
if not isinstance(info, dict):
349+
continue
350+
351+
bg = Modules.task_async_handles.get(task_id)
352+
if bg and not bg.done():
353+
bg.cancel()
354+
355+
if Modules.openclaw:
356+
try:
357+
stop_result = await Modules.openclaw.stop_running(
358+
sender_id=info.get("sender_id"),
359+
session_id=info.get("session_id"),
360+
conversation_id=info.get("session_id"),
361+
role_name=info.get("lanlan_name"),
362+
task_id=task_id,
363+
)
364+
if not stop_result.get("success"):
365+
logger.warning(
366+
"[OpenClaw] stop_running failed during /stop for %s: %s",
367+
task_id,
368+
stop_result.get("error"),
369+
)
370+
except Exception as exc:
371+
logger.warning("[OpenClaw] stop_running failed during /stop for %s: %s", task_id, exc)
372+
373+
info["status"] = "cancelled"
374+
info["error"] = "Cancelled by user"
375+
info["end_time"] = _now_iso()
376+
cancelled_task_ids.append(task_id)
377+
378+
# Let the task coroutine emit the cancelled update when it is still
379+
# alive; only emit here when there is no active background handle.
380+
if not (bg and not bg.done()):
381+
try:
382+
await _emit_main_event(
383+
"task_update",
384+
info.get("lanlan_name"),
385+
task={
386+
"id": task_id,
387+
"status": "cancelled",
388+
"type": "openclaw",
389+
"start_time": info.get("start_time"),
390+
"end_time": info.get("end_time"),
391+
"params": info.get("params", {}),
392+
"error": "Cancelled by user",
393+
},
394+
)
395+
except Exception:
396+
logger.debug("[OpenClaw] emit task_update(cancelled by /stop) failed: task_id=%s", task_id, exc_info=True)
397+
398+
return cancelled_task_ids
399+
400+
286401
def _cleanup_task_registry() -> List[Dict[str, Any]]:
287402
"""清理 task_registry 中超过 5 分钟的已完成/失败/取消任务,防止内存泄漏;同时检查 deferred 任务超时
288403
@@ -939,8 +1054,23 @@ def _build_user_turn_fingerprint(messages: Any) -> Optional[str]:
9391054
if m.get("role") != "user":
9401055
continue
9411056
text = str(m.get("text") or m.get("content") or "").strip()
942-
if text:
943-
user_parts.append(text)
1057+
attachments = m.get("attachments") or []
1058+
attachment_urls: list[str] = []
1059+
if isinstance(attachments, list):
1060+
for item in attachments:
1061+
if isinstance(item, str):
1062+
url = item.strip()
1063+
elif isinstance(item, dict):
1064+
url = str(item.get("url") or item.get("image_url") or "").strip()
1065+
else:
1066+
url = ""
1067+
if url:
1068+
attachment_urls.append(url)
1069+
if text or attachment_urls:
1070+
part = text
1071+
if attachment_urls:
1072+
part = f"{part}\n[attachments]\n" + "\n".join(attachment_urls)
1073+
user_parts.append(part.strip())
9441074
if not user_parts:
9451075
return None
9461076
payload = "\n".join(user_parts).encode("utf-8", errors="ignore")
@@ -1632,10 +1762,71 @@ def _cleanup_up_task(_t, _tid=result.task_id):
16321762
if Modules.agent_flags.get("openclaw_enabled", False) and Modules.openclaw:
16331763
nk_start = _now_iso()
16341764
instruction = ""
1765+
attachments = []
1766+
magic_command = None
1767+
direct_reply = False
16351768
if isinstance(result.tool_args, dict):
16361769
instruction = str(result.tool_args.get("instruction") or "")
1637-
task_params = {"description": result.task_description or _default_openclaw_task_description()}
1638-
nk_sender_id = Modules.openclaw.default_sender_id
1770+
attachments = result.tool_args.get("attachments") or []
1771+
magic_command = Modules.openclaw.normalize_magic_command(result.tool_args.get("magic_command"))
1772+
direct_reply = bool(result.tool_args.get("direct_reply"))
1773+
task_params = {
1774+
"description": result.task_description or _default_openclaw_task_description(),
1775+
"attachment_count": len(attachments) if isinstance(attachments, list) else 0,
1776+
}
1777+
if magic_command:
1778+
task_params["magic_command"] = magic_command
1779+
nk_sender_id = _resolve_openclaw_sender_id(messages) or Modules.openclaw.default_sender_id
1780+
if magic_command:
1781+
if magic_command == "/stop":
1782+
cancelled_task_ids = await _cancel_openclaw_tasks_for_stop(
1783+
sender_id=nk_sender_id,
1784+
lanlan_name=lanlan_name,
1785+
exclude_task_id=result.task_id,
1786+
)
1787+
if cancelled_task_ids:
1788+
task_params["cancelled_task_ids"] = cancelled_task_ids
1789+
try:
1790+
nk_result = await Modules.openclaw.run_magic_command(
1791+
magic_command,
1792+
sender_id=nk_sender_id,
1793+
role_name=lanlan_name,
1794+
)
1795+
success = bool(nk_result.get("success"))
1796+
reply = str(nk_result.get("reply") or "")
1797+
if success:
1798+
await _emit_task_result(
1799+
lanlan_name,
1800+
channel="openclaw",
1801+
task_id=str(result.task_id or ""),
1802+
success=True,
1803+
summary=reply[:500] if reply else _rp_phrase('openclaw_done', _rp_lang(None)),
1804+
detail=reply,
1805+
direct_reply=direct_reply,
1806+
)
1807+
else:
1808+
await _emit_task_result(
1809+
lanlan_name,
1810+
channel="openclaw",
1811+
task_id=str(result.task_id or ""),
1812+
success=False,
1813+
summary=_rp_phrase('openclaw_failed', _rp_lang(None)),
1814+
error_message=str(nk_result.get("error") or "")[:500],
1815+
)
1816+
except Exception as e:
1817+
logger.exception("[OpenClaw] magic command dispatch failed: %s", e)
1818+
try:
1819+
await _emit_task_result(
1820+
lanlan_name,
1821+
channel="openclaw",
1822+
task_id=str(result.task_id or ""),
1823+
success=False,
1824+
summary=_rp_phrase('openclaw_dispatch_failed', _rp_lang(None)),
1825+
error_message=str(e)[:500],
1826+
)
1827+
except Exception:
1828+
pass
1829+
return
16391830
nk_session_id = Modules.openclaw.get_or_create_persistent_session_id(
16401831
role_name=lanlan_name,
16411832
sender_id=nk_sender_id,
@@ -1672,11 +1863,12 @@ def _cleanup_up_task(_t, _tid=result.task_id):
16721863
except Exception as emit_err:
16731864
logger.debug("[OpenClaw] emit task_update(running) failed: task_id=%s error=%s", result.task_id, emit_err)
16741865
try:
1866+
ack_text = _rp_phrase("openclaw_try", _rp_lang(None))
16751867
await _emit_main_event(
16761868
"proactive_message",
16771869
lanlan_name,
1678-
text="我试试",
1679-
detail="我试试",
1870+
text=ack_text,
1871+
detail=ack_text,
16801872
direct_reply=True,
16811873
timestamp=_now_iso(),
16821874
)
@@ -1686,6 +1878,7 @@ async def _run_openclaw_dispatch():
16861878
try:
16871879
nk_result = await Modules.openclaw.run_instruction(
16881880
instruction,
1881+
attachments=attachments,
16891882
sender_id=nk_sender_id,
16901883
session_id=nk_session_id,
16911884
conversation_id=conversation_id,
@@ -1701,6 +1894,7 @@ async def _run_openclaw_dispatch():
17011894
_reg["status"] = "completed" if success else "failed"
17021895
_reg["end_time"] = _now_iso()
17031896
_reg["result"] = nk_result
1897+
_reg["session_id"] = str(nk_result.get("session_id") or _reg.get("session_id") or "")
17041898
if not success:
17051899
_reg["error"] = str(nk_result.get("error") or "")[:500]
17061900
_task_tracker.record_completed(
@@ -1716,7 +1910,7 @@ async def _run_openclaw_dispatch():
17161910
success=True,
17171911
summary=reply[:500] if reply else _rp_phrase('openclaw_done', _rp_lang(None)),
17181912
detail=reply,
1719-
direct_reply=False,
1913+
direct_reply=direct_reply,
17201914
)
17211915
else:
17221916
await _emit_task_result(
@@ -2505,6 +2699,68 @@ async def health():
25052699
)
25062700

25072701

2702+
@app.post("/openclaw/preflight")
2703+
async def openclaw_preflight(payload: Dict[str, Any]):
2704+
"""快速判断当前输入是否应由 OpenClaw(QwenPaw) 接管。"""
2705+
if not Modules.task_executor:
2706+
raise HTTPException(503, "Task executor not ready")
2707+
2708+
if not Modules.analyzer_enabled:
2709+
return {
2710+
"success": True,
2711+
"should_handoff": False,
2712+
"reason": "analyzer_disabled",
2713+
}
2714+
2715+
if not Modules.agent_flags.get("openclaw_enabled", False):
2716+
return {
2717+
"success": True,
2718+
"should_handoff": False,
2719+
"reason": "openclaw_disabled",
2720+
}
2721+
2722+
messages = (payload or {}).get("messages") or []
2723+
if not isinstance(messages, list) or not messages:
2724+
raise HTTPException(400, "messages required")
2725+
2726+
lanlan_name = (payload or {}).get("lanlan_name")
2727+
conversation_id = (payload or {}).get("conversation_id")
2728+
lang = str((payload or {}).get("lang") or "en")
2729+
2730+
flags = {
2731+
"computer_use_enabled": False,
2732+
"browser_use_enabled": False,
2733+
"user_plugin_enabled": False,
2734+
"openclaw_enabled": True,
2735+
"openfang_enabled": False,
2736+
}
2737+
2738+
result = await Modules.task_executor.analyze_and_execute(
2739+
messages=messages,
2740+
lanlan_name=lanlan_name,
2741+
agent_flags=flags,
2742+
conversation_id=conversation_id,
2743+
lang=lang,
2744+
)
2745+
2746+
should_handoff = bool(
2747+
result
2748+
and getattr(result, "has_task", False)
2749+
and getattr(result, "execution_method", "") == "openclaw"
2750+
)
2751+
tool_args = result.tool_args if isinstance(getattr(result, "tool_args", None), dict) else {}
2752+
2753+
return {
2754+
"success": True,
2755+
"should_handoff": should_handoff,
2756+
"execution_method": getattr(result, "execution_method", None) if result else None,
2757+
"task_description": getattr(result, "task_description", "") if result else "",
2758+
"reason": getattr(result, "reason", "") if result else "",
2759+
"magic_command": tool_args.get("magic_command"),
2760+
"direct_reply": bool(tool_args.get("direct_reply")) if tool_args else False,
2761+
}
2762+
2763+
25082764
# 插件直接触发路由(放在顶层,确保不在其它函数体内)
25092765
@app.post("/plugin/execute")
25102766
async def plugin_execute_direct(payload: Dict[str, Any]):

0 commit comments

Comments
 (0)