Skip to content

Commit 60d824b

Browse files
committed
feat: add reject/rollback API + source tracking + tool definitions
Phase 2 of review API integration: API endpoints: - POST /runs/{run_id}/reject — revise current node with feedback - POST /runs/{run_id}/rollback — rollback to any earlier node, clears intermediate nodes, bumps session generation Chat source tracking: - save_chat_message() accepts source param (web|discord|openclaw) - api_chat() passes source from request body Plugin tools: - contentpipe_reject: reject current node with reason - contentpipe_rollback: rollback to target node
1 parent 27acbdd commit 60d824b

3 files changed

Lines changed: 167 additions & 2 deletions

File tree

openclaw.plugin.yaml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,32 @@ tools:
105105
description: "指定节点(留空用当前节点)"
106106
required: false
107107

108+
- name: contentpipe_reject
109+
description: "驳回当前节点,带反馈重新执行"
110+
parameters:
111+
run_id:
112+
type: string
113+
required: true
114+
reason:
115+
type: string
116+
required: true
117+
description: "驳回原因/修改要求"
118+
119+
- name: contentpipe_rollback
120+
description: "回退到指定节点重新执行(丢弃当前节点及下游产物)"
121+
parameters:
122+
run_id:
123+
type: string
124+
required: true
125+
target_node:
126+
type: string
127+
required: true
128+
description: "回退目标节点 (scout/researcher/writer/director/formatter)"
129+
reason:
130+
type: string
131+
description: "回退原因"
132+
required: false
133+
108134
# 依赖
109135
dependencies:
110136
python:

scripts/web/routes/api.py

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,7 @@ async def api_chat(request: Request, run_id: str):
651651
body = await request.json()
652652
user_msg = body.get("message", "").strip()
653653
node_id = body.get("node", "")
654+
source = body.get("source", "web") # web | discord | openclaw
654655
attachments = body.get("attachments", []) or []
655656
if not user_msg and not attachments:
656657
raise HTTPException(status_code=400, detail="Empty message")
@@ -666,7 +667,7 @@ async def api_chat(request: Request, run_id: str):
666667

667668
history = get_chat_history(run_id, node_id)
668669
display_msg = user_msg or "(附图片)"
669-
save_chat_message(run_id, node_id, "user", display_msg, attachments=attachments)
670+
save_chat_message(run_id, node_id, "user", display_msg, attachments=attachments, source=source)
670671

671672
# ── skill-driven 提示:检测 URL / 搜索意图,但不在 Python 里预抓取内容 ──
672673
import re as _re
@@ -916,6 +917,139 @@ async def api_rollback_image_gen_to_director(run_id: str):
916917
}
917918

918919

