239 lines
8.7 KiB
Python
239 lines
8.7 KiB
Python
"""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/<task_id>", 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/<task_id>/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"]
|