agent-Specialization/server/tasks/api.py
JOJO ae70e4902e refactor(stop-button): 拆分停止按钮和后台任务控制
停止按钮现在只停主智能体,与后台任务无关。
- 后端 cancel_task 简化:移除两段式判断、force_stop参数、is_ma_mode分支
- 删除 _force_stop_multi_agent 方法
- 新增 /api/sub_agents/stop_all 接口(mode=terminate/soft_stop)
- 前端 stopTask 移除 forceStop 概念,只取消主智能体
- 取消期间不再二次点击终结子智能体

子智能体停止按钮:
- InputComposer state bar 加'x个子智能体运行中'按钮
- 独立浮层显示,不依赖 git 栏(参考 askuser 方案)
- 点击传统模式弹窗终结;多智能体弹窗软停止
- App.vue 新增 handleStopAllSubAgents 调 ui.confirmDialog

后台指令停止按钮:
- LeftPanel 后台指令标签右边加停止按钮
- 仅当 panelMode=backgroundCommands 且存在运行中后台指令时显示
- 调 backgroundCommandStore.stopAllCommands 无二次确认

showStopIcon 只看主对话 streaming 状态:
- 后台任务在跑但主对话空闲时显示发送按钮
- 多智能体 / 传统模式统一处理

子智能体 store 加 stopAllAgents(mode) action 调新 API

useLegacySocket task_stopped handler 简化:
- 移除 multi_agent_reloaded 重载
- 移除'再次按下停止按钮以结束后台任务'提示
- 保留 has_running_multi_agent/sub_agents 用于 taskInProgress 判定

清理调试日志:[TaskCancel][stop_debug], [STOP_DEBUG] 全部去掉
保留 ma_debug 框架用于多智能体调试
2026-07-14 18:34:56 +08:00

