@@ -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+
9191053def _build_node_chat_prompt (node_id : str , state : dict ) -> str :
9201054 """为每个节点生成专属聊天 system prompt(含执行 context)"""
9211055 topic = state .get ("topic" , {})
0 commit comments