agent-Specialization/server/api_v1.py

652 lines
26 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 v1Bearer Token 版轻量接口(后台任务 + 轮询 + 文件)。"""
from __future__ import annotations
import os
import json
import zipfile
from io import BytesIO
from pathlib import Path
from typing import Dict, Any, Optional
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, apply_conversation_overrides
from .files import sanitize_filename_preserve_unicode
from .utils_common import debug_log
from config.model_profiles import MODEL_PROFILES
from core.tool_config import TOOL_CATEGORIES
from . import state
from modules.personalization_manager import sanitize_personalization_payload
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
@api_v1_bp.route("/tools", methods=["GET"])
@api_token_required
def list_tools_api():
"""返回工具分类与工具名称列表。"""
categories = []
for cid, cat in TOOL_CATEGORIES.items():
categories.append({
"id": cid,
"label": cat.label,
"default_enabled": bool(cat.default_enabled),
"silent_when_disabled": bool(cat.silent_when_disabled),
"tools": list(cat.tools),
})
return jsonify({"success": True, "categories": categories})
# -------------------- 工作区管理 --------------------
def _sanitize_workspace_id(ws_id: str) -> str:
import re
ws = (ws_id or "").strip()
if not ws:
return ""
if not re.fullmatch(r"[A-Za-z0-9._-]{1,40}", ws):
return ""
return ws
@api_v1_bp.route("/workspaces", methods=["GET"])
@api_token_required
def list_workspaces_api():
username = session.get("username")
items = state.api_user_manager.list_workspaces(username)
return jsonify({"success": True, "items": list(items.values())})
@api_v1_bp.route("/workspaces", methods=["POST"])
@api_token_required
def create_workspace_api():
username = session.get("username")
payload = request.get_json(silent=True) or {}
ws_id = _sanitize_workspace_id(payload.get("workspace_id") or payload.get("name") or "")
if not ws_id:
return jsonify({"success": False, "error": "workspace_id 只能包含字母/数字/._-长度1-40"}), 400
try:
ws = state.api_user_manager.ensure_workspace(username, ws_id)
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
return jsonify({
"success": True,
"workspace_id": ws.workspace_id,
"project_path": str(ws.project_path),
"data_dir": str(ws.data_dir),
})
@api_v1_bp.route("/workspaces/<workspace_id>", methods=["GET"])
@api_token_required
def get_workspace_api(workspace_id: str):
username = session.get("username")
ws_id = _sanitize_workspace_id(workspace_id)
if not ws_id:
return jsonify({"success": False, "error": "workspace_id 不合法"}), 400
workspaces = state.api_user_manager.list_workspaces(username)
ws_info = workspaces.get(ws_id)
if not ws_info:
return jsonify({"success": False, "error": "workspace 不存在"}), 404
return jsonify({"success": True, "workspace": ws_info})
@api_v1_bp.route("/workspaces/<workspace_id>", methods=["DELETE"])
@api_token_required
def delete_workspace_api(workspace_id: str):
username = session.get("username")
ws_id = _sanitize_workspace_id(workspace_id)
if not ws_id:
return jsonify({"success": False, "error": "workspace_id 不合法"}), 400
# 阻止删除有运行中任务的工作区
running = [t for t in task_manager.list_tasks(username, ws_id) if t.status in {"pending", "running"}]
if running:
return jsonify({"success": False, "error": "该工作区有运行中的任务,无法删除"}), 409
removed = state.api_user_manager.delete_workspace(username, ws_id)
# 清理终端/容器缓存
term_key = f"{username}::{ws_id}"
state.user_terminals.pop(term_key, None)
try:
state.container_manager.release_container(term_key, reason="workspace_deleted")
except Exception:
pass
if not removed:
return jsonify({"success": False, "error": "workspace 不存在或删除失败"}), 404
return jsonify({"success": True, "workspace_id": ws_id})
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
def _conversation_path(workspace, conv_id: str) -> Path:
return Path(workspace.data_dir) / "conversations" / f"{conv_id}.json"
def _update_conversation_metadata(workspace, conv_id: str, updates: Dict[str, Any]):
path = _conversation_path(workspace, conv_id)
if not path.exists():
return
try:
data = {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
data = {}
meta = data.get("metadata") or {}
meta.update({k: v for k, v in updates.items() if v is not None})
data["metadata"] = meta
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception as exc:
debug_log(f"[meta] 更新对话元数据失败: {exc}")
def _prompt_dir(workspace):
# 优先使用用户级共享目录ApiUserWorkspace.prompts_dir否则退回 data_dir/prompts
target = getattr(workspace, "prompts_dir", None)
if target:
Path(target).mkdir(parents=True, exist_ok=True)
return Path(target)
p = Path(workspace.data_dir) / "prompts"
p.mkdir(parents=True, exist_ok=True)
return p
def _personalization_dir(workspace):
target = getattr(workspace, "personalization_dir", None)
if target:
Path(target).mkdir(parents=True, exist_ok=True)
return Path(target)
p = Path(workspace.data_dir) / "personalization"
p.mkdir(parents=True, exist_ok=True)
return p
def _resolve_workspace(username: str, workspace_id: str):
if not workspace_id:
raise RuntimeError("workspace_id 不能为空")
return state.api_user_manager.ensure_workspace(username, workspace_id)
@api_v1_bp.route("/workspaces/<workspace_id>/conversations", methods=["POST"])
@api_token_required
def create_conversation_api(workspace_id: str):
username = session.get("username")
ws = _resolve_workspace(username, workspace_id)
terminal, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
if not terminal:
return jsonify({"success": False, "error": "系统未初始化"}), 503
payload = request.get_json(silent=True) or {}
prompt_name = payload.get("prompt_name")
personalization_name = payload.get("personalization_name")
result = terminal.create_new_conversation()
if not result.get("success"):
return jsonify({"success": False, "error": result.get("error") or "创建对话失败"}), 500
conv_id = result.get("conversation_id")
# 记录元数据,稍后消息时可自动应用
_update_conversation_metadata(workspace, conv_id, {
"custom_prompt_name": prompt_name,
"personalization_name": personalization_name,
"workspace_id": ws.workspace_id,
})
# 立即应用覆盖
apply_conversation_overrides(terminal, workspace, conv_id)
return jsonify({"success": True, "conversation_id": conv_id, "workspace_id": ws.workspace_id})
@api_v1_bp.route("/workspaces/<workspace_id>/messages", methods=["POST"])
@api_token_required
def send_message_api(workspace_id: str):
username = session.get("username")
ws = _resolve_workspace(username, workspace_id)
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")
prompt_name = payload.get("prompt_name")
personalization_name = payload.get("personalization_name")
if not message and not images:
return jsonify({"success": False, "error": "消息不能为空"}), 400
terminal, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
if not terminal or not workspace:
return jsonify({"success": False, "error": "系统未初始化"}), 503
try:
conversation_id, _ = ensure_conversation_loaded(terminal, conversation_id, workspace=workspace)
except Exception as exc:
return jsonify({"success": False, "error": f"对话加载失败: {exc}"}), 400
# 应用自定义 prompt/personalization如果提供
try:
if prompt_name:
prompt_path = _prompt_dir(workspace) / f"{prompt_name}.txt"
if not prompt_path.exists():
return jsonify({"success": False, "error": "prompt 不存在"}), 404
terminal.context_manager.custom_system_prompt = prompt_path.read_text(encoding="utf-8")
if personalization_name:
pers_path = _personalization_dir(workspace) / f"{personalization_name}.json"
if not pers_path.exists():
return jsonify({"success": False, "error": "personalization 不存在"}), 404
try:
terminal.context_manager.custom_personalization_config = json.loads(pers_path.read_text(encoding="utf-8"))
except Exception:
return jsonify({"success": False, "error": "personalization 解析失败"}), 400
# 持久化到对话元数据
_update_conversation_metadata(workspace, conversation_id, {
"custom_prompt_name": prompt_name,
"personalization_name": personalization_name,
"workspace_id": ws.workspace_id,
})
# 立即应用个性化偏好(确保禁用工具分类生效)
try:
terminal.apply_personalization_preferences(terminal.context_manager.custom_personalization_config)
except Exception as exc:
debug_log(f"[api_v1] 应用个性化失败: {exc}")
except Exception as exc:
return jsonify({"success": False, "error": f"自定义参数错误: {exc}"}), 400
try:
rec = task_manager.create_chat_task(
username=username,
workspace_id=ws.workspace_id,
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,
"workspace_id": ws.workspace_id,
"status": rec.status,
"created_at": rec.created_at,
}), 202
@api_v1_bp.route("/workspaces/<workspace_id>/conversations", methods=["GET"])
@api_token_required
def list_conversations_api(workspace_id: str):
username = session.get("username")
ws = _resolve_workspace(username, workspace_id)
terminal, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
if not terminal or not workspace:
return jsonify({"success": False, "error": "系统未初始化"}), 503
limit = max(1, min(int(request.args.get("limit", 20)), 100))
offset = max(0, int(request.args.get("offset", 0)))
conv_dir = Path(workspace.data_dir) / "conversations"
items = []
for p in sorted(conv_dir.glob("conv_*.json"), key=lambda x: x.stat().st_mtime, reverse=True):
try:
data = json.loads(p.read_text(encoding="utf-8"))
meta = data.get("metadata") or {}
items.append({
"id": data.get("id"),
"title": data.get("title"),
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"run_mode": meta.get("run_mode"),
"model_key": meta.get("model_key"),
"custom_prompt_name": meta.get("custom_prompt_name"),
"personalization_name": meta.get("personalization_name"),
"workspace_id": ws.workspace_id,
"messages_count": len(data.get("messages", [])),
})
except Exception:
continue
sliced = items[offset:offset + limit]
return jsonify({"success": True, "data": sliced, "total": len(items)})
@api_v1_bp.route("/workspaces/<workspace_id>/conversations/<conv_id>", methods=["GET"])
@api_token_required
def get_conversation_api(workspace_id: str, conv_id: str):
username = session.get("username")
ws = _resolve_workspace(username, workspace_id)
_, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
if not workspace:
return jsonify({"success": False, "error": "系统未初始化"}), 503
path = _conversation_path(workspace, conv_id)
if not path.exists():
return jsonify({"success": False, "error": "对话不存在"}), 404
try:
data = json.loads(path.read_text(encoding="utf-8"))
include_messages = request.args.get("full", "0") == "1"
if not include_messages:
data["messages"] = None
return jsonify({"success": True, "data": data, "workspace_id": ws.workspace_id})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
@api_v1_bp.route("/workspaces/<workspace_id>/conversations/<conv_id>", methods=["DELETE"])
@api_token_required
def delete_conversation_api(workspace_id: str, conv_id: str):
username = session.get("username")
ws = _resolve_workspace(username, workspace_id)
terminal, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
if not terminal or not workspace:
return jsonify({"success": False, "error": "系统未初始化"}), 503
result = terminal.delete_conversation(conv_id)
status = 200 if result.get("success") else 404
result["workspace_id"] = ws.workspace_id
return jsonify(result), status
@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,
"workspace_id": getattr(rec, "workspace_id", None),
"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("/workspaces/<workspace_id>/files/upload", methods=["POST"])
@api_token_required
def upload_file_api(workspace_id: str):
username = session.get("username")
ws = _resolve_workspace(username, workspace_id)
terminal, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
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,
"workspace_id": ws.workspace_id,
"path": rel_path,
"filename": target_path.name,
"size": metadata.get("size"),
"sha256": metadata.get("sha256"),
})
@api_v1_bp.route("/workspaces/<workspace_id>/files", methods=["GET"])
@api_token_required
def list_files_api(workspace_id: str):
username = session.get("username")
ws = _resolve_workspace(username, workspace_id)
_, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
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, "workspace_id": ws.workspace_id, "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("/workspaces/<workspace_id>/files/download", methods=["GET"])
@api_token_required
def download_file_api(workspace_id: str):
username = session.get("username")
ws = _resolve_workspace(username, workspace_id)
_, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
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)
@api_v1_bp.route("/prompts", methods=["GET"])
@api_token_required
def list_prompts_api():
username = session.get("username")
# prompts 按用户共享,使用默认工作区上下文
ws = _resolve_workspace(username, "default")
_, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
if not workspace:
return jsonify({"success": False, "error": "系统未初始化"}), 503
base = _prompt_dir(workspace)
items = []
for p in sorted(base.glob("*.txt")):
stat = p.stat()
items.append({
"name": p.stem,
"size": stat.st_size,
"updated_at": stat.st_mtime,
})
return jsonify({"success": True, "items": items})
@api_v1_bp.route("/prompts/<name>", methods=["GET"])
@api_token_required
def get_prompt_api(name: str):
username = session.get("username")
ws = _resolve_workspace(username, "default")
_, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
if not workspace:
return jsonify({"success": False, "error": "系统未初始化"}), 503
p = _prompt_dir(workspace) / f"{name}.txt"
if not p.exists():
return jsonify({"success": False, "error": "prompt 不存在"}), 404
return jsonify({"success": True, "name": name, "content": p.read_text(encoding="utf-8")})
@api_v1_bp.route("/prompts", methods=["POST"])
@api_token_required
def create_prompt_api():
username = session.get("username")
ws = _resolve_workspace(username, "default")
_, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
if not workspace:
return jsonify({"success": False, "error": "系统未初始化"}), 503
payload = request.get_json() or {}
name = (payload.get("name") or "").strip()
content = payload.get("content") or ""
if not name:
return jsonify({"success": False, "error": "name 不能为空"}), 400
p = _prompt_dir(workspace) / f"{name}.txt"
p.write_text(content, encoding="utf-8")
return jsonify({"success": True, "name": name})
@api_v1_bp.route("/personalizations", methods=["GET"])
@api_token_required
def list_personalizations_api():
username = session.get("username")
ws = _resolve_workspace(username, "default")
_, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
if not workspace:
return jsonify({"success": False, "error": "系统未初始化"}), 503
base = _personalization_dir(workspace)
items = []
for p in sorted(base.glob("*.json")):
stat = p.stat()
items.append({
"name": p.stem,
"size": stat.st_size,
"updated_at": stat.st_mtime,
})
return jsonify({"success": True, "items": items})
@api_v1_bp.route("/personalizations/<name>", methods=["GET"])
@api_token_required
def get_personalization_api(name: str):
username = session.get("username")
ws = _resolve_workspace(username, "default")
_, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
if not workspace:
return jsonify({"success": False, "error": "系统未初始化"}), 503
p = _personalization_dir(workspace) / f"{name}.json"
if not p.exists():
return jsonify({"success": False, "error": "personalization 不存在"}), 404
try:
raw = json.loads(p.read_text(encoding="utf-8"))
# 读取时也做一次规范化,避免历史脏数据一直向外暴露
content = sanitize_personalization_payload(raw)
if content != raw:
p.write_text(json.dumps(content, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception as exc:
return jsonify({"success": False, "error": f"解析失败: {exc}"}), 500
return jsonify({"success": True, "name": name, "content": content})
@api_v1_bp.route("/personalizations", methods=["POST"])
@api_token_required
def create_personalization_api():
username = session.get("username")
ws = _resolve_workspace(username, "default")
_, workspace = get_user_resources(username, workspace_id=ws.workspace_id)
if not workspace:
return jsonify({"success": False, "error": "系统未初始化"}), 503
payload = request.get_json() or {}
name = (payload.get("name") or "").strip()
content = payload.get("content")
if not name:
return jsonify({"success": False, "error": "name 不能为空"}), 400
if content is None:
return jsonify({"success": False, "error": "content 不能为空"}), 400
p = _personalization_dir(workspace) / f"{name}.json"
try:
if not isinstance(content, dict):
return jsonify({"success": False, "error": "content 必须是 JSON object"}), 400
existing = None
if p.exists():
try:
existing = json.loads(p.read_text(encoding="utf-8"))
except Exception:
existing = None
# 规范化/清洗:截断长度、过滤非法值、回落默认
sanitized = sanitize_personalization_payload(content, fallback=existing)
p.write_text(json.dumps(sanitized, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception as exc:
return jsonify({"success": False, "error": f"保存失败: {exc}"}), 500
return jsonify({"success": True, "name": name, "content": sanitized})
@api_v1_bp.route("/models", methods=["GET"])
def list_models_api():
items = []
for key, profile in MODEL_PROFILES.items():
items.append({
"model_key": key,
"name": profile.get("name", key),
"supports_thinking": profile.get("supports_thinking", False),
"fast_only": profile.get("fast_only", False),
})
return jsonify({"success": True, "items": items})
@api_v1_bp.route("/health", methods=["GET"])
def health_api():
return jsonify({"success": True, "status": "ok"})
__all__ = ["api_v1_bp"]