"""简单任务 API:将聊天任务与 WebSocket 解耦,支持后台运行与轮询。""" from __future__ import annotations from server.tasks import tasks_bp import mimetypes import json import time import threading import uuid import re 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.helpers import _task_public_payload from server.tasks.media import _normalize_media_payload SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\n(?P.*?)\n---\s*\n?", re.S) SKILL_FIELD_RE = re.compile(r"^(?Pname|description)\s*:\s*(?P.*)$") @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: _terminal, workspace = get_user_resources(username, workspace_id) if not workspace: return jsonify({"success": False, "error": "工作区不可用"}), 400 skill_context_messages = _build_skill_context_messages(workspace, raw_skill_refs) except ValueError as exc: return jsonify({"success": False, "error": str(exc)}), 400 except Exception as exc: debug_log(f"[SkillsAPI] 读取技能上下文失败: {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/", 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//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//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//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//runtime_queue/", 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//runtime_queue//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 [], }, } )