920+
# ── JSON API:reject / rollback(供 OpenClaw 工具调用)─────────
921+
922+
@router.post("/runs/{run_id}/reject")
923+
async def api_reject_node(request: Request, run_id: str, background_tasks: BackgroundTasks):
924+
"""驳回当前节点,带反馈重新执行。
925+
926+
JSON body: {"reason": "...", "source": "openclaw"}
927+
"""
928+
body = await request.json()
929+
reason = body.get("reason", "")
930+
source = body.get("source", "api")
931+
932+
raw = _load_raw_state(run_id)
933+
if not raw:
934+
raise HTTPException(status_code=404, detail="Run not found")
935+
936+
if raw.get("status") != "review":
937+
raise HTTPException(status_code=400, detail=f"Run not in review status (current: {raw.get('status')})")
938+
939+
node_id = raw.get("current_stage", "")
940+
raw["review_action"] = "revise"
941+
raw["user_feedback"] = {"action": "revise", "global_note": reason, "source": source}
942+
_save_state(raw)
943+
944+
# 恢复 Pipeline 执行(带反馈重新执行当前节点)
945+
background_tasks.add_task(_execute_pipeline, run_id)
946+
947+
return {
948+
"ok": True,
949+
"node": node_id,
950+
"action": "revise",
951+
"message": f"{node_id} rejected with feedback, re-executing",
952+
}
953+
954+
955+
@router.post("/runs/{run_id}/rollback")
956+
async def api_rollback_node(request: Request, run_id: str):
957+
"""回退到指定节点。
958+
959+
JSON body: {"target_node": "writer", "reason": "...", "source": "openclaw"}
960+
"""
961+
body = await request.json()
962+
target_node = body.get("target_node", "")
963+
reason = body.get("reason", "")
964+
965+
raw = _load_raw_state(run_id)
966+
if not raw:
967+
raise HTTPException(status_code=404, detail="Run not found")
968+
969+
current_node = raw.get("current_stage", "")
970+
interactive_nodes = ["scout", "researcher", "writer", "director", "formatter"]
971+
972+
if target_node not in interactive_nodes:
973+
raise HTTPException(status_code=400, detail=f"Invalid target node: {target_node}")
974+
975+
if target_node == current_node:
976+
raise HTTPException(status_code=400, detail=f"Already at {target_node}")
977+
978+
# 确保 target 在 current 之前
979+
cur_idx = interactive_nodes.index(current_node) if current_node in interactive_nodes else len(interactive_nodes)
980+
tgt_idx = interactive_nodes.index(target_node)
981+
if tgt_idx >= cur_idx:
982+
raise HTTPException(status_code=400, detail=f"Cannot rollback forward: {current_node}{target_node}")
983+
984+
# 清理从 target+1 到 current 的所有节点
985+
nodes_to_clear = interactive_nodes[tgt_idx + 1: cur_idx + 1]
986+
run_dir = Path(__file__).parent.parent.parent / "output" / "runs" / run_id
987+
988+
state_cleanup = {
989+
"scout": ["topic", "writer_brief", "handoff_to_researcher", "reference_articles",
990+
"user_requirements", "reference_index", "link_usage_policy", "scout_process_summary"],
991+
"researcher": ["research", "writer_packet", "verification_results",
992+
"evidence_backed_insights", "open_issues"],
993+
"writer": ["article", "article_edited", "writer_context"],
994+
"director": ["visual_plan", "image_candidates", "selected_images",
995+
"generated_images", "generated_cover"],
996+
"formatter": ["formatted_html"],
997+
}
998+
artifact_cleanup = {
999+
"scout": ["topic.yaml", "scout_raw.txt"],
1000+
"researcher": ["research.yaml", "researcher_raw.txt"],
1001+
"writer": ["writer_context.yaml", "article_draft.md", "article_edited.md"],
1002+
"director": ["director_raw.txt", "visual_plan.json", "director_refine_raw.txt",
1003+
"image_candidates.json", "generated_images.json", "generated_cover.json"],
1004+
"formatter": ["formatted.html", "content_body.html"],
1005+
}
1006+
1007+
cleared: list[str] = []
1008+
session_gen = raw.get("_session_gen") if isinstance(raw.get("_session_gen"), dict) else {}
1009+
for node in nodes_to_clear:
1010+
# 清理 state 字段
1011+
for key in state_cleanup.get(node, []):
1012+
raw.pop(key, None)
1013+
# 清理文件
1014+
for fname in artifact_cleanup.get(node, []):
1015+
p = run_dir / fname
1016+
if p.exists():
1017+
p.unlink()
1018+
# 清理 chat 文件
1019+
chat_file = run_dir / f"chat_{node}.json"
1020+
if chat_file.exists():
1021+
chat_file.unlink()
1022+
# 提升 session generation
1023+
session_gen[node] = int(session_gen.get(node, 0) or 0) + 1
1024+
cleared.append(node)
1025+
1026+
# director → 清理图片目录
1027+
if "director" in nodes_to_clear:
1028+
images_dir = run_dir / "images"
1029+
if images_dir.exists():
1030+
import shutil
1031+
shutil.rmtree(images_dir, ignore_errors=True)
1032+
1033+
raw["_session_gen"] = session_gen
1034+
raw["current_stage"] = target_node
1035+
raw["status"] = "review"
1036+
raw["_node_done"] = True
1037+
raw["review_action"] = ""
1038+
raw.pop("user_feedback", None)
1039+
if reason:
1040+
raw["_rollback_reason"] = reason
1041+
_save_state(raw)
1042+
1043+
return {
1044+
"ok": True,
1045+
"rolled_back_to": target_node,
1046+
"cleared_nodes": cleared,
1047+
"message": f"Rolled back to {target_node}, cleared: {', '.join(cleared)}",
1048+
}
1049+
1050+
1051+
# ── 聊天 prompt 构建 ─────────────────────────────────────────
1052+
9191053
def _build_node_chat_prompt(node_id: str, state: dict) -> str:
9201054
"""为每个节点生成专属聊天 system prompt(含执行 context)"""
9211055
topic = state.get("topic", {})

scripts/web/run_manager.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,14 @@ def get_chat_history_visible(run_id: str, node_id: str = "") -> list[dict]:
121121

122122
def save_chat_message(run_id: str, node_id: str, role: str, content: str,
123123
tag: str = "", internal: bool = False,
124-
attachments: list[dict[str, Any]] | None = None):
124+
attachments: list[dict[str, Any]] | None = None,
125+
source: str = ""):
125126
"""追加一条聊天消息到节点 session
126127
127128
internal=True 的消息不会在前端审核对话框中显示,
128129
但会作为 LLM chat_history 传递(节点执行上下文、系统提示等)。
130+
131+
source: 消息来源标记 ("web" | "discord" | "openclaw" | "system" | "")
129132
"""
130133
suffix = f"_{node_id}" if node_id else ""
131134
chat_file = OUTPUT_DIR / "runs" / run_id / f"chat{suffix}.json"
@@ -143,6 +146,8 @@ def save_chat_message(run_id: str, node_id: str, role: str, content: str,
143146
msg["internal"] = True
144147
if attachments:
145148
msg["attachments"] = attachments
149+
if source:
150+
msg["source"] = source
146151
history.append(msg)
147152
chat_file.write_text(json.dumps(history, ensure_ascii=False, indent=2), encoding="utf-8")
148153

0 commit comments

Comments
 (0)