323 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""简单任务 API将聊天任务与 WebSocket 解耦,支持后台运行与轮询。"""
from __future__ import annotations
from server.tasks import tasks_bp
import mimetypes
import json
import time
import threading
import uuid
import traceback
from collections import deque
from pathlib import Path
from typing import Dict, Any, Optional, List
from flask import Blueprint, request, jsonify
from flask import current_app, session
from server.auth_helpers import api_login_required, get_current_username
from server.context import get_user_resources, ensure_conversation_loaded
from server.chat_flow import run_chat_task_sync
from server.state import stop_flags
from server.utils_common import debug_log, log_conn_diag
from utils.host_workspace_debug import write_host_workspace_debug
from config import DATA_DIR, WORKSPACE_SKILLS_DIRNAME
from modules.goal_state_manager import GoalStateManager, REASON_USER_CANCEL
from server.tasks import task_manager
from server.tasks.skills import _build_skill_context_messages
from server.tasks.helpers import _task_public_payload
from server.tasks.media import _normalize_media_payload
@tasks_bp.route("/api/tasks", methods=["GET"])
@api_login_required
def list_tasks_api():
username = get_current_username()
workspace_id = (request.args.get("workspace_id") or "").strip() or None
status_filter = (request.args.get("status") or "").strip().lower()
recs = task_manager.list_tasks(username, workspace_id)
if status_filter:
if status_filter == "active":
active = {"pending", "running", "cancel_requested"}
recs = [rec for rec in recs if rec.status in active]
else:
wanted = {part.strip() for part in status_filter.split(",") if part.strip()}
recs = [rec for rec in recs if rec.status in wanted]
return jsonify({
"success": True,
"data": [
_task_public_payload(r)
for r in sorted(recs, key=lambda x: x.created_at, reverse=True)
]
})
@tasks_bp.route("/api/tasks", methods=["POST"])
@api_login_required
def create_task_api():
username = get_current_username()
workspace_id = session.get("workspace_id") or "default"
payload = request.get_json() or {}
message = (payload.get("message") or "").strip()
images, videos = _normalize_media_payload(payload.get("images") or [], payload.get("videos") or [])
conversation_id = payload.get("conversation_id")
if not message and not images and not videos:
return jsonify({"success": False, "error": "消息不能为空"}), 400
model_key = payload.get("model_key")
thinking_mode = payload.get("thinking_mode")
run_mode = payload.get("run_mode")
message_source = payload.get("message_source")
max_iterations = payload.get("max_iterations")
goal_mode = bool(payload.get("goal_mode"))
# 用户显式发送非目标模式消息时,若工作区仍有上一轮残留的活动目标,先清理,
# 避免新对话错误继承旧目标状态。
if not goal_mode:
try:
_terminal, workspace = get_user_resources(username, workspace_id)
if workspace:
gsm = GoalStateManager(workspace.data_dir)
if gsm.is_active():
gsm.mark_stopped("new_conversation")
debug_log(f"[Goal] 新任务未开启目标模式,清理工作区残留目标状态")
except Exception as exc:
debug_log(f"[Goal] 新任务清理残留目标状态失败: {exc}")
skill_context_messages: List[Dict[str, str]] = []
raw_skill_refs = payload.get("skill_refs")
if raw_skill_refs:
try:
debug_log(
f"[SkillsAPI] create_task skill_refs type={type(raw_skill_refs).__name__} "
f"count={len(raw_skill_refs) if isinstance(raw_skill_refs, list) else 'N/A'} "
f"refs={raw_skill_refs!r}"
)
_terminal, workspace = get_user_resources(username, workspace_id)
if not workspace:
debug_log(f"[SkillsAPI] create_task workspace unavailable user={username} ws={workspace_id}")
return jsonify({"success": False, "error": "工作区不可用"}), 400
debug_log(
f"[SkillsAPI] create_task workspace OK project_path={getattr(workspace, 'project_path', None)!r} "
f"data_dir={getattr(workspace, 'data_dir', None)!r}"
)
skill_context_messages = _build_skill_context_messages(workspace, raw_skill_refs)
debug_log(f"[SkillsAPI] create_task built {len(skill_context_messages)} skill context messages")
except ValueError as exc:
debug_log(f"[SkillsAPI] create_task ValueError: {exc}")
return jsonify({"success": False, "error": str(exc)}), 400
except Exception as exc:
debug_log(f"[SkillsAPI] 读取技能上下文失败: {exc}")
debug_log(f"[SkillsAPI] 读取技能上下文失败 traceback:\n{traceback.format_exc()}")
return jsonify({"success": False, "error": "读取 skill 失败"}), 500
try:
debug_log(
"[TaskAPI] create_task payload "
f"model_key={model_key!r} run_mode={run_mode!r} thinking_mode={thinking_mode!r} "
f"conversation_id={conversation_id!r} images={len(images)} videos={len(videos)}"
)
except Exception:
pass
try:
rec = task_manager.create_chat_task(
username,
workspace_id,
message,
images,
conversation_id,
videos=videos,
model_key=model_key,
thinking_mode=thinking_mode,
run_mode=run_mode,
max_iterations=max_iterations,
message_source=message_source,
goal_mode=goal_mode,
skill_context_messages=skill_context_messages,
)
except RuntimeError as exc:
return jsonify({"success": False, "error": str(exc)}), 409
return jsonify({
"success": True,
"data": {
"task_id": rec.task_id,
"workspace_id": rec.workspace_id,
"status": rec.status,
"created_at": rec.created_at,
"conversation_id": rec.conversation_id,
}
}), 202
@tasks_bp.route("/api/tasks/<task_id>", methods=["GET"])
@api_login_required
def get_task_api(task_id: str):
started_at = time.time()
username = get_current_username()
poll_req_id = request.headers.get("X-Task-Poll", "-")
rec = task_manager.get_task(username, task_id)
if not rec:
log_conn_diag(
f"task-poll-missing req={poll_req_id} user={username} task_id={task_id}"
)
return jsonify({"success": False, "error": "任务不存在"}), 404
try:
offset = int(request.args.get("from", 0))
except Exception:
offset = 0
events = [e for e in rec.events if e["idx"] >= offset]
next_offset = events[-1]["idx"] + 1 if events else offset
elapsed_ms = (time.time() - started_at) * 1000.0
should_log = (
offset == 0
or len(events) > 0
or rec.status != "running"
or elapsed_ms >= 800
)
if should_log:
log_conn_diag(
"task-poll "
f"req={poll_req_id} user={username} task_id={task_id} status={rec.status} "
f"from={offset} events={len(events)} next_offset={next_offset} "
f"elapsed_ms={elapsed_ms:.1f} slow={elapsed_ms >= 800}"
)
return jsonify({
"success": True,
"data": {
"task_id": rec.task_id,
"workspace_id": rec.workspace_id,
"status": rec.status,
"created_at": rec.created_at,
"updated_at": rec.updated_at,
"message": rec.message,
"conversation_id": rec.conversation_id,
"error": rec.error,
"message_source": (rec.session_data or {}).get("message_source"),
"goal_mode": bool((rec.session_data or {}).get("goal_mode")),
"goal_progress": (rec.session_data or {}).get("goal_progress"),
"events": events,
"next_offset": next_offset,
"runtime_queued_messages": task_manager.get_runtime_pending_messages(
username, task_id
),
}
})
@tasks_bp.route("/api/tasks/<task_id>/cancel", methods=["POST"])
@api_login_required
def cancel_task_api(task_id: str):
username = get_current_username()
rec = task_manager.get_task(username, task_id)
if not rec:
return jsonify({"success": False, "error": "任务不存在"}), 404
ok = task_manager.cancel_task(username, task_id)
# 用户取消任务时,一并停止该工作区的目标模式,避免后续新对话继承旧目标。
try:
if ok and rec.workspace_id:
_, workspace = get_user_resources(username, rec.workspace_id)
if workspace:
gsm = GoalStateManager(workspace.data_dir)
if gsm.is_active():
gsm.mark_stopped(REASON_USER_CANCEL)
debug_log(f"[Goal] 用户取消任务 {task_id},同步停止工作区目标模式")
except Exception as exc:
debug_log(f"[Goal] 取消任务时停止目标模式失败: {exc}")
return jsonify({"success": True})
@tasks_bp.route("/api/tasks/<task_id>/runtime_guidance", methods=["POST"])
@api_login_required
def enqueue_runtime_guidance_api(task_id: str):
username = get_current_username()
payload = request.get_json() or {}
message = (payload.get("message") or "").strip()
if not message:
return jsonify({"success": False, "error": "引导内容不能为空"}), 400
result = task_manager.enqueue_runtime_guidance(username, task_id, message)
if not result.get("success"):
code = result.get("code") or "runtime_guidance_failed"
if code == "task_not_found":
return jsonify({"success": False, "error": result.get("error") or "任务不存在"}), 404
if code in {"queue_full", "task_not_running"}:
return jsonify({"success": False, "error": result.get("error") or "任务状态不允许"}), 409
return jsonify({"success": False, "error": result.get("error") or "追加引导失败"}), 400
return jsonify(
{
"success": True,
"data": {
"task_id": result.get("task_id"),
"queued_count": result.get("queued_count", 0),
},
}
)
@tasks_bp.route("/api/tasks/<task_id>/runtime_queue", methods=["POST"])
@api_login_required
def enqueue_runtime_queue_message_api(task_id: str):
username = get_current_username()
payload = request.get_json() or {}
message = (payload.get("message") or "").strip()
if not message:
return jsonify({"success": False, "error": "消息不能为空"}), 400
result = task_manager.enqueue_runtime_pending_message(username, task_id, message)
if not result.get("success"):
code = result.get("code") or "runtime_queue_enqueue_failed"
if code == "task_not_found":
return jsonify({"success": False, "error": result.get("error") or "任务不存在"}), 404
if code in {"queue_full", "task_not_running"}:
return jsonify({"success": False, "error": result.get("error") or "任务状态不允许"}), 409
return jsonify({"success": False, "error": result.get("error") or "追加消息失败"}), 400
return jsonify(
{
"success": True,
"data": {
"task_id": result.get("task_id"),
"item": result.get("item"),
"messages": result.get("messages") or [],
},
}
)
@tasks_bp.route("/api/tasks/<task_id>/runtime_queue/<message_id>", methods=["DELETE"])
@api_login_required
def delete_runtime_queue_message_api(task_id: str, message_id: str):
username = get_current_username()
result = task_manager.remove_runtime_pending_message(username, task_id, message_id)
if not result.get("success"):
code = result.get("code") or "runtime_queue_delete_failed"
if code == "task_not_found":
return jsonify({"success": False, "error": result.get("error") or "任务不存在"}), 404
if code == "message_not_found":
return jsonify({"success": False, "error": result.get("error") or "消息不存在"}), 404
return jsonify({"success": False, "error": result.get("error") or "删除失败"}), 400
return jsonify(
{
"success": True,
"data": {
"task_id": result.get("task_id"),
"messages": result.get("messages") or [],
},
}
)
@tasks_bp.route("/api/tasks/<task_id>/runtime_queue/<message_id>/guide", methods=["POST"])
@api_login_required
def guide_runtime_queue_message_api(task_id: str, message_id: str):
username = get_current_username()
result = task_manager.promote_runtime_pending_to_guidance(username, task_id, message_id)
if not result.get("success"):
code = result.get("code") or "runtime_queue_guide_failed"
if code == "task_not_found":
return jsonify({"success": False, "error": result.get("error") or "任务不存在"}), 404
if code == "message_not_found":
return jsonify({"success": False, "error": result.get("error") or "消息不存在"}), 404
if code in {"guidance_queue_full", "task_not_running"}:
return jsonify({"success": False, "error": result.get("error") or "任务状态不允许"}), 409
return jsonify({"success": False, "error": result.get("error") or "引导失败"}), 400
return jsonify(
{
"success": True,
"data": {
"task_id": result.get("task_id"),
"queued_count": result.get("queued_count", 0),
"messages": result.get("messages") or [],
},
}
)