"""API v1:Bearer Token 版轻量接口(后台任务 + 轮询 + 文件)。""" from __future__ import annotations import os import zipfile from io import BytesIO from pathlib import Path from typing import Dict, Any from flask import Blueprint, request, jsonify, send_file, session from .api_auth import api_token_required from .tasks import task_manager from .context import get_user_resources, ensure_conversation_loaded, get_upload_guard from .files import sanitize_filename_preserve_unicode from .utils_common import debug_log api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1") def _within_uploads(workspace, rel_path: str) -> Path: base = Path(workspace.uploads_dir).resolve() rel = rel_path or "" # 兼容传入包含 user_upload/ 前缀的路径 if rel.startswith("user_upload/"): rel = rel.split("user_upload/", 1)[1] rel = rel.lstrip("/").strip() target = (base / rel).resolve() if not str(target).startswith(str(base)): raise ValueError("非法路径") return target @api_v1_bp.route("/conversations", methods=["POST"]) @api_token_required def create_conversation_api(): username = session.get("username") terminal, workspace = get_user_resources(username) if not terminal: return jsonify({"success": False, "error": "系统未初始化"}), 503 result = terminal.create_new_conversation() if not result.get("success"): return jsonify({"success": False, "error": result.get("error") or "创建对话失败"}), 500 return jsonify({"success": True, "conversation_id": result.get("conversation_id")}) @api_v1_bp.route("/messages", methods=["POST"]) @api_token_required def send_message_api(): username = session.get("username") payload = request.get_json() or {} message = (payload.get("message") or "").strip() images = payload.get("images") or [] conversation_id = payload.get("conversation_id") model_key = payload.get("model_key") thinking_mode = payload.get("thinking_mode") run_mode = payload.get("run_mode") max_iterations = payload.get("max_iterations") if not message and not images: return jsonify({"success": False, "error": "消息不能为空"}), 400 terminal, workspace = get_user_resources(username) if not terminal or not workspace: return jsonify({"success": False, "error": "系统未初始化"}), 503 try: conversation_id, _ = ensure_conversation_loaded(terminal, conversation_id) except Exception as exc: return jsonify({"success": False, "error": f"对话加载失败: {exc}"}), 400 try: rec = task_manager.create_chat_task( username=username, message=message, images=images, conversation_id=conversation_id, model_key=model_key, thinking_mode=thinking_mode, run_mode=run_mode, max_iterations=max_iterations, ) except ValueError as exc: return jsonify({"success": False, "error": str(exc)}), 400 except RuntimeError as exc: # 并发等业务冲突仍用 409 return jsonify({"success": False, "error": str(exc)}), 409 return jsonify({ "success": True, "task_id": rec.task_id, "conversation_id": rec.conversation_id, "status": rec.status, "created_at": rec.created_at, }), 202 @api_v1_bp.route("/tasks/", methods=["GET"]) @api_token_required def get_task_events(task_id: str): username = session.get("username") rec = task_manager.get_task(username, task_id) if not rec: 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 return jsonify({ "success": True, "data": { "task_id": rec.task_id, "status": rec.status, "created_at": rec.created_at, "updated_at": rec.updated_at, "error": rec.error, "events": events, "next_offset": next_offset, } }) @api_v1_bp.route("/tasks//cancel", methods=["POST"]) @api_token_required def cancel_task_api_v1(task_id: str): username = session.get("username") ok = task_manager.cancel_task(username, task_id) if not ok: return jsonify({"success": False, "error": "任务不存在"}), 404 return jsonify({"success": True}) @api_v1_bp.route("/files/upload", methods=["POST"]) @api_token_required def upload_file_api(): username = session.get("username") terminal, workspace = get_user_resources(username) if not terminal or not workspace: return jsonify({"success": False, "error": "系统未初始化"}), 503 if 'file' not in request.files: return jsonify({"success": False, "error": "未找到文件"}), 400 file_obj = request.files['file'] raw_name = request.form.get('filename') or file_obj.filename filename = sanitize_filename_preserve_unicode(raw_name) if not filename: return jsonify({"success": False, "error": "非法文件名"}), 400 subdir = request.form.get("dir") or "" try: target_dir = _within_uploads(workspace, subdir) target_dir.mkdir(parents=True, exist_ok=True) target_path = (target_dir / filename).resolve() except Exception as exc: return jsonify({"success": False, "error": str(exc)}), 400 guard = get_upload_guard(workspace) rel_path = str(target_path.relative_to(workspace.uploads_dir)) try: result = guard.process_upload( file_obj, target_path, username=username, source="api_v1", original_name=raw_name, relative_path=rel_path, ) except Exception as exc: return jsonify({"success": False, "error": f"保存文件失败: {exc}"}), 500 metadata = result.get("metadata", {}) return jsonify({ "success": True, "path": rel_path, "filename": target_path.name, "size": metadata.get("size"), "sha256": metadata.get("sha256"), }) @api_v1_bp.route("/files", methods=["GET"]) @api_token_required def list_files_api(): username = session.get("username") _, workspace = get_user_resources(username) if not workspace: return jsonify({"success": False, "error": "系统未初始化"}), 503 rel = request.args.get("path") or "" try: target = _within_uploads(workspace, rel) if not target.exists(): return jsonify({"success": False, "error": "路径不存在"}), 404 if not target.is_dir(): return jsonify({"success": False, "error": "路径不是文件夹"}), 400 items = [] for entry in sorted(target.iterdir(), key=lambda p: p.name): stat = entry.stat() rel_entry = entry.relative_to(workspace.uploads_dir) items.append({ "name": entry.name, "is_dir": entry.is_dir(), "size": stat.st_size, "modified_at": stat.st_mtime, "path": str(rel_entry), }) return jsonify({"success": True, "items": items, "base": str(target.relative_to(workspace.uploads_dir))}) except Exception as exc: return jsonify({"success": False, "error": str(exc)}), 400 @api_v1_bp.route("/files/download", methods=["GET"]) @api_token_required def download_file_api(): username = session.get("username") _, workspace = get_user_resources(username) if not workspace: return jsonify({"success": False, "error": "系统未初始化"}), 503 rel = request.args.get("path") if not rel: return jsonify({"success": False, "error": "缺少 path"}), 400 try: target = _within_uploads(workspace, rel) except Exception as exc: return jsonify({"success": False, "error": str(exc)}), 400 if not target.exists(): return jsonify({"success": False, "error": "文件不存在"}), 404 if target.is_dir(): memory_file = BytesIO() with zipfile.ZipFile(memory_file, mode='w', compression=zipfile.ZIP_DEFLATED) as zf: for root, _, files in os.walk(target): for file in files: full_path = Path(root) / file arcname = full_path.relative_to(workspace.project_path) zf.write(full_path, arcname=str(arcname)) memory_file.seek(0) download_name = f"{target.name}.zip" return send_file(memory_file, as_attachment=True, download_name=download_name, mimetype='application/zip') return send_file(target, as_attachment=True, download_name=target.name) __all__ = ["api_v1_bp"]