7718 lines
412 KiB
Python
7718 lines
412 KiB
Python
"""FastAPI 入口 + 全部 M1 路由"""
|
||
import logging
|
||
import shutil
|
||
import subprocess
|
||
import threading
|
||
import hashlib
|
||
import hmac
|
||
import uuid
|
||
import io
|
||
import json
|
||
import time
|
||
import asyncio
|
||
import zipfile
|
||
import math
|
||
import os
|
||
import re
|
||
from collections import deque
|
||
from pathlib import Path
|
||
from urllib.parse import urlparse
|
||
from fastapi import FastAPI, Depends, HTTPException, UploadFile, File, Form, Request
|
||
from fastapi.exceptions import RequestValidationError
|
||
from fastapi.exception_handlers import http_exception_handler, request_validation_exception_handler
|
||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||
from sqlmodel import Session, select
|
||
from sqlalchemy import update, func
|
||
from openai import OpenAI
|
||
|
||
from .db import init_db, get_session
|
||
from .ownership import owner_fields
|
||
from . import db as _db
|
||
from . import progress as _progress
|
||
from . import seed as seed_mod
|
||
|
||
|
||
def _engine():
|
||
"""动态取当前引擎:测试可整体替换 db.engine,主流程跟随。"""
|
||
return _db.engine
|
||
from .models import (ContentTag, Agent, ModelProvider, ModelCatalog, KnowledgeBase, Campaign, Segment,
|
||
Notification, PendingMemory, Memory, LLMCall, Artifact, QCReport, Canvas,
|
||
CampaignRun, RunEvent, AgentSession,
|
||
KnowledgeUsage, KnowledgeCategory, KnowledgeFile, KnowledgeBaseGrant, MemoryGrant, CanvasGrant,
|
||
AgentSkill, AgentSkillBinding, AgentSkillGrant, AgentGrant,
|
||
AgentPromptTemplate, RoleDefinition, PointAccount, PointTransaction,
|
||
CampaignRun, RunEvent, AgentSession, AppUser, AuthSession, now, DigitalHuman, DigitalHumanGrant,
|
||
AuditLog, SystemLog, ResourceRevision, ResourceFork,
|
||
CampaignPlanVersion, WorkflowTemplate)
|
||
from .models import WeChatBotAccount
|
||
from .models import UsageEvent, UsageQuota
|
||
from .models import AppSetting, TaskQueueItem
|
||
from . import task_queue
|
||
from . import execution_config
|
||
from . import campaign_jobs
|
||
from . import segment_assignment
|
||
from . import system_log
|
||
|
||
logger = logging.getLogger("yawen.main")
|
||
_os = os
|
||
from .agents.coordinator import parse_mindmap, provider_fingerprint
|
||
from .agents import runner
|
||
from .agents.composer import compose
|
||
from .rag import pipeline as rag_pipeline
|
||
from .rag import store as rag_store
|
||
from .rag import embed as rag_embed
|
||
from .config import DATA_DIR, KB_DIR, CORS_ORIGINS
|
||
from .llm import chat
|
||
from .agents.skills import agent_skill_ids, agent_skills, skills_prompt
|
||
from .agents.runtime import (RUNTIME_TOOL_CATALOG, RUNTIME_TOOL_NAMES,
|
||
SYSTEM_RUNTIME_TOOL_NAMES, new_trace_id,
|
||
set_tool_approval)
|
||
from .agents.snapshot import freeze_agent_snapshot, freeze_stage_snapshot
|
||
from .agents.prompting import normalize_template_payload
|
||
from .catalog import resolve_catalog_model, sync_provider_catalog
|
||
from .readiness import readiness_report
|
||
from .category_recommendation import (category_options_for_agent, heuristic_segment_recommendation,
|
||
recommend_segment_categories, validate_segment_category_ids)
|
||
from . import secret_store
|
||
from .ops import ops_dashboard, run_alert_sweep
|
||
from .ai_assist import draft_from_blab as ai_draft, conflict_scan as conflict_check
|
||
from . import login_guard
|
||
from . import perf_metrics
|
||
from .billing import (POINTS_PER_TOKEN, event_payload, events_for_period, grant_points, point_state,
|
||
record_usage_event, usage_summary)
|
||
from .access import (actor_fields, can_access, can_access_agent, request_user, require_kb, require_memory, skill_grant, require_skill,
|
||
visibility_for_create, visible_kb_ids, visible_memory_ids, can_use_canvas, require_canvas, canvas_grant)
|
||
from .auth import (KNOWN_PERMISSIONS, PERMISSION_LABELS, ROLE_LABELS, VALID_ROLES, authenticate_token, feature_permissions, has_permission, hash_password,
|
||
issue_session, permission_catalog_grouped, permission_label, required_permission, revoke_token, role_exists, role_label, user_payload,
|
||
validate_password, validate_username, verify_password)
|
||
|
||
app = FastAPI(title="私域Agent", version="0.1.0-M1")
|
||
from .training_map import router as training_map_router
|
||
from .plan_api import router as plan_api_router, create_campaign_plan
|
||
app.include_router(plan_api_router)
|
||
app.include_router(training_map_router)
|
||
perf_metrics.install_sql_hooks(_engine())
|
||
|
||
# 进程内轻量请求基线。只保留最近 512 次 API 请求,避免为每个请求写
|
||
# SQLite;管理员可通过 /api/ops/performance 查看 P50/P95 和慢接口。
|
||
_HTTP_METRICS_LOCK = threading.Lock()
|
||
_HTTP_METRICS: deque[dict] = deque(maxlen=512)
|
||
|
||
def _record_http_metric(path: str, method: str, status: int, elapsed_ms: float) -> None:
|
||
if not path.startswith("/api/"):
|
||
return
|
||
with _HTTP_METRICS_LOCK:
|
||
_HTTP_METRICS.append({"endpoint": f"{method} {path}", "status": int(status),
|
||
"elapsed_ms": float(elapsed_ms), "at": time.time()})
|
||
|
||
def _metric_path(path: str) -> str:
|
||
return re.sub(r"/[0-9a-fA-F]{8,}|/\d+(?=/|$)", "/:id", path)[:180]
|
||
|
||
def _metric_percentile(values: list[float], fraction: float) -> float:
|
||
if not values:
|
||
return 0.0
|
||
values = sorted(values)
|
||
return round(values[min(len(values) - 1, int((len(values) - 1) * fraction))], 2)
|
||
|
||
@app.exception_handler(StarletteHTTPException)
|
||
async def detailed_http_exception_handler(request: Request, exc: StarletteHTTPException):
|
||
# 中间件在响应返回后读取该字段,把 404/422 等错误的具体原因写进系统日志。
|
||
request.state.system_log_detail = system_log.safe_detail(exc.detail)
|
||
return await http_exception_handler(request, exc)
|
||
|
||
@app.exception_handler(RequestValidationError)
|
||
async def detailed_validation_exception_handler(request: Request, exc: RequestValidationError):
|
||
request.state.system_log_detail = system_log.safe_detail(exc.errors())
|
||
return await request_validation_exception_handler(request, exc)
|
||
|
||
@app.middleware("http")
|
||
async def security_headers(request, call_next):
|
||
response = await call_next(request)
|
||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
||
response.headers["Content-Security-Policy"] = "frame-ancestors 'self'"
|
||
return response
|
||
app.add_middleware(CORSMiddleware, allow_origins=CORS_ORIGINS, allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||
allow_headers=["Content-Type", "Authorization", "X-WeChat-Bot-Secret",
|
||
"X-WeChat-Bot-Timestamp", "X-WeChat-Bot-Signature"], allow_credentials=False)
|
||
|
||
_ACTIVE_RUNS: set[int] = set()
|
||
_RUN_LOCK = threading.Lock()
|
||
_INLINE_WORKER_ID = f"inline-{__import__('socket').gethostname()}-{_os.getpid()}-{uuid.uuid4().hex[:6]}"
|
||
|
||
_PUBLIC_AUTH_PATHS = {"/api/health", "/api/auth/status", "/api/auth/bootstrap", "/api/auth/login",
|
||
"/api/wechat/bot/reply"}
|
||
|
||
|
||
def _audit_write(db: Session, *, actor_user_id: int = 0, actor_display_name: str = "", action: str, resource: str, result: str = "success", metadata: dict | None = None) -> AuditLog:
|
||
"""统一安全审计写入:仅记录动作/资源/结果,绝不记录 body、token 或 secrets。"""
|
||
safe_resource = str(resource or "")[:240]
|
||
row = AuditLog(actor_user_id=int(actor_user_id or 0), actor_display_name=str(actor_display_name or "")[:120], action=str(action)[:120], resource=safe_resource, result=str(result)[:40])
|
||
db.add(row); db.commit(); db.refresh(row)
|
||
return row
|
||
|
||
|
||
def _write_http_failure(*, request: Request, trace_id: str, status_code: int,
|
||
elapsed_ms: float, actor: str, reason: str = "") -> None:
|
||
"""Best-effort HTTP failure log; observability must never mask the response."""
|
||
try:
|
||
status_group = f"{status_code // 100}xx"
|
||
level = "ERROR" if status_code >= 500 else "WARNING"
|
||
detail = system_log.safe_detail(reason) if reason else ""
|
||
suffix = f" · 原因:{detail}" if detail else ""
|
||
with Session(_engine()) as log_db:
|
||
system_log.emit(log_db, level=level, source="http",
|
||
message=(f"{request.method} {request.url.path} · HTTP {status_code} · "
|
||
f"用户:{actor} · 耗时:{elapsed_ms}ms{suffix}"),
|
||
trace_id=trace_id, status=status_group)
|
||
except Exception as log_exc:
|
||
# 日志存储故障不能遮蔽原始请求结果,也不把异常详情写入第二套日志。
|
||
logger.error("system log write failed (%s)", type(log_exc).__name__)
|
||
|
||
|
||
def _response_error_detail(response) -> str:
|
||
"""从 JSON 错误响应补取业务 detail,避免异常处理器未写入 request.state 时丢失原因。"""
|
||
try:
|
||
body = getattr(response, "body", b"")
|
||
if isinstance(body, bytes) and body:
|
||
payload = json.loads(body.decode("utf-8"))
|
||
detail = payload.get("detail") if isinstance(payload, dict) else ""
|
||
return system_log.safe_detail(detail) if detail else ""
|
||
except (UnicodeDecodeError, ValueError, TypeError, AttributeError):
|
||
pass
|
||
return ""
|
||
|
||
|
||
@app.middleware("http")
|
||
async def require_authenticated_api(request: Request, call_next):
|
||
"""所有 API 默认认证;新增写接口未显式匹配时只允许管理员调用。"""
|
||
path = request.url.path
|
||
incoming_trace = request.headers.get("X-Request-ID", "")
|
||
trace_id = re.sub(r"[^A-Za-z0-9._:-]", "", incoming_trace)[:64] or uuid.uuid4().hex[:16]
|
||
request.state.trace_id = trace_id
|
||
started_at = time.perf_counter()
|
||
if (not path.startswith("/api/") or request.method == "OPTIONS" or path in _PUBLIC_AUTH_PATHS
|
||
or (request.method == "POST" and re.match(r"^/api/wechat/bot/accounts/\d+/callback$", path))):
|
||
return await call_next(request)
|
||
permission = required_permission(request.method, path)
|
||
# 侧栏功能权限是第二道门:保留旧单权限映射的兼容性,同时确保拥有
|
||
# 通用 read 的角色不能访问未被授予的功能页面及其 GET 接口。
|
||
feature_perms = feature_permissions(request.method, path)
|
||
auth_header = request.headers.get("Authorization")
|
||
if not auth_header and path.startswith("/api/events/"):
|
||
# EventSource 无法携带 Authorization 头,允许 ?token= 查询参数(仅 SSE 端点)
|
||
qt = request.query_params.get("token")
|
||
if qt:
|
||
auth_header = f"Bearer {qt}"
|
||
with Session(_engine(), expire_on_commit=False) as auth_db:
|
||
user = authenticate_token(auth_db, auth_header)
|
||
base_allowed = bool(user and has_permission(user, permission, auth_db))
|
||
# 功能栏权限是入口级硬门槛:如果接口映射了 feature:*,必须先拥有该功能,
|
||
# 再由后面的细分权限控制读写动作;旧接口没有 feature:* 时保持兼容。
|
||
# 功能栏权限是独立入口开关:必须明确勾选 feature:* 才能进入或调用该功能。
|
||
# 细分权限只负责控制该功能内的读写动作,避免仅有旧的通用 read 绕过侧栏。
|
||
feature_key = feature_perms[0] if feature_perms and feature_perms[0].startswith("feature:") else ""
|
||
# 未归属侧栏功能的基础接口(例如个人余额、登录辅助端点)只走原有
|
||
# required_permission;不要因空权限元组被 any([]) 判定为拒绝。
|
||
feature_allowed = bool(user and (
|
||
has_permission(user, feature_key, auth_db) if feature_key
|
||
else (not feature_perms or any(has_permission(user, item, auth_db) for item in feature_perms))
|
||
))
|
||
# GET 页面按功能栏权限判断:审查员可以查看任务但不具备 tasks:write,
|
||
# 只要命中该功能的任一查看权限即可;写请求仍必须满足原有精确权限。
|
||
allowed = bool(user and feature_allowed and (base_allowed or (request.method in {"GET", "HEAD"} and feature_perms)))
|
||
if not user:
|
||
_write_http_failure(request=request, trace_id=trace_id, status_code=401,
|
||
elapsed_ms=round((time.perf_counter() - started_at) * 1000, 1),
|
||
actor="未登录用户", reason="登录已过期或无有效访问令牌")
|
||
return JSONResponse(status_code=401, headers={"X-Request-ID": trace_id},
|
||
content={"detail": "登录已过期或无有效访问令牌"})
|
||
if not allowed:
|
||
reason = f"当前角色没有权限:{permission_label(permission)}"
|
||
actor = str(getattr(user, "display_name", "") or getattr(user, "username", "") or "未知用户")
|
||
_write_http_failure(request=request, trace_id=trace_id, status_code=403,
|
||
elapsed_ms=round((time.perf_counter() - started_at) * 1000, 1),
|
||
actor=actor, reason=reason)
|
||
if user and request.method.upper() in {"POST", "PUT", "PATCH", "DELETE"}:
|
||
try:
|
||
with Session(_engine()) as audit_db:
|
||
_audit_write(audit_db, actor_user_id=int(getattr(user, "id", 0) or 0),
|
||
actor_display_name=actor, action=f"http.{request.method.lower()}",
|
||
resource=path, result="failure")
|
||
except Exception as log_exc:
|
||
logger.error("audit write failed (%s)", type(log_exc).__name__)
|
||
return JSONResponse(status_code=403, headers={"X-Request-ID": trace_id}, content={"detail": reason})
|
||
request.state.user = user
|
||
started_at = time.perf_counter()
|
||
endpoint = f"{request.method} {request.url.path}"
|
||
actor = str(getattr(user, "display_name", "") or getattr(user, "username", "") or "未知用户")
|
||
try:
|
||
# Cloudflare 公网网关约 100 秒会返回 524。对普通 API 提前在 95 秒
|
||
# 结束请求并落一条可检索的系统日志,避免网关超时后后台完全没有记录。
|
||
# SSE 进度流需要长连接,因此保持原有行为不套此超时。
|
||
if path.startswith("/api/") and not path.startswith("/api/events/"):
|
||
response = await asyncio.wait_for(call_next(request), timeout=95)
|
||
else:
|
||
response = await call_next(request)
|
||
except asyncio.TimeoutError:
|
||
elapsed_ms = round((time.perf_counter() - started_at) * 1000, 1)
|
||
_record_http_metric(path, request.method, 504, elapsed_ms)
|
||
detail = "请求处理超过 95 秒,已提前结束;公网网关可能显示 524,请改用后台任务或稍后重试"
|
||
try:
|
||
with Session(_engine()) as log_db:
|
||
system_log.write(log_db, level="ERROR", source="http",
|
||
message=f"{endpoint} · 超时:{detail} · 用户:{actor} · 耗时:{elapsed_ms}ms",
|
||
trace_id=trace_id, status="timeout")
|
||
except Exception as log_exc:
|
||
logger.error("system timeout log write failed (%s)", type(log_exc).__name__)
|
||
if request.method.upper() in {"POST", "PUT", "PATCH", "DELETE"}:
|
||
try:
|
||
with Session(_engine()) as audit_db:
|
||
_audit_write(audit_db, actor_user_id=int(getattr(user, "id", 0) or 0),
|
||
actor_display_name=actor, action=f"http.{request.method.lower()}",
|
||
resource=path, result="failure")
|
||
except Exception as log_exc:
|
||
logger.error("audit timeout write failed (%s)", type(log_exc).__name__)
|
||
return JSONResponse(status_code=504, headers={"X-Request-ID": trace_id},
|
||
content={"detail": detail, "trace_id": trace_id})
|
||
except Exception as exc:
|
||
elapsed_ms = round((time.perf_counter() - started_at) * 1000, 1)
|
||
_record_http_metric(path, request.method, 500, elapsed_ms)
|
||
try:
|
||
with Session(_engine()) as log_db:
|
||
system_log.write(log_db, level="ERROR", source="http",
|
||
message=(f"{endpoint} · 未处理异常 {type(exc).__name__}: "
|
||
f"{system_log.safe_detail(exc)} · 用户:{actor} · 耗时:{elapsed_ms}ms"),
|
||
trace_id=trace_id, status="unhandled")
|
||
except Exception as log_exc:
|
||
logger.error("system log write failed (%s)", type(log_exc).__name__)
|
||
if request.method.upper() in {"POST", "PUT", "PATCH", "DELETE"}:
|
||
try:
|
||
with Session(_engine()) as audit_db:
|
||
_audit_write(audit_db, actor_user_id=int(getattr(user, "id", 0) or 0),
|
||
actor_display_name=actor, action=f"http.{request.method.lower()}",
|
||
resource=path, result="failure")
|
||
except Exception as log_exc:
|
||
logger.error("audit write failed (%s)", type(log_exc).__name__)
|
||
raise
|
||
elapsed_ms = round((time.perf_counter() - started_at) * 1000, 1)
|
||
_record_http_metric(path, request.method, response.status_code, elapsed_ms)
|
||
response.headers["X-Request-ID"] = trace_id
|
||
# 所有会改变业务状态的请求都写入操作审计。此前这里只记录失败请求,
|
||
# 导致创建内容段、提交任务等成功操作在操作日志中完全不可见。
|
||
# GET 轮询和读取接口不写审计,避免页面自动刷新制造大量噪声。
|
||
# Estimate is a read-only preview despite using POST for its structured
|
||
# payload. It is polled while the user edits the selection; auditing every
|
||
# poll creates unnecessary SQLite writes and can make the preview wait
|
||
# behind a long-running knowledge-base reading transaction.
|
||
audit_request = request.method.upper() in {"POST", "PUT", "PATCH", "DELETE"} and path != "/api/training-maps/estimate"
|
||
if audit_request:
|
||
try:
|
||
with Session(_engine()) as audit_db:
|
||
_audit_write(audit_db, actor_user_id=int(getattr(user, "id", 0) or 0),
|
||
actor_display_name=str(getattr(user, "display_name", "") or getattr(user, "username", "") or ""),
|
||
action=f"http.{request.method.lower()}", resource=path,
|
||
result="success" if response.status_code < 400 else "failure")
|
||
except Exception as exc:
|
||
logger.error("audit write failed: %s", exc)
|
||
if response.status_code >= 400:
|
||
reason = getattr(request.state, "system_log_detail", "") or _response_error_detail(response)
|
||
_write_http_failure(request=request, trace_id=trace_id, status_code=response.status_code,
|
||
elapsed_ms=elapsed_ms, actor=actor,
|
||
reason=reason)
|
||
return response
|
||
|
||
@app.middleware("http")
|
||
async def performance_telemetry(request: Request, call_next):
|
||
"""Collect total API latency, including authentication and audit writes."""
|
||
if not request.url.path.startswith("/api/"):
|
||
return await call_next(request)
|
||
incoming = re.sub(r"[^A-Za-z0-9._:-]", "", request.headers.get("X-Request-ID", ""))[:64]
|
||
scope = perf_metrics.request_scope(incoming)
|
||
metrics = scope.__enter__()
|
||
try:
|
||
response = await call_next(request)
|
||
row = metrics.finish(request.method, _metric_path(request.url.path), response.status_code)
|
||
perf_metrics.ingest_http(row)
|
||
return response
|
||
except Exception:
|
||
row = metrics.finish(request.method, _metric_path(request.url.path), 500)
|
||
perf_metrics.ingest_http(row)
|
||
raise
|
||
finally:
|
||
scope.__exit__(None, None, None)
|
||
|
||
@app.get("/api/audit-logs")
|
||
def list_audit_logs(request: Request, page: int = 1, page_size: int = 50,
|
||
s: Session = Depends(get_session)):
|
||
page = max(1, page); page_size = min(100, max(1, page_size))
|
||
rows = s.exec(select(AuditLog).order_by(AuditLog.id.desc()).offset((page-1)*page_size).limit(page_size)).all()
|
||
total = int(s.exec(select(func.count()).select_from(AuditLog)).one() or 0)
|
||
return {"items": [{"id": r.id, "display_name": r.actor_display_name, "action": r.action,
|
||
"resource": r.resource, "result": r.result, "created_at": r.created_at} for r in rows],
|
||
"page": page, "page_size": page_size, "total": total}
|
||
|
||
@app.get("/api/system-logs")
|
||
def list_system_logs(request: Request, page: int = 1, page_size: int = 50,
|
||
level: str = "", source: str = "", s: Session = Depends(get_session)):
|
||
user = request_user(request)
|
||
if not user or user.role != "admin":
|
||
raise HTTPException(403, "仅管理员可查看系统日志")
|
||
page = max(1, page); page_size = min(100, max(1, page_size))
|
||
# 系统日志页面只保留需要关注的信息和错误,普通 WARNING/WARN 不展示。
|
||
# 仍保留数据库原始记录,便于后续审计和故障排查。
|
||
q = select(SystemLog).where(SystemLog.level.notin_(["WARNING", "WARN"]))
|
||
if level:
|
||
normalized_level = level.upper()
|
||
if normalized_level in {"WARNING", "WARN"}:
|
||
q = q.where(SystemLog.id == -1)
|
||
else:
|
||
q = q.where(SystemLog.level == normalized_level)
|
||
if source: q = q.where(SystemLog.source == source)
|
||
rows = s.exec(q.order_by(SystemLog.id.desc()).offset((page-1)*page_size).limit(page_size)).all()
|
||
# count(*) 只扫描索引,不把整张日志表加载到 Python 内存。
|
||
total = int(s.exec(select(func.count()).select_from(q.subquery())).one() or 0)
|
||
return {"items": [{"id": r.id, "level": r.level, "source": r.source, "message": r.message,
|
||
"trace_id": r.trace_id, "status": r.status, "created_at": r.created_at} for r in rows],
|
||
"page": page, "page_size": page_size, "total": total}
|
||
|
||
|
||
def _active_admin_count(s: Session, organization_id: int | None = None) -> int:
|
||
query = select(AppUser).where(AppUser.role == "admin", AppUser.enabled == True) # noqa: E712
|
||
if organization_id is not None:
|
||
query = query.where(AppUser.organization_id == int(organization_id))
|
||
return len(s.exec(query).all())
|
||
|
||
|
||
@app.get("/api/health")
|
||
def health_check():
|
||
"""供本地启动器和部署平台判断进程存活,不读取或返回业务数据。"""
|
||
return {"status": "ok", "service": "私域Agent", "version": app.version}
|
||
|
||
|
||
@app.get("/api/ops/performance")
|
||
def performance_metrics(request: Request):
|
||
"""管理员性能基线;仅返回当前 API 进程收到的匿名聚合数据。"""
|
||
user = request_user(request)
|
||
if not user or user.role != "admin":
|
||
raise HTTPException(403, "仅管理员可查看性能指标")
|
||
current = perf_metrics.snapshot()
|
||
current["rag"] = perf_metrics.rag_snapshot()
|
||
# Query-vector cache counters live in the resident RAG worker; per-request
|
||
# envelopes already include cache_hit and PID, so do not expose the API
|
||
# process's unrelated (usually empty) cache as a misleading total.
|
||
# 统一使用匿名、归一化路由指标;旧兼容缓冲不再对外暴露原始路径。
|
||
status_counts = current.get("api", {}).get("status_counts", {})
|
||
return {
|
||
"samples": current["window_samples"],
|
||
"api": {**current["api"], "status_counts": status_counts},
|
||
"rag": current["rag"],
|
||
"scope": current["scope"], "window_samples": current["window_samples"],
|
||
"resets_on_restart": True,
|
||
"note": "仅统计当前 API 进程最近 512 次请求;RAG 子进程每次检索通过匿名指标信封回传",
|
||
}
|
||
|
||
|
||
@app.get("/api/ops/dashboard")
|
||
def ops_dashboard_api(request: Request, s: Session = Depends(get_session)):
|
||
"""P2:运营指标看板(队列/任务/成本/知识库/Provider 健康/死信/告警)。
|
||
|
||
管理员看全组织;普通用户只看自己的任务/用量/告警(用户隔离)。
|
||
"""
|
||
user = request_user(request)
|
||
owner_user_id = None if (user and user.role == "admin") else int(getattr(user, "id", 0) or 0)
|
||
dash = ops_dashboard(s, owner_user_id=owner_user_id,
|
||
organization_id=(int(user.organization_id or 1) if user else None))
|
||
# 组织级敏感数据仅管理员可见(成本/Provider 健康/死信/备份)
|
||
if not (user and user.role == "admin"):
|
||
dash = {k: v for k, v in dash.items()
|
||
if k not in ("cost_7d", "providers", "dead_letter", "backup")}
|
||
dash["scope_note"] = "组织级运营数据仅管理员可见"
|
||
return dash
|
||
|
||
|
||
@app.post("/api/ops/alerts/sweep")
|
||
def ops_alert_sweep(s: Session = Depends(get_session)):
|
||
"""P2:手动触发一轮告警巡检(前端定时轮询调用)。"""
|
||
return {"fired": run_alert_sweep(s)}
|
||
|
||
|
||
@app.post("/api/ops/dead-letter/{task_id}/requeue")
|
||
def ops_requeue_dead(task_id: int, s: Session = Depends(get_session)):
|
||
"""P2:手动重放死信任务。"""
|
||
from .task_queue import requeue_dead
|
||
if not requeue_dead(s, TaskQueueItem, task_id):
|
||
raise HTTPException(404, "死信任务不存在或状态已变化")
|
||
return {"ok": True}
|
||
|
||
|
||
@app.get("/api/ops/backup")
|
||
def ops_backup_status():
|
||
"""P0-5:备份状态(最近备份/保留策略/恢复演练结果)。"""
|
||
from . import backup as backup_mod
|
||
return backup_mod.backup_status()
|
||
|
||
|
||
@app.post("/api/ops/backup")
|
||
def ops_backup_now():
|
||
"""P0-5:立即执行一次备份并返回状态。"""
|
||
from . import backup as backup_mod
|
||
path = backup_mod.create_backup("manual")
|
||
if not path:
|
||
raise HTTPException(500, "备份失败,请查看服务日志")
|
||
return {"ok": True, "file": path.name, "status": backup_mod.backup_status()}
|
||
|
||
|
||
@app.post("/api/ops/backup/drill")
|
||
def ops_backup_drill():
|
||
"""P0-5:对最新备份执行一次恢复演练。"""
|
||
from . import backup as backup_mod
|
||
return backup_mod.verify_restore_latest()
|
||
|
||
|
||
@app.get("/api/system/status")
|
||
def system_status(s: Session = Depends(get_session)):
|
||
"""Hermes 融合:侧栏真实系统自检(数据库 / 向量库 / 磁盘 / 运行队列)。"""
|
||
import os as _os
|
||
from .rag import store as _rag_store
|
||
checks: dict[str, dict] = {}
|
||
try:
|
||
s.exec(select(AppUser).limit(1)).first()
|
||
checks["database"] = {"ok": True, "label": "数据库"}
|
||
except Exception as exc:
|
||
checks["database"] = {"ok": False, "label": "数据库", "detail": str(exc)[:120]}
|
||
try:
|
||
from .rag import backend as _rag_backend
|
||
if _rag_backend.is_disabled():
|
||
checks["vector"] = {"ok": True, "label": "向量库", "detail": "本地向量已禁用(轻量模式)"}
|
||
else:
|
||
_rag_store.get_client()
|
||
checks["vector"] = {"ok": True, "label": "向量库", "detail": _rag_backend.effective_backend()}
|
||
except Exception as exc:
|
||
checks["vector"] = {"ok": False, "label": "向量库", "detail": str(exc)[:120]}
|
||
try:
|
||
usage = __import__("shutil").disk_usage(DATA_DIR)
|
||
free_gb = usage.free / 1024 ** 3
|
||
checks["disk"] = {"ok": free_gb > 1.0, "label": "磁盘空间",
|
||
"detail": f"可用 {free_gb:.1f} GB" if free_gb <= 1.0 else ""}
|
||
except Exception as exc:
|
||
checks["disk"] = {"ok": False, "label": "磁盘空间", "detail": str(exc)[:120]}
|
||
backend = execution_config.execution_backend()
|
||
dsh_pending = dsh_running = 0
|
||
if backend == "dsh":
|
||
dsh_pending = len(s.exec(select(TaskQueueItem).where(
|
||
TaskQueueItem.task_key.like("campaign-%"),
|
||
TaskQueueItem.status == "pending")).all())
|
||
dsh_running = len(s.exec(select(TaskQueueItem).where(
|
||
TaskQueueItem.task_key.like("campaign-%"),
|
||
TaskQueueItem.status == "running")).all())
|
||
active = dsh_running if backend == "dsh" else len(_ACTIVE_RUNS)
|
||
scheduler_detail = (f"DSH:{dsh_pending} 个待领取 / {dsh_running} 个执行中"
|
||
if backend == "dsh" else
|
||
(f"{active} 个运行中" if active else ""))
|
||
checks["scheduler"] = {"ok": True, "label": "任务调度", "detail": scheduler_detail}
|
||
from .env_config import IS_DEV
|
||
degraded = [name for name, item in checks.items() if not item["ok"]]
|
||
return {"status": "ok" if not degraded else "degraded",
|
||
"checks": checks,
|
||
"environment": "dev" if IS_DEV else "prod",
|
||
"execution_backend": backend,
|
||
"active_runs": active,
|
||
"dsh_queue": {"pending": dsh_pending, "running": dsh_running},
|
||
"pid": _os.getpid()}
|
||
|
||
|
||
@app.get("/api/auth/status")
|
||
def auth_status(request: Request, s: Session = Depends(get_session)):
|
||
user = authenticate_token(s, request.headers.get("Authorization"), touch=False)
|
||
return {"bootstrap_required": not s.exec(select(AppUser)).first(),
|
||
"authenticated": bool(user), "user": user_payload(user, s) if user else None,
|
||
"roles": sorted(VALID_ROLES),
|
||
"role_catalog": [{"role": key, "label": ROLE_LABELS[key]} for key in sorted(ROLE_LABELS)]}
|
||
|
||
|
||
def _next_app_user_id(s: Session) -> int:
|
||
"""分配永不复用的本地账号 ID,避免历史 owner/user_id 误指向新账号。"""
|
||
max_id = s.exec(select(AppUser.id).order_by(AppUser.id.desc())).first() or 0
|
||
marker = s.get(AppSetting, "auth.next_user_id")
|
||
try:
|
||
next_id = max(int(max_id) + 1, int(marker.value) if marker and marker.value else 1)
|
||
except (TypeError, ValueError):
|
||
next_id = int(max_id) + 1
|
||
if marker is None:
|
||
marker = AppSetting(key="auth.next_user_id")
|
||
marker.value = str(next_id + 1)
|
||
s.add(marker)
|
||
return next_id
|
||
|
||
|
||
@app.post("/api/auth/bootstrap")
|
||
def bootstrap_admin(body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
ip = login_guard.client_ip(request)
|
||
login_guard.check_login_allowed(ip, "__bootstrap__")
|
||
if s.exec(select(AppUser)).first():
|
||
raise HTTPException(409, "管理员已初始化,请直接登录")
|
||
try:
|
||
username = validate_username(body.get("username"))
|
||
password = validate_password(body.get("password"))
|
||
except ValueError as exc:
|
||
raise HTTPException(422, str(exc)) from exc
|
||
user = AppUser(id=_next_app_user_id(s), username=username, display_name=str(body.get("display_name") or username).strip()[:80],
|
||
password_hash=hash_password(password), role="admin", enabled=True)
|
||
s.add(user); s.commit(); s.refresh(user)
|
||
token, auth_session = issue_session(s, user)
|
||
_audit_write(s, actor_user_id=user.id or 0, actor_display_name=user.display_name,
|
||
action="login", resource="/api/auth/bootstrap", result="success")
|
||
return {"token": token, "expires_at": auth_session.expires_at, "user": user_payload(user, s)}
|
||
|
||
|
||
@app.post("/api/auth/login")
|
||
def login(body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
ip = login_guard.client_ip(request)
|
||
username = str(body.get("username") or "").strip().lower()
|
||
login_guard.check_login_allowed(ip, username)
|
||
try:
|
||
username = validate_username(body.get("username"))
|
||
except ValueError:
|
||
login_guard.record_login_failure(ip, username)
|
||
raise HTTPException(401, "用户名或密码错误")
|
||
user = s.exec(select(AppUser).where(AppUser.username == username)).first()
|
||
if not user or not user.enabled or not verify_password(str(body.get("password") or ""), user.password_hash):
|
||
login_guard.record_login_failure(ip, username)
|
||
raise HTTPException(401, "用户名或密码错误")
|
||
login_guard.record_login_success(ip, username)
|
||
token, auth_session = issue_session(s, user)
|
||
_audit_write(s, actor_user_id=user.id or 0, actor_display_name=user.display_name,
|
||
action="login", resource="/api/auth/login", result="success")
|
||
return {"token": token, "expires_at": auth_session.expires_at, "user": user_payload(user, s)}
|
||
|
||
|
||
@app.post("/api/auth/logout")
|
||
def logout(request: Request, s: Session = Depends(get_session)):
|
||
revoke_token(s, request.headers.get("Authorization"))
|
||
return {"ok": True}
|
||
|
||
|
||
@app.get("/api/auth/me")
|
||
def current_user(request: Request, s: Session = Depends(get_session)):
|
||
return user_payload(request.state.user, s)
|
||
|
||
|
||
@app.get("/api/auth/users")
|
||
def list_users(request: Request, s: Session = Depends(get_session)):
|
||
organization_id = int(request.state.user.organization_id or 1)
|
||
return [user_payload(user, s) | {"updated_at": user.updated_at}
|
||
for user in s.exec(select(AppUser).where(AppUser.organization_id == organization_id).order_by(AppUser.id)).all()
|
||
if not user.username.startswith("deleted_")]
|
||
|
||
@app.get("/api/auth/roles")
|
||
def list_roles(request: Request, s: Session = Depends(get_session)):
|
||
organization_id = int(request.state.user.organization_id or 1)
|
||
rows = s.exec(select(RoleDefinition).where(
|
||
RoleDefinition.organization_id.in_([0, organization_id])
|
||
).order_by(RoleDefinition.id)).all()
|
||
catalog = [{"key": key, "label": PERMISSION_LABELS[key]} for key in KNOWN_PERMISSIONS]
|
||
# 保留旧 permission_catalog 字段,同时提供按功能栏分组的目录,前端可用抽屉逐层展示。
|
||
catalog_grouped = permission_catalog_grouped()
|
||
return [{"id": row.id, "role": row.role, "label": row.label or ROLE_LABELS.get(row.role, "自定义角色"),
|
||
"description": row.description, "permissions": list(row.permissions or []),
|
||
"builtin": row.builtin, "organization_id": row.organization_id,
|
||
"permission_labels": {item: PERMISSION_LABELS.get(item, "其他权限") for item in (row.permissions or [])},
|
||
"permission_catalog": catalog,
|
||
"permission_catalog_grouped": catalog_grouped}
|
||
for row in rows]
|
||
|
||
@app.put("/api/auth/roles/{role}")
|
||
def update_role_permissions(role: str, body: dict, request: Request, s: Session = Depends(get_session)):
|
||
organization_id = int(request.state.user.organization_id or 1)
|
||
if not role_exists(s, role, organization_id):
|
||
raise HTTPException(404, "角色不存在")
|
||
permissions = body.get("permissions")
|
||
if not isinstance(permissions, list) or any(str(item) not in KNOWN_PERMISSIONS and str(item) != "*" for item in permissions):
|
||
raise HTTPException(422, "权限必须来自系统权限目录")
|
||
row = s.exec(select(RoleDefinition).where(
|
||
RoleDefinition.role == role, RoleDefinition.organization_id == organization_id
|
||
)).first()
|
||
row = row or RoleDefinition(role=role, label=ROLE_LABELS.get(role, "自定义角色"),
|
||
builtin=role in VALID_ROLES, organization_id=organization_id)
|
||
if "label" in body:
|
||
label = str(body.get("label") or "").strip()
|
||
if not 1 <= len(label) <= 40:
|
||
raise HTTPException(422, "角色名称需要 1 到 40 个字符")
|
||
row.label = label
|
||
if "description" in body:
|
||
row.description = str(body.get("description") or "").strip()[:200]
|
||
# 管理员角色始终保留全量权限,避免把最后一个管理员锁在系统外。
|
||
row.permissions = ["*"] if role == "admin" else sorted(set(str(item) for item in permissions))
|
||
row.updated_at = now()
|
||
s.add(row); s.commit(); s.refresh(row)
|
||
return {"id": row.id, "role": row.role, "label": row.label,
|
||
"description": row.description, "permissions": list(row.permissions or []), "builtin": row.builtin}
|
||
|
||
|
||
@app.post("/api/auth/roles")
|
||
def create_role(body: dict, request: Request, s: Session = Depends(get_session)):
|
||
label = str(body.get("label") or "").strip()
|
||
if not 1 <= len(label) <= 40:
|
||
raise HTTPException(422, "角色名称需要 1 到 40 个字符")
|
||
description = str(body.get("description") or "").strip()[:200]
|
||
permissions = body.get("permissions") or []
|
||
if not isinstance(permissions, list) or any(str(item) not in KNOWN_PERMISSIONS for item in permissions):
|
||
raise HTTPException(422, "自定义角色只能选择系统权限目录中的权限")
|
||
role = f"custom_{uuid.uuid4().hex[:12]}"
|
||
row = RoleDefinition(role=role, label=label, description=description,
|
||
permissions=sorted(set(str(item) for item in permissions)), builtin=False,
|
||
organization_id=int(request.state.user.organization_id or 1))
|
||
s.add(row); s.commit(); s.refresh(row)
|
||
return {"id": row.id, "role": row.role, "label": row.label,
|
||
"description": row.description, "permissions": list(row.permissions or []), "builtin": False,
|
||
"organization_id": row.organization_id}
|
||
|
||
|
||
@app.delete("/api/auth/roles/{role}")
|
||
def delete_role(role: str, request: Request, s: Session = Depends(get_session)):
|
||
if role in VALID_ROLES:
|
||
raise HTTPException(409, "系统内置角色只能调整权限,不能删除")
|
||
organization_id = int(request.state.user.organization_id or 1)
|
||
row = s.exec(select(RoleDefinition).where(
|
||
RoleDefinition.role == role, RoleDefinition.organization_id == organization_id
|
||
)).first()
|
||
if not row:
|
||
raise HTTPException(404, "角色不存在")
|
||
if s.exec(select(AppUser).where(AppUser.role == role, AppUser.organization_id == organization_id)).first():
|
||
raise HTTPException(409, "该角色仍有成员使用,请先调整成员角色")
|
||
s.delete(row); s.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/auth/users")
|
||
def create_user(body: dict, request: Request, s: Session = Depends(get_session)):
|
||
try:
|
||
username = validate_username(body.get("username"))
|
||
password = validate_password(body.get("password"))
|
||
except ValueError as exc:
|
||
raise HTTPException(422, str(exc)) from exc
|
||
if s.exec(select(AppUser).where(AppUser.username == username)).first():
|
||
raise HTTPException(409, "用户名已存在")
|
||
role = str(body.get("role") or "viewer")
|
||
if not role_exists(s, role, int(request.state.user.organization_id or 1)):
|
||
raise HTTPException(422, "角色无效")
|
||
user = AppUser(id=_next_app_user_id(s), username=username, display_name=str(body.get("display_name") or username).strip()[:80],
|
||
password_hash=hash_password(password), role=role, enabled=bool(body.get("enabled", True)),
|
||
organization_id=int(request.state.user.organization_id or 1))
|
||
s.add(user); s.commit(); s.refresh(user)
|
||
return user_payload(user, s)
|
||
|
||
|
||
@app.put("/api/auth/users/{user_id}")
|
||
def update_user(user_id: int, body: dict, request: Request, s: Session = Depends(get_session)):
|
||
user = s.get(AppUser, user_id)
|
||
if not user or user.organization_id != request.state.user.organization_id:
|
||
raise HTTPException(404, "用户不存在")
|
||
if user.username.startswith("deleted_"):
|
||
raise HTTPException(409, "已删除的用户不能修改")
|
||
role = str(body.get("role", user.role))
|
||
enabled = bool(body.get("enabled", user.enabled))
|
||
if not role_exists(s, role):
|
||
raise HTTPException(422, "角色无效")
|
||
if user.role == "admin" and user.enabled and (role != "admin" or not enabled) and _active_admin_count(s, user.organization_id) <= 1:
|
||
raise HTTPException(409, "至少保留一个启用的管理员")
|
||
if user.id == request.state.user.id and not enabled:
|
||
raise HTTPException(409, "不能停用当前登录账号")
|
||
user.role = role
|
||
user.enabled = enabled
|
||
if "display_name" in body:
|
||
user.display_name = str(body.get("display_name") or user.username).strip()[:80]
|
||
user.updated_at = now()
|
||
s.add(user); s.commit()
|
||
return user_payload(user, s)
|
||
|
||
|
||
@app.delete("/api/auth/users/{user_id}")
|
||
def delete_user(user_id: int, request: Request, s: Session = Depends(get_session)):
|
||
"""永久删除成员账号,同时清理登录会话、授权关系和用户级配额。"""
|
||
user = s.get(AppUser, user_id)
|
||
if not user or user.organization_id != request.state.user.organization_id:
|
||
raise HTTPException(404, "用户不存在")
|
||
if user.id == request.state.user.id:
|
||
raise HTTPException(409, "不能删除当前登录账号")
|
||
if user.role == "admin" and user.enabled and _active_admin_count(s, user.organization_id) <= 1:
|
||
raise HTTPException(409, "至少保留一个启用的管理员")
|
||
# 授权关系属于账号本身,永久删除时一并清理,避免资源授权页出现“用户#ID”。
|
||
for model in (KnowledgeBaseGrant, CanvasGrant, MemoryGrant, DigitalHumanGrant, AgentSkillGrant):
|
||
for grant in s.exec(select(model).where(model.user_id == user.id)).all():
|
||
s.delete(grant)
|
||
# 会话、点数余额和配额是可重建的用户级状态,不应在删除后继续占用账号 ID。
|
||
for sess in s.exec(select(AuthSession).where(
|
||
AuthSession.user_id == user.id)).all():
|
||
s.delete(sess)
|
||
for model in (PointAccount, UsageQuota):
|
||
for row in s.exec(select(model).where(model.user_id == user.id)).all():
|
||
s.delete(row)
|
||
# 即使删除的是当前最大 ID,也让后续新账号跳过它,防止历史记录误归属。
|
||
marker = s.get(AppSetting, "auth.next_user_id")
|
||
try:
|
||
marker_value = int(marker.value) if marker and marker.value else 0
|
||
except (TypeError, ValueError):
|
||
marker_value = 0
|
||
if marker is None:
|
||
marker = AppSetting(key="auth.next_user_id", value=str(int(user.id or 0) + 1))
|
||
s.add(marker)
|
||
elif marker_value <= int(user.id or 0):
|
||
marker.value = str(int(user.id or 0) + 1)
|
||
s.add(marker)
|
||
s.delete(user)
|
||
s.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/auth/users/{user_id}/reset-password")
|
||
def reset_user_password(user_id: int, body: dict, request: Request, s: Session = Depends(get_session)):
|
||
user = s.get(AppUser, user_id)
|
||
if not user or user.organization_id != request.state.user.organization_id:
|
||
raise HTTPException(404, "用户不存在")
|
||
if user.username.startswith("deleted_"):
|
||
raise HTTPException(409, "已删除的用户不能重设密码")
|
||
try:
|
||
password = validate_password(body.get("password"))
|
||
except ValueError as exc:
|
||
raise HTTPException(422, str(exc)) from exc
|
||
user.password_hash = hash_password(password)
|
||
user.updated_at = now()
|
||
s.add(user)
|
||
for item in s.exec(select(AuthSession).where(AuthSession.user_id == user.id, AuthSession.revoked_at == "")).all():
|
||
item.revoked_at = now()
|
||
s.add(item)
|
||
s.commit()
|
||
return {"ok": True}
|
||
|
||
DEFAULT_NEW_AGENT_RULES = """# 新 Agent 规则
|
||
|
||
## 默认文本样式(系统会强制执行)
|
||
- 用大白话写,像当面给人讲,不写公文、教程或分析腔。
|
||
- 正文一段到底,不空行、不分小标题、不列清单;表演提示只用括号内联。
|
||
- 只写任务和知识库已经提供的信息,缺少资料时不补写、不猜测。
|
||
"""
|
||
|
||
|
||
def _schedule_embedding_prewarm() -> None:
|
||
"""Warm the local embedding runtime without delaying service startup.
|
||
|
||
Loading SentenceTransformer can take several seconds (and may download a
|
||
missing local model). Run it in a daemon thread so health checks become
|
||
available immediately; the first request remains a safe fallback if the
|
||
warmup fails.
|
||
"""
|
||
def _worker() -> None:
|
||
from .rag import backend as _rag_backend
|
||
if not _rag_backend.is_disabled():
|
||
rag_embed.prewarm()
|
||
|
||
threading.Thread(target=_worker, name="rag-embedding-prewarm", daemon=True).start()
|
||
|
||
|
||
def _schedule_rag_worker_prewarm() -> None:
|
||
"""Warm one resident RAG subprocess without charging a retrieval."""
|
||
def _worker() -> None:
|
||
try:
|
||
from .rag import backend as _rag_backend
|
||
if _rag_backend.is_disabled():
|
||
return
|
||
from .rag.worker_pool import get_pool
|
||
result = get_pool().prewarm()
|
||
logger.info("RAG 子进程预热完成:%s", result)
|
||
except Exception:
|
||
logger.exception("RAG 子进程预热失败(不影响服务可用性)")
|
||
threading.Thread(target=_worker, name="rag-worker-prewarm", daemon=True).start()
|
||
|
||
|
||
def _schedule_knowledge_maintenance() -> None:
|
||
"""Run file reconciliation and FTS warmup after startup in the background.
|
||
|
||
These operations scan every indexed file and used to delay readiness (and
|
||
compete with API traffic) on large installations. They are idempotent and
|
||
already guarded by per-library locks, so a daemon maintenance task is safe.
|
||
"""
|
||
def _worker() -> None:
|
||
try:
|
||
with Session(_engine()) as session:
|
||
rag_pipeline.reconcile_kb_files(session)
|
||
queued = rag_pipeline.prewarm_ready_indexes(session)
|
||
if queued:
|
||
logger.info("已排队后台预热 %s 个知识库全文索引", queued)
|
||
except Exception:
|
||
logger.exception("知识库启动维护失败(不影响服务可用性)")
|
||
threading.Thread(target=_worker, name="knowledge-maintenance", daemon=True).start()
|
||
|
||
@app.on_event("startup")
|
||
def _startup():
|
||
init_db()
|
||
with Session(_engine()) as log_db:
|
||
system_log.emit(log_db, level="INFO", source="startup", message="service started", status="ok")
|
||
seed_mod.seed()
|
||
from .env_config import IS_DEV
|
||
if IS_DEV:
|
||
logger.info("UI sandbox: recovery, scheduled AI runs and background maintenance disabled")
|
||
return
|
||
# Start this before the potentially expensive library reconciliation. The
|
||
# model warms concurrently while the service continues becoming ready.
|
||
_schedule_embedding_prewarm()
|
||
_schedule_rag_worker_prewarm()
|
||
# 文件对账和全文索引预热会扫描大库,放到后台避免阻塞健康检查和首屏。
|
||
_schedule_knowledge_maintenance()
|
||
# 把进程中断留下的 running 状态恢复为可继续的 queued;已写好的正文会被执行器跳过。
|
||
with Session(_engine()) as session:
|
||
if not execution_config.is_dsh_backend():
|
||
for runtime_session in session.exec(select(AgentSession).where(
|
||
AgentSession.status == "running")).all():
|
||
runtime_session.status = "interrupted"
|
||
runtime_session.updated_at = now()
|
||
session.add(runtime_session)
|
||
recovered = session.exec(select(CampaignRun).where(
|
||
CampaignRun.status.in_(["queued", "running"]))).all()
|
||
recover_ids = []
|
||
dsh_backend = execution_config.is_dsh_backend()
|
||
for run in recovered:
|
||
if dsh_backend:
|
||
# DSH 是执行器:API 重启不能把 DSH 正在执行的 run/segment 改回待执行。
|
||
# 仅修复极端情况下“有 run、无队列行”的不一致;正常租约由 DSH 心跳维持。
|
||
has_queue_item = session.exec(select(TaskQueueItem).where(
|
||
TaskQueueItem.task_key == f"campaign-run:{run.id}",
|
||
TaskQueueItem.status.in_(["pending", "running"]))).first()
|
||
if not has_queue_item:
|
||
mode = "step" if (run.checkpoint or {}).get("mode") == "step" else "full"
|
||
payload = {"run_id": run.id, "mode": mode}
|
||
if mode == "step":
|
||
payload["segment_id"] = ((run.checkpoint or {}).get("segment_ids") or [0])[0]
|
||
payload["campaign_id"] = run.campaign_id
|
||
_enqueue_campaign_job(f"campaign-run:{run.id}", payload)
|
||
continue
|
||
if run.status == "running":
|
||
run.status = "queued"
|
||
run.checkpoint = {**(run.checkpoint or {}), "recovered": True}
|
||
campaign = session.get(Campaign, run.campaign_id)
|
||
if campaign and campaign.status == "running":
|
||
campaign.status = "human_review"
|
||
session.add(campaign)
|
||
for seg in session.exec(select(Segment).where(
|
||
Segment.campaign_id == run.campaign_id, Segment.status == "running")).all():
|
||
seg.status = "pending"
|
||
session.add(seg)
|
||
active_item = session.exec(select(TaskQueueItem).where(
|
||
TaskQueueItem.task_key == f"campaign-run:{run.id}",
|
||
TaskQueueItem.status.in_(["pending", "running"]))).first()
|
||
if active_item:
|
||
active_item.status = "pending"
|
||
active_item.worker_id = ""
|
||
active_item.lease_until = ""
|
||
active_item.next_run_at = now()
|
||
active_item.updated_at = now()
|
||
session.add(active_item)
|
||
session.add(run)
|
||
recover_ids.append(run.id)
|
||
session.commit()
|
||
for run_id in recover_ids:
|
||
_schedule_run(run_id)
|
||
if execution_config.is_dsh_backend():
|
||
# API 重启不执行内容流水线,只修复“状态已置位但队列行丢失”的派单不一致。
|
||
with Session(_engine()) as reconcile_db:
|
||
for status, prefix in (("parsing", "campaign-parse:"),
|
||
("composing", "campaign-compose:")):
|
||
for campaign in reconcile_db.exec(select(Campaign).where(Campaign.status == status)).all():
|
||
active = reconcile_db.exec(select(TaskQueueItem).where(
|
||
TaskQueueItem.task_key == f"{prefix}{campaign.id}",
|
||
TaskQueueItem.status.in_(["pending", "running"]))).first()
|
||
if not active:
|
||
# API 重启补单只恢复任务,不把导图原文误当作用户修改意见。
|
||
_enqueue_campaign_job(
|
||
f"{prefix}{campaign.id}",
|
||
{"campaign_id": campaign.id, "advice": ""},
|
||
update_existing=False,
|
||
)
|
||
# P2:启动即做一轮告警巡检,之后每 5 分钟一次(守护线程,异常不外抛)。
|
||
def _alert_loop():
|
||
import time as _time
|
||
while True:
|
||
try:
|
||
with Session(_engine()) as session:
|
||
run_alert_sweep(session)
|
||
except Exception as exc:
|
||
logger.error("告警巡检失败:%s", exc)
|
||
_time.sleep(300)
|
||
threading.Thread(target=_alert_loop, name="alert-sweep", daemon=True).start()
|
||
|
||
# P0-5:启动时备份一次,之后每 6 小时周期备份(守护线程)。
|
||
from . import backup as backup_mod
|
||
def _backup_loop():
|
||
import time as _time
|
||
while True:
|
||
try:
|
||
path = backup_mod.create_backup("auto")
|
||
if path:
|
||
logger.info("自动备份完成:%s", path.name)
|
||
backup_mod.verify_restore_latest()
|
||
except Exception as exc:
|
||
logger.error("自动备份失败:%s", exc)
|
||
_time.sleep(6 * 3600)
|
||
threading.Thread(target=_backup_loop, name="backup-cycle", daemon=True).start()
|
||
|
||
|
||
@app.on_event("shutdown")
|
||
def _shutdown():
|
||
# 普通日志允许批量异步写入,但进程优雅退出时必须先刷盘。
|
||
try:
|
||
system_log.shutdown(timeout=5.0)
|
||
except Exception:
|
||
logger.exception("普通系统日志刷盘失败")
|
||
|
||
def _record_run_event(session: Session, run: CampaignRun, event_type: str,
|
||
status: str = "", segment_id: int = 0, node_id: str = "", payload: dict | None = None):
|
||
session.add(RunEvent(run_id=run.id, campaign_id=run.campaign_id, segment_id=segment_id,
|
||
node_id=node_id, trace_id=run.trace_id, event_type=event_type,
|
||
status=status, payload=payload or {}))
|
||
|
||
def _run_campaign_background(run_id: int):
|
||
"""后台线程只持有自己的 Session;状态和错误均先落库,前端轮询不会丢进度。"""
|
||
with _RUN_LOCK:
|
||
if run_id in _ACTIVE_RUNS:
|
||
return
|
||
_ACTIVE_RUNS.add(run_id)
|
||
try:
|
||
with Session(_engine()) as session:
|
||
claimed = task_queue.claim_by_key(
|
||
session, TaskQueueItem, f"campaign-run:{run_id}", _INLINE_WORKER_ID
|
||
)
|
||
run = session.get(CampaignRun, run_id)
|
||
if not run or run.status == "cancelled":
|
||
return
|
||
if claimed is None:
|
||
logger.info("内容运行 %s 已由其他执行器认领,本进程跳过", run_id)
|
||
return
|
||
run.status = "running"
|
||
run.trace_id = run.trace_id or new_trace_id()
|
||
run.started_at = run.started_at or now()
|
||
run.checkpoint = {**(run.checkpoint or {}), "phase": "running"}
|
||
session.add(run)
|
||
_record_run_event(session, run, "run_started", "running")
|
||
session.commit()
|
||
try:
|
||
result = runner.run_campaign(session, run.campaign_id, run_id=run.id)
|
||
session.refresh(run)
|
||
campaign = session.get(Campaign, run.campaign_id)
|
||
if run.cancel_requested:
|
||
run.status = "cancelled"
|
||
elif result.get("status") in ("failed", "error") or (campaign and campaign.has_failure):
|
||
run.status = "failed"
|
||
else:
|
||
run.status = "awaiting_review"
|
||
run.checkpoint = {"phase": "finished", "campaign_status": result.get("status"),
|
||
"results": result.get("results", [])[-100:]}
|
||
run.finished_at = now()
|
||
run.error = str(result.get("error") or "")
|
||
session.add(run)
|
||
_record_run_event(session, run, "run_finished", run.status,
|
||
payload={"campaign_status": result.get("status")})
|
||
session.commit()
|
||
except Exception as exc:
|
||
run = session.get(CampaignRun, run_id)
|
||
if run:
|
||
run.status = "failed"
|
||
run.error = str(exc)[:500]
|
||
run.finished_at = now()
|
||
run.checkpoint = {**(run.checkpoint or {}), "phase": "failed"}
|
||
session.add(run)
|
||
_record_run_event(session, run, "run_failed", "failed", payload={"error": run.error})
|
||
campaign = session.get(Campaign, run.campaign_id) if run else None
|
||
if campaign:
|
||
campaign.status = "failed"
|
||
campaign.has_failure = True
|
||
session.add(campaign)
|
||
session.add(Notification(type="failure", ref_id=run.campaign_id if run else 0,
|
||
message=f"任务执行异常:{str(exc)[:180]}"))
|
||
session.commit()
|
||
finally:
|
||
with _RUN_LOCK:
|
||
_ACTIVE_RUNS.discard(run_id)
|
||
# Hermes 融合:运行结束释放轮次预算池(暂停恢复时会重新创建)。
|
||
from .agents.runtime import release_run_budget
|
||
release_run_budget(run_id)
|
||
# P0-3:同步持久队列状态。
|
||
try:
|
||
with Session(_engine()) as qs:
|
||
item = qs.exec(select(TaskQueueItem).where(
|
||
TaskQueueItem.task_key == f"campaign-run:{run_id}",
|
||
TaskQueueItem.status.in_(["pending", "running"]))).first()
|
||
if item and item.worker_id == _INLINE_WORKER_ID:
|
||
task_queue.complete(qs, TaskQueueItem, item.id, _INLINE_WORKER_ID)
|
||
except Exception as exc:
|
||
logger.error("任务 %s 队列状态同步失败:%s", run_id, exc)
|
||
|
||
def _enqueue_campaign_job(task_key: str, payload: dict, update_existing: bool = False) -> int:
|
||
with Session(_engine()) as qs:
|
||
return task_queue.enqueue(qs, TaskQueueItem, task_key=task_key, payload=payload,
|
||
update_existing=update_existing)
|
||
|
||
|
||
def _schedule_run(run_id: int):
|
||
# 队列表是事实源。inline 模式由 API 后台线程执行;dsh 模式仅入队,
|
||
# 由独立 DSH Worker 按 campaign-* 前缀原子认领,API 不再启动执行线程。
|
||
try:
|
||
_enqueue_campaign_job(f"campaign-run:{run_id}", {"run_id": run_id, "mode": "full"})
|
||
except Exception as exc:
|
||
logger.error("任务 %s 入持久队列失败:%s", run_id, exc)
|
||
if execution_config.is_dsh_backend():
|
||
raise HTTPException(503, "DSH 任务队列暂不可用,请稍后重试")
|
||
# 旧 inline 行为优先保证本地可用性:队列写失败不阻断线程执行。
|
||
if execution_config.is_dsh_backend():
|
||
logger.info("内容运行 %s 已交给 DSH 队列", run_id)
|
||
return
|
||
with _RUN_LOCK:
|
||
if run_id in _ACTIVE_RUNS:
|
||
return
|
||
threading.Thread(target=_run_campaign_background, args=(run_id,),
|
||
name=f"campaign-run-{run_id}", daemon=True).start()
|
||
|
||
def _S(db: Session = Depends(get_session)):
|
||
return db
|
||
|
||
def _required_text(value, label: str) -> str:
|
||
text = str(value or "").strip()
|
||
if not text:
|
||
raise HTTPException(422, f"{label}不能为空")
|
||
return text
|
||
|
||
def _body_int(body: dict, key: str, label: str, default: int = 0) -> int:
|
||
raw = body.get(key)
|
||
if raw is None or raw == "":
|
||
return default
|
||
try:
|
||
return int(raw)
|
||
except (TypeError, ValueError) as exc:
|
||
raise HTTPException(422, f"{label}必须是整数") from exc
|
||
|
||
def _body_float(body: dict, key: str, label: str, default: float = 0.0) -> float:
|
||
raw = body.get(key)
|
||
if raw is None or raw == "":
|
||
return default
|
||
try:
|
||
value = float(raw)
|
||
except (TypeError, ValueError) as exc:
|
||
raise HTTPException(422, f"{label}必须是数字") from exc
|
||
if not math.isfinite(value):
|
||
raise HTTPException(422, f"{label}必须是有限数字")
|
||
return value
|
||
|
||
def _body_text(body: dict, key: str, label: str, max_len: int, default: str = "") -> str:
|
||
"""读取有限长度文本,避免任务契约把异常大内容写入每一条运行提示。"""
|
||
raw = body.get(key, default)
|
||
if raw is None:
|
||
return default
|
||
value = str(raw).strip()
|
||
if len(value) > max_len:
|
||
raise HTTPException(422, f"{label}不能超过{max_len}个字符")
|
||
return value
|
||
|
||
def _default_prompt_template_id(s: Session, request: Request | None = None) -> int:
|
||
user = request_user(request)
|
||
template = s.exec(select(AgentPromptTemplate).where(
|
||
AgentPromptTemplate.enabled == True
|
||
).order_by(AgentPromptTemplate.builtin.desc(), AgentPromptTemplate.id)).first()
|
||
if user:
|
||
template = next((item for item in s.exec(select(AgentPromptTemplate).where(
|
||
AgentPromptTemplate.enabled == True
|
||
).order_by(AgentPromptTemplate.builtin.desc(), AgentPromptTemplate.id)).all()
|
||
if can_access(item, user, "read")), None)
|
||
if not template or not template.id:
|
||
raise HTTPException(503, "系统默认提示模板未就绪,请先完成种子初始化")
|
||
return template.id
|
||
|
||
def _validate_provider_url(value) -> str:
|
||
url = _required_text(value, "Base URL").rstrip("/")
|
||
parsed = urlparse(url)
|
||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||
raise HTTPException(422, "Base URL 必须是有效的 http/https 地址")
|
||
return url
|
||
|
||
def _validate_agent_refs(s: Session, content_tags: list, kb_ids: list,
|
||
provider_id: int | None, skill_ids: list = [],
|
||
request: Request | None = None):
|
||
user = request_user(request)
|
||
known_tags = {t.name for t in s.exec(select(ContentTag)).all()
|
||
if can_access(t, user, "read")}
|
||
missing_tags = [name for name in content_tags if name not in known_tags]
|
||
if missing_tags:
|
||
raise HTTPException(422, f"标签不存在:{', '.join(missing_tags)}")
|
||
missing_kbs = [kid for kid in kb_ids if not s.get(KnowledgeBase, kid)]
|
||
if missing_kbs:
|
||
raise HTTPException(422, f"知识库不存在:{missing_kbs}")
|
||
if provider_id is not None and not s.get(ModelProvider, provider_id):
|
||
raise HTTPException(422, "模型 Provider 不存在")
|
||
missing_skills = [skill_id for skill_id in skill_ids
|
||
if not (s.get(AgentSkill, skill_id) and can_access(
|
||
s.get(AgentSkill, skill_id), user, "use",
|
||
(skill_grant(s, skill_id, user).permission if s.get(AgentSkill, skill_id).visibility == "shared" and skill_grant(s, skill_id, user) else None)))]
|
||
if missing_skills:
|
||
raise HTTPException(422, f"技能不存在:{missing_skills}")
|
||
|
||
def _validate_agent_model(s: Session, provider_id: int | None, model_name: str) -> None:
|
||
"""Agent 只能使用已存在于 Provider 清单中的模型名称。"""
|
||
if not model_name:
|
||
return
|
||
if provider_id is not None:
|
||
provider = s.get(ModelProvider, provider_id)
|
||
if not provider or model_name not in (provider.models or []):
|
||
raise HTTPException(422, "模型必须来自已配置 Provider 的模型清单")
|
||
return
|
||
providers = s.exec(select(ModelProvider).where(ModelProvider.enabled == True)).all() # noqa: E712
|
||
if not any(model_name in (provider.models or []) for provider in providers):
|
||
raise HTTPException(422, "未找到可用的模型目录项")
|
||
|
||
def _validate_user_model_selection(s: Session, provider_id: int | None, model_name: str,
|
||
catalog_id: int | None, request: Request | None) -> None:
|
||
user = request_user(request)
|
||
if not user or user.role == "admin" or not provider_id or not model_name:
|
||
return
|
||
if not catalog_id:
|
||
raise HTTPException(403, "用户只能选择管理端已发布的模型目录")
|
||
try:
|
||
catalog, provider = resolve_catalog_model(s, int(catalog_id))
|
||
except (ValueError, TypeError) as exc:
|
||
raise HTTPException(403, str(exc)) from exc
|
||
if catalog.provider_id != provider_id or catalog.model_name != model_name:
|
||
raise HTTPException(422, "模型目录与 Provider 不匹配")
|
||
|
||
def _catalog_id_for_model(s: Session, provider_id: int | None, model_name: str) -> int | None:
|
||
if not provider_id or not model_name:
|
||
return None
|
||
row = s.exec(select(ModelCatalog).where(
|
||
ModelCatalog.provider_id == provider_id, ModelCatalog.model_name == model_name
|
||
)).first()
|
||
return row.id if row else None
|
||
|
||
def _fusion_agent_ids(s: Session, raw_ids, role: str, owner_id: int | None = None,
|
||
user: AppUser | None = None) -> list[int]:
|
||
"""融合只组合生产 Agent 的表达能力,主 Agent 仍是唯一知识来源责任人。"""
|
||
if raw_ids is None:
|
||
return []
|
||
if not isinstance(raw_ids, list):
|
||
raise HTTPException(422, "融合 Agent 必须是列表")
|
||
try:
|
||
ids = list(dict.fromkeys(int(item) for item in raw_ids))
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(422, "融合 Agent ID 格式错误")
|
||
if ids and role != "production":
|
||
raise HTTPException(422, "只有生产 Agent 可以配置融合 Agent")
|
||
if owner_id is not None and owner_id in ids:
|
||
raise HTTPException(422, "Agent 不能融合自己")
|
||
for fusion_id in ids:
|
||
target = s.get(Agent, fusion_id)
|
||
if not target:
|
||
raise HTTPException(422, f"融合 Agent 不存在:{fusion_id}")
|
||
if not can_access(target, user, "use"):
|
||
raise HTTPException(404, "融合 Agent 不存在")
|
||
if target.role != "production":
|
||
raise HTTPException(422, f"只能融合生产 Agent:{target.name}")
|
||
if target.status != "active":
|
||
raise HTTPException(422, f"融合 Agent 未启用:{target.name}")
|
||
return ids
|
||
|
||
def _sync_agent_skills(s: Session, agent_id: int, skill_ids: list[int]):
|
||
current = s.exec(select(AgentSkillBinding).where(AgentSkillBinding.agent_id == agent_id)).all()
|
||
for binding in current:
|
||
s.delete(binding)
|
||
for skill_id in list(dict.fromkeys(skill_ids)):
|
||
s.add(AgentSkillBinding(agent_id=agent_id, skill_id=skill_id))
|
||
|
||
def _sync_skill_agents(s: Session, skill_id: int, agent_ids: list[int], request: Request | None = None):
|
||
"""Replace the Agent bindings for a skill after checking each target is editable.
|
||
|
||
This mirrors the Agent editor's multi-select skill chips while allowing a
|
||
skill owner to manage bindings from the skill library itself.
|
||
"""
|
||
try:
|
||
ids = list(dict.fromkeys(int(item) for item in (agent_ids or [])))
|
||
except (TypeError, ValueError) as exc:
|
||
raise HTTPException(422, "Agent ID 必须是数字列表") from exc
|
||
user = request_user(request)
|
||
for aid in ids:
|
||
agent = s.get(Agent, aid)
|
||
if not agent or not can_access_agent(s, agent, user, "manage"):
|
||
raise HTTPException(404, f"Agent 不存在或无权挂载:{aid}")
|
||
current = s.exec(select(AgentSkillBinding).where(AgentSkillBinding.skill_id == skill_id)).all()
|
||
for binding in current:
|
||
s.delete(binding)
|
||
for aid in ids:
|
||
s.add(AgentSkillBinding(agent_id=aid, skill_id=skill_id))
|
||
|
||
def _agent_runtime_snapshot(s: Session, agent: Agent, visited: set[int] | None = None,
|
||
user: AppUser | None = None, allow_agent_scope: bool = False,
|
||
allowed_agent_ids: set[int] | None = None) -> dict:
|
||
"""冻结运行所需配置,避免任务创建后被全局编辑影响历史结果。"""
|
||
return freeze_agent_snapshot(s, agent, visited, user=user, allow_agent_scope=allow_agent_scope,
|
||
allowed_agent_ids=allowed_agent_ids)
|
||
|
||
def _discover_provider_models(base_url: str, api_key: str) -> list[str]:
|
||
"""读取 OpenAI 兼容 /models,创建 Provider 时以服务端实际返回为准。"""
|
||
try:
|
||
if not api_key:
|
||
raise ValueError("没有读取到可用密钥,请重新填写并保存;若已保存,请检查密钥存储是否可用")
|
||
client = OpenAI(base_url=base_url, api_key=api_key, timeout=20, max_retries=0)
|
||
response = client.models.list()
|
||
models = sorted({str(item.id).strip() for item in getattr(response, "data", [])
|
||
if getattr(item, "id", None) and str(item.id).strip()})
|
||
except Exception as exc:
|
||
raise ValueError(_humanize_provider_error(exc, api_key)) from exc
|
||
if not models:
|
||
raise ValueError("Provider 没有返回任何可用模型")
|
||
return models
|
||
|
||
|
||
def _humanize_provider_error(exc: Exception, api_key: str = "") -> str:
|
||
"""把底层 SDK 异常翻译成用户能行动的中文。"""
|
||
text = str(exc)
|
||
if api_key:
|
||
text = text.replace(api_key, '[REDACTED]')
|
||
text = system_log.redact(text, 600)
|
||
if "IP_REGION_RESTRICTED" in text or "region is not available" in text:
|
||
return "该中转服务拒绝了当前网络地区(IP 地域限制)。请联系服务商确认允许的地区或索取备用接入域名"
|
||
if getattr(exc, 'status_code', None) == 401 or "401" in text or "invalid api key" in text.lower():
|
||
return "模型服务认证失败(HTTP 401),请核对密钥对应的服务地址与权限。服务方返回:" + text[:360]
|
||
if "insufficient" in text.lower() or "quota" in text.lower():
|
||
return "服务方账户余额或配额不足"
|
||
return text[:300]
|
||
|
||
|
||
def _provider_key(value) -> str:
|
||
key = str(value or '').strip()
|
||
if key.lower().startswith('bearer '):
|
||
key = key[7:].strip()
|
||
if key.startswith(('***', '••••', 'keychain:')):
|
||
raise HTTPException(422, '请输入真实 API Key,不能使用列表中的密钥掩码或存储引用')
|
||
if any(char.isspace() for char in key):
|
||
raise HTTPException(422, 'API Key 中包含空格或换行,请重新复制完整密钥')
|
||
return key
|
||
|
||
def _kb_category_tree(s: Session, kid: int) -> list[dict]:
|
||
categories = s.exec(select(KnowledgeCategory).where(
|
||
KnowledgeCategory.kb_id == kid
|
||
).order_by(KnowledgeCategory.sort_order, KnowledgeCategory.id)).all()
|
||
files = s.exec(select(KnowledgeFile).where(KnowledgeFile.kb_id == kid)).all()
|
||
file_counts: dict[int | None, int] = {}
|
||
for file in files:
|
||
file_counts[file.category_id] = file_counts.get(file.category_id, 0) + 1
|
||
nodes = {category.id: {"id": category.id, "name": category.name, "desc": category.desc,
|
||
"parent_id": category.parent_id, "direct_file_count": file_counts.get(category.id, 0),
|
||
"file_count": file_counts.get(category.id, 0),
|
||
"children": []} for category in categories}
|
||
roots = []
|
||
for node in nodes.values():
|
||
parent = nodes.get(node["parent_id"])
|
||
if parent:
|
||
parent["children"].append(node)
|
||
else:
|
||
roots.append(node)
|
||
def aggregate(node, path):
|
||
if node['id'] in path:
|
||
return 0
|
||
node['file_count'] = node['direct_file_count'] + sum(
|
||
aggregate(child, path | {node['id']}) for child in node['children'])
|
||
return node['file_count']
|
||
for node in roots:
|
||
aggregate(node, set())
|
||
return roots
|
||
|
||
def _agent_name(s: Session, agent_id: int) -> str:
|
||
agent = s.get(Agent, agent_id)
|
||
return f"{agent.name}(Agent#{agent_id})" if agent else f"未知智能体(Agent#{agent_id})"
|
||
|
||
def _validate_category(s: Session, kid: int, category_id: int | None, parent_id: int | None = None):
|
||
if category_id is not None:
|
||
category = s.get(KnowledgeCategory, category_id)
|
||
if not category or category.kb_id != kid:
|
||
raise HTTPException(422, "分类不属于当前知识库")
|
||
if parent_id is not None:
|
||
parent = s.get(KnowledgeCategory, parent_id)
|
||
if not parent or parent.kb_id != kid:
|
||
raise HTTPException(422, "父分类不属于当前知识库")
|
||
|
||
def _validate_agent_category_mapping(s: Session, kb_ids: list, mapping: dict) -> dict:
|
||
"""Validate per-KB category selection; categories must belong to bound, usable KBs."""
|
||
result = {}
|
||
bound = {int(k) for k in kb_ids}
|
||
for raw_kb, raw_ids in (mapping or {}).items():
|
||
kid = int(raw_kb)
|
||
if kid not in bound:
|
||
raise HTTPException(422, "分类所属知识库未绑定到 Agent")
|
||
kb = s.get(KnowledgeBase, kid)
|
||
if not kb:
|
||
raise HTTPException(422, "知识库不存在或无权使用")
|
||
ids = list(dict.fromkeys(int(x) for x in (raw_ids or [])))
|
||
for cid in ids:
|
||
category = s.get(KnowledgeCategory, cid)
|
||
if not category or category.kb_id != kid:
|
||
raise HTTPException(422, "分类不属于绑定知识库")
|
||
result[str(kid)] = ids
|
||
return result
|
||
|
||
def _normalize_canvas(s: Session, nodes: list, edges: list) -> tuple[list, list]:
|
||
"""Remove the built-in composer Agent from user canvas definitions.
|
||
|
||
The composer is a system stage used after all content segments are
|
||
approved. It is never a user-editable canvas node. Older canvases may
|
||
still contain such a node, so normalize those payloads on read/write
|
||
paths and drop only edges attached to the removed node.
|
||
"""
|
||
# id=0 is reserved for the runtime-only fallback composer. Include it so
|
||
# a stale client payload cannot persist a synthetic composer node either.
|
||
composer_ids = {0}
|
||
composer_ids.update(int(agent.id) for agent in s.exec(
|
||
select(Agent).where(Agent.role == "composer")).all() if agent.id)
|
||
def _node_agent_id(node: object) -> int:
|
||
try:
|
||
return int(node.get("agent_id") or 0) if isinstance(node, dict) else 0
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
|
||
removed_ids = {
|
||
str(node.get("node_id") or "") for node in nodes
|
||
if isinstance(node, dict) and _node_agent_id(node) in composer_ids
|
||
}
|
||
if not removed_ids:
|
||
return nodes, edges
|
||
kept_nodes = [node for node in nodes
|
||
if not (isinstance(node, dict) and _node_agent_id(node) in composer_ids)]
|
||
kept_edges = [edge for edge in edges
|
||
if not isinstance(edge, dict) or
|
||
(edge.get("from") not in removed_ids and edge.get("to") not in removed_ids)]
|
||
return kept_nodes, kept_edges
|
||
|
||
|
||
def _validate_canvas(s: Session, nodes: list, edges: list):
|
||
node_ids = [str(node.get("node_id") or "") for node in nodes]
|
||
if any(not node_id for node_id in node_ids):
|
||
raise HTTPException(422, "画布节点缺少 node_id")
|
||
if len(node_ids) != len(set(node_ids)):
|
||
raise HTTPException(422, "画布节点 ID 不能重复")
|
||
for node in nodes:
|
||
if not s.get(Agent, node.get("agent_id")):
|
||
raise HTTPException(422, f"画布引用了不存在的 Agent:{node.get('agent_id')}")
|
||
known = set(node_ids)
|
||
seen_edges = set()
|
||
dependencies = {node_id: set() for node_id in known}
|
||
for edge in edges:
|
||
source, target = edge.get("from"), edge.get("to")
|
||
if source not in known or target not in known:
|
||
raise HTTPException(422, "连线必须连接画布中的节点")
|
||
if source == target:
|
||
raise HTTPException(422, "节点不能连接自身")
|
||
if (source, target) in seen_edges:
|
||
raise HTTPException(422, "画布存在重复连线")
|
||
seen_edges.add((source, target))
|
||
dependencies[target].add(source)
|
||
completed = set()
|
||
while len(completed) < len(known):
|
||
ready = [node_id for node_id, deps in dependencies.items()
|
||
if node_id not in completed and deps.issubset(completed)]
|
||
if not ready:
|
||
raise HTTPException(422, "画布不能包含循环依赖")
|
||
completed.update(ready)
|
||
|
||
# ---------- 标签 ----------
|
||
@app.get("/api/tags")
|
||
def list_tags(s: Session = Depends(get_session), request: Request = None):
|
||
user = request_user(request)
|
||
return [{"id": t.id, "name": t.name, "category": t.category, "definition": t.definition,
|
||
# `desc` 是旧前端使用的字段名;保留别名,避免列表展示丢失已填写的说明。
|
||
"desc": t.definition,
|
||
"usage": t.usage, "is_core": t.is_core, "enabled": t.enabled,
|
||
"organization_id": t.organization_id, **owner_fields(s, t.owner_user_id),
|
||
"visibility": t.visibility}
|
||
for t in s.exec(select(ContentTag).order_by(ContentTag.id)).all()
|
||
if can_access(t, user, "read")]
|
||
|
||
def _visible_rows(s: Session, model, request: Request | None):
|
||
user = request_user(request)
|
||
return [item for item in s.exec(select(model).order_by(model.id)).all()
|
||
if can_access(item, user, "read")]
|
||
|
||
@app.post("/api/tags")
|
||
def create_tag(tag: dict, s: Session = Depends(get_session), request: Request = None):
|
||
name = _required_text(tag.get("name"), "标签名")
|
||
if s.exec(select(ContentTag).where(ContentTag.name == name)).first():
|
||
raise HTTPException(409, "标签名已存在")
|
||
user = request_user(request)
|
||
is_admin = user is None or user.role == "admin"
|
||
organization_id, owner_user_id = actor_fields(request)
|
||
t = ContentTag(name=name, category=str(tag.get("category") or "内容").strip(),
|
||
definition=str(tag.get("definition") or "").strip(),
|
||
usage=str(tag.get("usage") or "").strip(),
|
||
is_core=bool(tag.get("is_core", False)) if is_admin else False,
|
||
organization_id=organization_id, owner_user_id=owner_user_id,
|
||
visibility=visibility_for_create(request, tag.get("visibility", "global" if is_admin else "private")))
|
||
s.add(t); s.commit(); s.refresh(t)
|
||
return {"id": t.id}
|
||
|
||
@app.patch("/api/tags/{tid}")
|
||
def update_tag(tid: int, tag: dict, s: Session = Depends(get_session), request: Request = None):
|
||
t = s.get(ContentTag, tid)
|
||
if not t or not can_access(t, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "标签不存在")
|
||
user = request_user(request)
|
||
old_name = t.name
|
||
if "name" in tag:
|
||
new_name = _required_text(tag.get("name"), "标签名")
|
||
duplicate = s.exec(select(ContentTag).where(ContentTag.name == new_name)).first()
|
||
if duplicate and duplicate.id != tid:
|
||
raise HTTPException(409, "标签名已存在")
|
||
tag["name"] = new_name
|
||
for k in ("name", "category", "definition", "usage", "enabled"):
|
||
if k in tag:
|
||
setattr(t, k, tag[k])
|
||
if t.name != old_name:
|
||
for agent in s.exec(select(Agent)).all():
|
||
if user and user.role != "admin" and agent.owner_user_id != user.id:
|
||
continue
|
||
if old_name in (agent.content_tags or []):
|
||
agent.content_tags = [t.name if name == old_name else name
|
||
for name in agent.content_tags]
|
||
s.add(agent)
|
||
s.add(t); s.commit()
|
||
return {"ok": True}
|
||
|
||
@app.delete("/api/tags/{tid}")
|
||
def delete_tag(tid: int, s: Session = Depends(get_session), request: Request = None):
|
||
t = s.get(ContentTag, tid)
|
||
if not t or not can_access(t, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "标签不存在")
|
||
for agent in s.exec(select(Agent)).all():
|
||
if t.name in (agent.content_tags or []):
|
||
agent.content_tags = [name for name in agent.content_tags if name != t.name]
|
||
s.add(agent)
|
||
s.delete(t); s.commit()
|
||
return {"ok": True}
|
||
|
||
# ---------- Agent ----------
|
||
def _allowed_tools(raw: object, *, default: bool = True) -> list[str]:
|
||
"""校验 Agent 工具白名单;未配置的历史 Agent 只启用系统内置工具。"""
|
||
if raw is None or (isinstance(raw, list) and not raw and default):
|
||
return list(SYSTEM_RUNTIME_TOOL_NAMES)
|
||
if not isinstance(raw, list):
|
||
raise HTTPException(422, "工具权限必须是列表")
|
||
names = list(dict.fromkeys(str(item).strip() for item in raw if str(item).strip()))
|
||
unknown = [name for name in names if name not in RUNTIME_TOOL_NAMES]
|
||
if unknown:
|
||
raise HTTPException(422, f"存在未知工具:{', '.join(unknown)}")
|
||
return names
|
||
|
||
|
||
@app.get("/api/agent-tools")
|
||
def list_agent_tools():
|
||
return RUNTIME_TOOL_CATALOG
|
||
|
||
|
||
@app.get("/api/agents")
|
||
def list_agents(s: Session = Depends(get_session), request: Request = None):
|
||
out = []
|
||
for a in s.exec(select(Agent).order_by(Agent.id)).all():
|
||
if _hidden_for_user(s, "agent", int(a.id or 0), request) or not can_access_agent(s, a, request_user(request), "read"):
|
||
continue
|
||
d = {c: getattr(a, c) for c in ("id", "name", "role", "desc", "content_tags", "kb_ids", "fusion_agent_ids",
|
||
"model_provider_id", "model_name", "status", "builtin",
|
||
"rules_version", "prompt_template_id", "model_catalog_id",
|
||
"organization_id", "owner_user_id", "visibility", "kb_category_ids")}
|
||
d.update(owner_fields(s, a.owner_user_id))
|
||
d["allowed_tools"] = _allowed_tools(a.allowed_tools)
|
||
d["persona"] = a.persona_json or {}
|
||
d["digital_human_id"] = a.digital_human_id
|
||
_dh = s.get(DigitalHuman, a.digital_human_id) if a.digital_human_id else None
|
||
d["digital_human_name"] = _dh.name if _dh else ""
|
||
d["digital_human_binding"] = ({"id": _dh.id, "name": _dh.name, "version": _dh.version,
|
||
"status": _dh.status,
|
||
"transport": "runtime_snapshot.digital_human.persona"} if _dh else None)
|
||
d["skill_ids"] = agent_skill_ids(s, a.id)
|
||
d["fusion_agent_names"] = [target.name for target in (
|
||
s.get(Agent, fusion_id) for fusion_id in (a.fusion_agent_ids or [])
|
||
) if target]
|
||
p = s.get(ModelProvider, a.model_provider_id) if a.model_provider_id else None
|
||
d["model_label"] = f"{p.name}/{a.model_name}" if p else "跟随全局默认"
|
||
template = s.get(AgentPromptTemplate, a.prompt_template_id) if a.prompt_template_id else None
|
||
d["prompt_template_name"] = template.name if template else "默认模板"
|
||
d["prompt_template_version"] = template.version if template else 1
|
||
out.append(d)
|
||
return out
|
||
|
||
# ---------- 全局功能开关(借鉴 Coze Feature Flags) ----------
|
||
FEATURE_FLAGS_DEFAULT = {
|
||
"cross_qc": True, # 跨稿质检门
|
||
"ai_assist": True, # AI 辅助创建/导图
|
||
"lazy_qc_auto_redo": True, # lazy 质检发现问题自动重做一轮
|
||
"kb_heading_split": True, # 知识库按标题切分支持
|
||
}
|
||
|
||
|
||
def _load_flags(s: Session) -> dict:
|
||
from .models import AppSetting
|
||
import json as _json
|
||
row = s.exec(select(AppSetting).where(AppSetting.key == "feature_flags")).first()
|
||
if not row:
|
||
return dict(FEATURE_FLAGS_DEFAULT)
|
||
try:
|
||
stored = _json.loads(row.value or "{}")
|
||
except Exception:
|
||
stored = {}
|
||
merged = dict(FEATURE_FLAGS_DEFAULT)
|
||
merged.update({k: bool(v) for k, v in stored.items() if k in FEATURE_FLAGS_DEFAULT})
|
||
return merged
|
||
|
||
|
||
@app.get("/api/settings/feature-flags")
|
||
def get_feature_flags(s: Session = Depends(get_session), request: Request = None):
|
||
user = request_user(request)
|
||
if not user or user.role != "admin":
|
||
if not user or not has_permission(user, "config:write", s):
|
||
raise HTTPException(403, "仅管理员可查看功能开关")
|
||
return _load_flags(s)
|
||
|
||
|
||
@app.put("/api/settings/feature-flags")
|
||
def put_feature_flags(body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
import json as _json
|
||
from .models import AppSetting
|
||
user = request_user(request)
|
||
if not user or user.role != "admin":
|
||
raise HTTPException(403, "仅管理员可修改功能开关")
|
||
current = _load_flags(s)
|
||
for k, v in (body or {}).items():
|
||
if k in current:
|
||
current[k] = bool(v)
|
||
row = s.exec(select(AppSetting).where(AppSetting.key == "feature_flags")).first()
|
||
if row:
|
||
row.value = _json.dumps(current, ensure_ascii=False)
|
||
else:
|
||
s.add(AppSetting(key="feature_flags", value=_json.dumps(current, ensure_ascii=False)))
|
||
s.commit()
|
||
return {"ok": True, "flags": current}
|
||
|
||
|
||
# ---------- 数字人(蒸馏人格,绑定 Agent) ----------
|
||
from .digital_persona import (
|
||
PERSONA_LABELS as _DIGITAL_HUMAN_LABELS, DISTILL_SYSTEM as _DH_SYSTEM,
|
||
normalize_persona, persona_prompt, human_access, require_human, binding_id, runtime_persona,
|
||
)
|
||
|
||
|
||
def _representative_sample(text: str, limit: int = 8000) -> str:
|
||
"""均匀取样,避免大文件蒸馏只看到开头。保留头/中/尾并按段落截断。"""
|
||
text = str(text or '').strip()
|
||
if len(text) <= limit:
|
||
return text
|
||
third = max(1, limit // 3)
|
||
start = text[:third]
|
||
mid_at = max(0, len(text) // 2 - third // 2)
|
||
middle = text[mid_at:mid_at + third]
|
||
end = text[-(limit - len(start) - len(middle)):]
|
||
return '\n\n[...中段采样... ]\n\n'.join([start, middle, end])[:limit]
|
||
|
||
|
||
def _unique_source_names(source_names: list[str] | None) -> list[str]:
|
||
"""保留来源顺序并去掉同名重复项,避免持续投喂后来源数量虚增。"""
|
||
seen: set[str] = set()
|
||
result: list[str] = []
|
||
for value in source_names or []:
|
||
name = str(value or '').strip()
|
||
if not name or name.casefold() in seen:
|
||
continue
|
||
seen.add(name.casefold())
|
||
result.append(name)
|
||
return result
|
||
|
||
|
||
def _distill_meta(samples: list[str], source_names: list[str], advice: str = '',
|
||
previous_meta: dict | None = None,
|
||
batch_sources: list[str] | None = None) -> dict:
|
||
"""可追溯蒸馏元数据;原始素材不落库,只保存覆盖与审核状态。"""
|
||
previous_meta = previous_meta if isinstance(previous_meta, dict) else {}
|
||
all_sources = _unique_source_names(source_names)
|
||
batch_sources = _unique_source_names(batch_sources if batch_sources is not None else source_names)
|
||
current_chars = sum(len(str(x or '')) for x in samples)
|
||
history = list(previous_meta.get('source_history') or [])
|
||
if not history and int(previous_meta.get('sampled_chars') or 0) > 0:
|
||
history.append({'batch': 1, 'source_names': _unique_source_names(previous_meta.get('source_names') or []),
|
||
'source_count': len(_unique_source_names(previous_meta.get('source_names') or [])),
|
||
'sampled_chars': int(previous_meta.get('sampled_chars') or 0), 'advice': ''})
|
||
history.append({'batch': len(history) + 1, 'source_names': batch_sources[:100],
|
||
'source_count': len(batch_sources), 'sampled_chars': current_chars,
|
||
'advice': str(advice or '')[:500]})
|
||
cumulative_chars = sum(int(item.get('sampled_chars') or 0) for item in history if isinstance(item, dict))
|
||
previous_material = str(previous_meta.get('source_material') or '').strip()
|
||
batch_material = '\n\n---\n\n'.join(str(item or '').strip() for item in samples if str(item or '').strip())
|
||
# Keep a bounded representative corpus so later supplemental distillation
|
||
# can re-read earlier material without retaining multi-GB uploads.
|
||
cumulative_material = _representative_sample(
|
||
'\n\n---\n\n'.join(x for x in (previous_material, batch_material) if x), 60000
|
||
)
|
||
return {
|
||
'source_count': len(all_sources),
|
||
'source_names': all_sources[:100],
|
||
'sampled_chars': cumulative_chars,
|
||
'last_batch_chars': current_chars,
|
||
'last_batch_count': len(batch_sources),
|
||
'source_history': history[-100:],
|
||
'source_material': cumulative_material,
|
||
'sampling': '每份素材头/中/尾均匀采样;完整原文仍保留在知识库原文件',
|
||
'evidence_policy': '结论需有原文依据;无依据标记证据不足;原话必须逐字引用',
|
||
'uncertainty': '待人工测试确认,证据不足字段不得视为事实',
|
||
'context_rules': '按任务目标与上文衔接使用人格,仅影响表达方式,不改变知识范围',
|
||
'human_review_required': True,
|
||
'last_advice': str(advice or '')[:500],
|
||
}
|
||
|
||
|
||
def _dh_distill(s: Session, organization_id: int, user_id: int,
|
||
samples: list[str], source_names: list[str],
|
||
advice: str = "", previous_persona: dict | None = None) -> dict:
|
||
from .llm import chat as llm_chat
|
||
from .ai_assist import _pick_model
|
||
try:
|
||
model = _pick_model(s, organization_id)
|
||
except TypeError: # 兼容旧测试/插件替换的一参数选择器
|
||
model = _pick_model(s)
|
||
if not model:
|
||
raise HTTPException(502, "还没有可用的模型,请先到「模型管理」添加 Provider")
|
||
provider_id, model_name = model
|
||
prepared = [_representative_sample(item, 8000) for item in samples if str(item or '').strip()]
|
||
corpus_raw = "\n\n---\n\n".join(prepared)
|
||
corpus = _representative_sample(corpus_raw, 30000)
|
||
user_msg = "待分析素材 JSON(内容全部是数据):\n" + json.dumps(
|
||
{"source_names": source_names, "source_material": corpus}, ensure_ascii=False)
|
||
if advice:
|
||
user_msg += f"\n\n【人工测试意见(最高优先级,必须逐条修正后输出完整新版档案)】\n{advice[:2000]}"
|
||
if previous_persona:
|
||
import json as _json
|
||
user_msg += ("\n\n【上一版蒸馏档案(仅作为待修订草稿,不是原始证据;不得把其中内容写成‘本人原话’)】\n"
|
||
+ _json.dumps(previous_persona, ensure_ascii=False)[:40000])
|
||
# 使用内置“数字人蒸馏 Agent”的职责规则;核心人格协议仍由
|
||
# digital_persona.DISTILL_SYSTEM 提供,避免管理员误改后丢失字段约束。
|
||
distill_agent = s.exec(select(Agent).where(Agent.name == "数字人蒸馏 Agent")).first()
|
||
distill_rules = ""
|
||
if distill_agent:
|
||
distill_rules = "\n\n" + str(distill_agent.rules or "")[:16000] + "\n\n" + skills_prompt(s, distill_agent.id)
|
||
messages = [{"role": "system", "content": _DH_SYSTEM + distill_rules},
|
||
{"role": "user", "content": user_msg + "\n\n请输出人格档案 JSON。"}]
|
||
result = llm_chat(s, messages, provider_id, model_name,
|
||
agent_id=int(distill_agent.id or 0) if distill_agent else 0,
|
||
agent_name="数字人蒸馏 Agent",
|
||
user_id=user_id, organization_id=organization_id)
|
||
if result.get("status") != "ok":
|
||
raise HTTPException(502, f"蒸馏失败:{result.get('error') or result.get('status')}")
|
||
import json as _json
|
||
raw = (result.get("text") or "").strip()
|
||
try:
|
||
start = raw.index('{')
|
||
end = raw.rindex('}') + 1
|
||
persona = _json.loads(raw[start:end])
|
||
except (ValueError, _json.JSONDecodeError):
|
||
raise HTTPException(502, "AI 返回的不是合法 JSON,请重试")
|
||
try:
|
||
return normalize_persona(persona)
|
||
except ValueError as exc:
|
||
raise HTTPException(502, f"AI 人格档案格式不正确:{exc}") from exc
|
||
|
||
|
||
def _ensure_digital_human_agent(session: Session, human: DigitalHuman) -> Agent:
|
||
"""为新数字人创建同名的默认对话 Agent,并绑定该数字人。
|
||
|
||
这个 Agent 使用 service 角色,因此不要求先挂知识库;运行时会继承数字人
|
||
人格和全局模型。用户仍可在 Agent 页面继续创建其他 Agent 并绑定同一数字人。
|
||
调用可重复执行,避免重试蒸馏时产生重复的默认 Agent。
|
||
"""
|
||
existing = session.exec(select(Agent).where(
|
||
Agent.digital_human_id == int(human.id or 0),
|
||
Agent.organization_id == int(human.organization_id or 1),
|
||
Agent.owner_user_id == int(human.owner_user_id or 0),
|
||
Agent.name == human.name,
|
||
)).first()
|
||
if existing:
|
||
return existing
|
||
agent = Agent(
|
||
name=human.name,
|
||
role="service",
|
||
desc=f"数字人「{human.name}」的默认对话 Agent(系统自动创建)",
|
||
content_tags=[], kb_ids=[], kb_category_ids={}, fusion_agent_ids=[],
|
||
model_provider_id=None, model_name="", prompt_template_id=None,
|
||
model_catalog_id=None, allowed_tools=[],
|
||
rules=DEFAULT_NEW_AGENT_RULES + f"\n\n## 数字人绑定\n- 始终使用绑定数字人「{human.name}」的人格与表达规则。",
|
||
persona_json={}, digital_human_id=int(human.id or 0),
|
||
runtime_snapshot={}, status="active", builtin=False,
|
||
organization_id=int(human.organization_id or 1),
|
||
owner_user_id=int(human.owner_user_id or 0), visibility="private",
|
||
)
|
||
session.add(agent)
|
||
session.flush()
|
||
return agent
|
||
|
||
|
||
@app.get("/api/digital-humans")
|
||
def list_digital_humans(s: Session = Depends(get_session), request: Request = None):
|
||
require_permission_stub = None
|
||
from .models import DigitalHuman as DH, DigitalHumanGrant
|
||
organization_id, user_id = actor_fields(request)
|
||
user = request_user(request)
|
||
rows = s.exec(select(DH).where(DH.organization_id.in_([0, organization_id])).order_by(DH.id.desc())).all()
|
||
|
||
rows = [d for d in rows if not _hidden_for_user(s, "digital_human", int(d.id or 0), request)
|
||
and human_access(s, d, user, "read")]
|
||
# 老版本已创建的数字人可能没有默认 Agent;首次查看时补齐,保持与新建
|
||
# 数字人的行为一致。全局资源(owner_user_id=0)不在这里生成私有 Agent。
|
||
backfilled = False
|
||
for human in rows:
|
||
if not int(human.owner_user_id or 0):
|
||
continue
|
||
exists = s.exec(select(Agent).where(
|
||
Agent.digital_human_id == int(human.id or 0),
|
||
Agent.organization_id == int(human.organization_id or 1),
|
||
Agent.owner_user_id == int(human.owner_user_id or 0),
|
||
)).first()
|
||
if not exists:
|
||
_ensure_digital_human_agent(s, human)
|
||
backfilled = True
|
||
if backfilled:
|
||
s.commit()
|
||
return [{"id": d.id, "name": d.name, "desc": d.desc, "status": d.status, "version": d.version,
|
||
"persona_name": (d.persona or {}).get("persona_name", ""),
|
||
"persona_meta": d.persona_meta or {},
|
||
"source_count": len(_unique_source_names(d.source_files or [])), "last_feedback": d.last_feedback,
|
||
"persona_preview": persona_prompt(d.persona, name=d.name, version=d.version),
|
||
"can_manage": human_access(s, d, user, "manage"),
|
||
"can_use": d.status == "active" and human_access(s, d, user, "use"),
|
||
"visibility": d.visibility, **owner_fields(s, d.owner_user_id),
|
||
"created_at": d.created_at} for d in rows]
|
||
|
||
|
||
@app.post("/api/digital-humans/distill")
|
||
async def distill_digital_human(request: Request = None, s: Session = Depends(get_session),
|
||
files: list[UploadFile] = File(default=[]),
|
||
name: str = Form(""), desc: str = Form(""),
|
||
text: str = Form(""), advice: str = Form(""),
|
||
previous_persona: str = Form(""),
|
||
visibility: str = Form("private")):
|
||
"""蒸馏数字人:上传文档(docx/pdf/txt/md/srt)和/或粘贴文本 → AI 蒸馏十一维。
|
||
advice+previous_persona = 人工测试未通过时的「按建议重新蒸馏」。"""
|
||
import io
|
||
from .models import DigitalHuman as DH
|
||
allowed = {".txt", ".md", ".srt", ".docx", ".pdf", ".doc"}
|
||
samples: list[str] = []
|
||
names: list[str] = []
|
||
all_files = list(files or [])
|
||
skipped: list[str] = []
|
||
for f in all_files:
|
||
safe = Path(f.filename or "file").name
|
||
suffix = Path(safe).suffix.lower()
|
||
if suffix not in allowed:
|
||
skipped.append(f"{safe}(格式不支持)")
|
||
continue
|
||
_limit = _upload_limit_bytes(request)
|
||
raw = await f.read(_limit + 1)
|
||
if len(raw) > _limit:
|
||
raise HTTPException(413, f"文件过大:{safe}(当前通道限 {_limit // (1024*1024)}MB)")
|
||
body = ""
|
||
if suffix == ".doc":
|
||
tmp_doc = Path(KB_DIR / "uploads") / f"dh_{uuid.uuid4().hex[:8]}.doc"
|
||
tmp_doc.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp_doc.write_bytes(raw)
|
||
try:
|
||
docx_path = _convert_doc_to_docx(tmp_doc)
|
||
raw = docx_path.read_bytes()
|
||
suffix = ".docx"
|
||
finally:
|
||
tmp_doc.unlink(missing_ok=True)
|
||
docx_path.unlink(missing_ok=True)
|
||
if suffix == ".docx":
|
||
try:
|
||
import docx as _docx
|
||
doc = _docx.Document(io.BytesIO(raw))
|
||
parts = [x.text for x in doc.paragraphs if x.text and x.text.strip()]
|
||
for table in doc.tables:
|
||
for row in table.rows:
|
||
for cell in row.cells:
|
||
t = (cell.text or "").strip()
|
||
if t:
|
||
parts.append(t)
|
||
body = "\n".join(parts)
|
||
except Exception as exc:
|
||
raise HTTPException(422, f"解析 docx 失败:{safe}")
|
||
elif suffix == ".pdf":
|
||
try:
|
||
import pypdf as _pdf
|
||
reader = _pdf.PdfReader(io.BytesIO(raw))
|
||
body = "\n".join((pg.extract_text() or "") for pg in reader.pages)
|
||
except Exception:
|
||
raise HTTPException(422, f"解析 pdf 失败:{safe}(扫描版无文字层请改 docx)")
|
||
else:
|
||
for enc in ("utf-8", "gb18030", "utf-16"):
|
||
try:
|
||
body = raw.decode(enc)
|
||
break
|
||
except (UnicodeDecodeError, UnicodeError):
|
||
continue
|
||
if body.strip():
|
||
# 蒸馏保留口头禅和语气,仍清掉字幕编号、时间戳和不可见字符等结构噪声。
|
||
from .rag.parse import clean_text as _clean_source_text
|
||
cleaned_body = _clean_source_text(body.strip(), remove_fillers=False)
|
||
if cleaned_body:
|
||
samples.append(_representative_sample(cleaned_body, 8000))
|
||
names.append(safe)
|
||
else:
|
||
skipped.append(f"{safe}(清洗后没有可读取的文字)")
|
||
else:
|
||
skipped.append(f"{safe}(没有可读取的文字,扫描版 PDF/图片不支持)")
|
||
from .rag.parse import clean_text as _clean_source_text
|
||
pasted = _clean_source_text((text or "").strip(), remove_fillers=False)
|
||
if pasted:
|
||
samples.insert(0, _representative_sample(pasted, 12000))
|
||
names.insert(0, "粘贴文本")
|
||
samples = [t for t in samples if t]
|
||
total = 0
|
||
kept: list[str] = []
|
||
for _sm in samples: # 总量保护 120000 字
|
||
if total >= 120000:
|
||
break
|
||
kept.append(_sm)
|
||
total += len(_sm)
|
||
samples = kept
|
||
if not samples and not (previous_persona or "").strip():
|
||
detail = ";".join(skipped[:5]) if skipped else "未收到有效文字"
|
||
raise HTTPException(422, f"没有可蒸馏的文字内容。{detail}。支持 docx/doc/pdf/txt/md/srt,扫描版 PDF 和图片不支持")
|
||
organization_id, user_id = actor_fields(request)
|
||
prev = None
|
||
if (previous_persona or "").strip():
|
||
try:
|
||
prev = json.loads(previous_persona)
|
||
except json.JSONDecodeError:
|
||
prev = None
|
||
# 蒸馏包含模型网络调用,移出事件循环线程,避免一个长请求拖住全站健康检查、SSE 和页面 API。
|
||
persona = await asyncio.to_thread(_dh_distill, s, organization_id, user_id, samples, names,
|
||
advice=advice, previous_persona=prev)
|
||
dh_name = (name or "").strip() or (persona.get("persona_name") or "未命名数字人")
|
||
names = _unique_source_names(names)
|
||
dh = DH(name=dh_name[:60], desc=desc[:300], persona=persona,
|
||
persona_meta=_distill_meta(samples, names, batch_sources=names),
|
||
status="testing", version=1, source_files=names,
|
||
organization_id=organization_id, owner_user_id=user_id,
|
||
visibility=visibility_for_create(request, visibility if visibility in ("private", "shared", "global") else "private"))
|
||
s.add(dh); s.commit(); s.refresh(dh)
|
||
# 每个数字人创建时自动配套一个同名对话 Agent;后续可在 Agent 页面继续
|
||
# 为同一数字人增加其他 Agent。service 角色不要求先挂知识库,适合微信对话。
|
||
default_agent = _ensure_digital_human_agent(s, dh)
|
||
s.commit(); s.refresh(default_agent)
|
||
return {"id": dh.id, "persona": dh.persona, "persona_meta": dh.persona_meta,
|
||
"version": dh.version, "sources": names,
|
||
"agent_id": default_agent.id, "agent_name": default_agent.name}
|
||
|
||
|
||
@app.post("/api/digital-humans/{dhid}/test-write")
|
||
def digital_human_test_write(dhid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
"""人工测试:用数字人当前人格试写一段文稿(不落库)。审核依据。"""
|
||
from .models import DigitalHuman as DH
|
||
dh = require_human(s, dhid, request_user(request), "use")
|
||
topic = str(body.get("topic") or "").strip() or "开场:欢迎姐妹们来到今晚的直播。"
|
||
organization_id, user_id = actor_fields(request)
|
||
from .llm import chat as llm_chat
|
||
from .ai_assist import _pick_model
|
||
try:
|
||
model = _pick_model(s, organization_id)
|
||
except TypeError: # 兼容旧测试/插件替换的一参数选择器
|
||
model = _pick_model(s)
|
||
if not model:
|
||
raise HTTPException(502, "还没有可用的模型")
|
||
provider_id, model_name = model
|
||
persona_text = persona_prompt(dh.persona, name=dh.name, version=dh.version)
|
||
messages = [
|
||
{"role": "system", "content": f"你是下面这个人本人在说话,完全按她的人格、口吻、词汇习惯输出直播口播稿正文(单段口语,500字左右)。\n\n## 人格档案(必须逐条贴合)\n{persona_text}"},
|
||
{"role": "user", "content": f"【任务】{topic[:500]}\n\n请以这个人的身份直接开讲。"},
|
||
]
|
||
result = llm_chat(s, messages, provider_id, model_name, agent_name=f"{dh.name}·测试",
|
||
user_id=user_id, organization_id=organization_id)
|
||
if result.get("status") != "ok":
|
||
raise HTTPException(502, f"试写失败:{result.get('error') or result.get('status')}")
|
||
return {"text": (result.get("text") or "").strip(), "model": result.get("model", "")}
|
||
|
||
|
||
@app.post("/api/digital-humans/{dhid}/redistill")
|
||
async def redistill_digital_human(dhid: int, request: Request = None, s: Session = Depends(get_session),
|
||
advice: str = Form(""), text: str = Form(""),
|
||
files: list[UploadFile] = File(default=[])):
|
||
"""人工测试未通过:填建议,可补充上传素材后重新蒸馏(version+1,回到 testing)。"""
|
||
from .models import DigitalHuman as DH
|
||
dh, _owner = _dh_manage(s, dhid, request)
|
||
organization_id, user_id = actor_fields(request)
|
||
samples: list[str] = []
|
||
names = _unique_source_names(list(dh.source_files or []))
|
||
batch_names: list[str] = []
|
||
if (text or "").strip():
|
||
samples.append(_representative_sample(text.strip(), 8000))
|
||
batch_names.append("补充文本")
|
||
names = _unique_source_names(names + batch_names)
|
||
# 重蒸馏允许补充文档素材,解析规则与首次蒸馏保持一致。
|
||
allowed = {".txt", ".md", ".srt", ".docx", ".pdf", ".doc"}
|
||
for f in list(files or []):
|
||
safe = Path(f.filename or "file").name
|
||
suffix = Path(safe).suffix.lower()
|
||
if suffix not in allowed:
|
||
raise HTTPException(422, f"格式不支持:{safe}(支持 docx/doc/pdf/txt/md/srt)")
|
||
limit = _upload_limit_bytes(request)
|
||
raw = await f.read(limit + 1)
|
||
if len(raw) > limit:
|
||
raise HTTPException(413, f"文件过大:{safe}(当前通道限 {limit // (1024 * 1024)}MB)")
|
||
body = ""
|
||
if suffix == ".doc":
|
||
tmp_doc = Path(KB_DIR / "uploads") / f"dh_redistill_{uuid.uuid4().hex[:8]}.doc"
|
||
tmp_doc.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp_doc.write_bytes(raw)
|
||
docx_path = None
|
||
try:
|
||
docx_path = _convert_doc_to_docx(tmp_doc)
|
||
raw = docx_path.read_bytes()
|
||
suffix = ".docx"
|
||
except Exception:
|
||
raise HTTPException(422, f"解析 doc 失败:{safe}")
|
||
finally:
|
||
tmp_doc.unlink(missing_ok=True)
|
||
if docx_path:
|
||
docx_path.unlink(missing_ok=True)
|
||
if suffix == ".docx":
|
||
try:
|
||
import docx as _docx
|
||
doc = _docx.Document(io.BytesIO(raw))
|
||
parts = [x.text for x in doc.paragraphs if x.text and x.text.strip()]
|
||
for table in doc.tables:
|
||
for row in table.rows:
|
||
for cell in row.cells:
|
||
value = (cell.text or "").strip()
|
||
if value:
|
||
parts.append(value)
|
||
body = "\n".join(parts)
|
||
except Exception:
|
||
raise HTTPException(422, f"解析 docx 失败:{safe}")
|
||
elif suffix == ".pdf":
|
||
try:
|
||
import pypdf as _pdf
|
||
reader = _pdf.PdfReader(io.BytesIO(raw))
|
||
body = "\n".join((page.extract_text() or "") for page in reader.pages)
|
||
except Exception:
|
||
raise HTTPException(422, f"解析 pdf 失败:{safe}(扫描版无文字层请改 docx)")
|
||
elif suffix in {".txt", ".md", ".srt"}:
|
||
for enc in ("utf-8", "gb18030", "utf-16"):
|
||
try:
|
||
body = raw.decode(enc)
|
||
break
|
||
except (UnicodeDecodeError, UnicodeError):
|
||
continue
|
||
from .rag.parse import clean_text as _clean_source_text
|
||
cleaned = _clean_source_text(body.strip(), remove_fillers=False) if body else ""
|
||
if cleaned:
|
||
samples.append(cleaned[:8000])
|
||
batch_names.append(safe)
|
||
names = _unique_source_names(names + [safe])
|
||
else:
|
||
raise HTTPException(422, f"文件没有可蒸馏的文字:{safe}(扫描版 PDF/图片不支持)")
|
||
if not samples:
|
||
# 没有新增原始素材时,AI 只能依据上一版草稿与人工意见修订;
|
||
# 不再把 persona JSON 冒充本人原文,避免证据污染。
|
||
samples = []
|
||
previous_version = dh.version
|
||
previous_sources = _unique_source_names(list(dh.source_files or []))
|
||
prior_material = str((dh.persona_meta or {}).get('source_material') or '').strip()
|
||
distill_samples = ([prior_material] if prior_material else []) + samples
|
||
persona = await asyncio.to_thread(_dh_distill, s, organization_id, user_id, distill_samples, names,
|
||
advice=advice, previous_persona=dh.persona)
|
||
dh.persona = persona
|
||
meta = _distill_meta(samples, names, advice,
|
||
previous_meta=dh.persona_meta,
|
||
batch_sources=batch_names)
|
||
meta.update({
|
||
"incremental": bool(samples),
|
||
"new_source_count": len(_unique_source_names(batch_names)),
|
||
"previous_version": previous_version,
|
||
"cumulative_source_count": len(_unique_source_names(names)),
|
||
})
|
||
dh.persona_meta = meta
|
||
dh.version += 1
|
||
dh.status = "testing"
|
||
dh.last_feedback = advice.strip()[:500]
|
||
# 保留本次补充文本/文件来源,详情页可追溯重蒸馏使用过的素材。
|
||
dh.source_files = _unique_source_names(names)
|
||
s.add(dh); s.commit(); s.refresh(dh)
|
||
_record_fork_revision(s, "digital_human", dhid, request)
|
||
return {"id": dh.id, "persona": dh.persona, "persona_meta": dh.persona_meta, "version": dh.version}
|
||
|
||
|
||
def _wechat_account_visible(row: WeChatBotAccount, user: AppUser | None) -> bool:
|
||
if not user:
|
||
return False
|
||
# 管理员也只能管理自己组织的连接;组织 0 仅用于兼容旧数据,不能成为
|
||
# 跨组织的通配入口。普通用户只能管理自己创建的连接。
|
||
if int(row.organization_id or 1) != int(user.organization_id or 1):
|
||
return False
|
||
return user.role == "admin" or int(row.owner_user_id or 0) == int(user.id or 0)
|
||
|
||
|
||
@app.get("/api/wechat/bot/accounts")
|
||
def list_wechat_bot_accounts(s: Session = Depends(get_session), request: Request = None):
|
||
user = request_user(request)
|
||
if not user:
|
||
raise HTTPException(401, "请先登录")
|
||
rows = s.exec(select(WeChatBotAccount).where(
|
||
WeChatBotAccount.organization_id.in_([0, int(user.organization_id or 1)])
|
||
).order_by(WeChatBotAccount.id.desc())).all()
|
||
rows = [row for row in rows if _wechat_account_visible(row, user)]
|
||
# sidecar 的登录状态是异步变化的;同步短期二维码/connected 状态,避免数据库
|
||
# 永远停留在 pending_qr,回调路由才能按已连接账号放行消息。
|
||
adapter_url = str(os.getenv("YAWAN_WECHAT_ADAPTER_URL") or "").strip().rstrip("/")
|
||
if adapter_url:
|
||
try:
|
||
import urllib.request
|
||
changed = False
|
||
for row in rows:
|
||
if row.status not in {"pending_qr", "error"}:
|
||
continue
|
||
try:
|
||
with urllib.request.urlopen(adapter_url + f"/accounts/{int(row.id)}", timeout=1.5) as resp:
|
||
data = json.loads(resp.read().decode("utf-8"))
|
||
status = str(data.get("status") or row.status)[:40]
|
||
qr_code = str(data.get("qr_code") or row.qr_code or "")[:4000]
|
||
last_error = str(data.get("last_error") or "")[:500]
|
||
if (status, qr_code, last_error) != (row.status, row.qr_code, row.last_error):
|
||
row.status, row.qr_code, row.last_error = status, qr_code, last_error
|
||
row.updated_at = now(); s.add(row); changed = True
|
||
except Exception:
|
||
continue
|
||
if changed:
|
||
s.commit()
|
||
except Exception:
|
||
pass
|
||
return [{"id": row.id, "account_name": row.account_name, "provider": row.provider,
|
||
"status": row.status, "qr_code": row.qr_code, "external_account_id": row.external_account_id,
|
||
"agent_id": row.agent_id,
|
||
"agent_name": (s.get(Agent, row.agent_id).name if row.agent_id and s.get(Agent, row.agent_id) else ""),
|
||
"digital_human_name": ((s.get(DigitalHuman, s.get(Agent, row.agent_id).digital_human_id).name
|
||
if row.agent_id and s.get(Agent, row.agent_id) and s.get(Agent, row.agent_id).digital_human_id
|
||
and s.get(DigitalHuman, s.get(Agent, row.agent_id).digital_human_id) else "")),
|
||
"last_error": row.last_error,
|
||
"created_at": row.created_at, "updated_at": row.updated_at,
|
||
"owner_user_id": row.owner_user_id} for row in rows]
|
||
|
||
|
||
@app.post("/api/wechat/bot/accounts/connect")
|
||
@app.post("/api/wechat/bot/accounts")
|
||
def connect_wechat_bot_account(body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
user = request_user(request)
|
||
if not user:
|
||
raise HTTPException(401, "请先登录")
|
||
name = str((body or {}).get("account_name") or (body or {}).get("name") or "微信 Bot")[:120]
|
||
agent_id = int((body or {}).get("agent_id") or 0)
|
||
agent = s.get(Agent, agent_id) if agent_id else None
|
||
if not agent or agent.status != "active" or agent.role not in {"production", "service"} or not can_access_agent(s, agent, user, "use"):
|
||
raise HTTPException(422, "请选择当前账号有权使用的生产或服务 Agent")
|
||
if not agent.digital_human_id:
|
||
raise HTTPException(422, "请选择创建数字人时自动生成的同名 Agent,再连接微信")
|
||
linked_human = s.get(DigitalHuman, agent.digital_human_id)
|
||
if not linked_human or linked_human.status != "active":
|
||
raise HTTPException(422, "该 Agent 绑定的数字人尚未审核生效,请先完成数字人审核")
|
||
adapter_url = str(os.getenv("YAWAN_WECHAT_ADAPTER_URL") or "").strip().rstrip("/")
|
||
account_secret = uuid.uuid4().hex + uuid.uuid4().hex
|
||
row = WeChatBotAccount(owner_user_id=int(user.id), organization_id=int(user.organization_id or 1),
|
||
account_name=name, agent_id=agent.id, status="pending_qr",
|
||
credential_ref=uuid.uuid4().hex,
|
||
bot_secret_hash=hashlib.sha256(account_secret.encode("utf-8")).hexdigest(),
|
||
bot_secret_hint=account_secret[-4:])
|
||
s.add(row); s.commit(); s.refresh(row)
|
||
# 官方 iLink/独立 sidecar 可通过此回调返回二维码;没有 sidecar 时明确提示,
|
||
# 避免伪造一个用户无法扫码的二维码地址。
|
||
if adapter_url:
|
||
try:
|
||
import urllib.request
|
||
payload = json.dumps({"account_id": row.id, "owner_user_id": row.owner_user_id,
|
||
"agent_id": row.agent_id, "bot_secret": account_secret}).encode("utf-8")
|
||
req = urllib.request.Request(adapter_url + "/accounts/connect", data=payload,
|
||
headers={"content-type": "application/json"}, method="POST")
|
||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||
data = json.loads(resp.read().decode("utf-8"))
|
||
row.qr_code = str(data.get("qr_code") or data.get("qr_url") or "")[:4000]
|
||
row.external_account_id = str(data.get("external_account_id") or "")[:200]
|
||
row.status = str(data.get("status") or "pending_qr")[:40]
|
||
row.last_error = ""
|
||
except Exception as exc:
|
||
row.status = "error"; row.last_error = f"微信适配器连接失败:{type(exc).__name__}"
|
||
else:
|
||
row.status = "error"
|
||
row.last_error = "尚未配置官方 iLink/微信适配器,请设置 YAWAN_WECHAT_ADAPTER_URL"
|
||
row.updated_at = now(); s.add(row); s.commit(); s.refresh(row)
|
||
return {"id": row.id, "status": row.status, "qr_code": row.qr_code,
|
||
"account_name": row.account_name, "agent_id": row.agent_id, "last_error": row.last_error,
|
||
# 只在创建响应返回一次;适配器应立即保存,后台之后无法再次读取。
|
||
"bot_secret": account_secret}
|
||
|
||
|
||
@app.post("/api/wechat/bot/accounts/{account_id}/callback")
|
||
def wechat_bot_account_callback(account_id: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
"""iLink sidecar 回报扫码/断开状态;仅接受该账号的一次性密钥。"""
|
||
row = s.get(WeChatBotAccount, account_id)
|
||
supplied = str((request.headers.get("X-WeChat-Bot-Secret") if request else "") or "")
|
||
if not row or not supplied or not row.bot_secret_hash or not hmac.compare_digest(
|
||
hashlib.sha256(supplied.encode("utf-8")).hexdigest(), row.bot_secret_hash):
|
||
raise HTTPException(404, "微信连接不存在")
|
||
status = str((body or {}).get("status") or "")
|
||
if status not in {"pending_qr", "connected", "disconnected", "error"}:
|
||
raise HTTPException(422, "无效的微信连接状态")
|
||
row.status = status
|
||
if "qr_code" in (body or {}): row.qr_code = str(body.get("qr_code") or "")[:4000]
|
||
if "external_account_id" in (body or {}): row.external_account_id = str(body.get("external_account_id") or "")[:200]
|
||
row.last_error = str((body or {}).get("last_error") or "")[:500]
|
||
row.updated_at = now(); s.add(row); s.commit()
|
||
return {"ok": True, "status": row.status}
|
||
|
||
|
||
@app.patch("/api/wechat/bot/accounts/{account_id}")
|
||
@app.patch("/api/wechat/bot/accounts/{account_id}/bind-agent")
|
||
def update_wechat_bot_account(account_id: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
user = request_user(request)
|
||
row = s.get(WeChatBotAccount, account_id)
|
||
if not user or not row or not _wechat_account_visible(row, user):
|
||
raise HTTPException(404, "微信连接不存在")
|
||
if "agent_id" in (body or {}):
|
||
agent = s.get(Agent, int(body.get("agent_id") or 0))
|
||
if not agent or agent.status != "active" or agent.role not in {"production", "service"} or not can_access_agent(s, agent, user, "use"):
|
||
raise HTTPException(422, "无权绑定该 Agent")
|
||
if not agent.digital_human_id:
|
||
raise HTTPException(422, "该 Agent 尚未绑定数字人,请先完成数字人绑定")
|
||
linked_human = s.get(DigitalHuman, agent.digital_human_id)
|
||
if not linked_human or linked_human.status != "active":
|
||
raise HTTPException(422, "该 Agent 绑定的数字人尚未审核生效")
|
||
row.agent_id = agent.id
|
||
if "account_name" in (body or {}):
|
||
row.account_name = str(body.get("account_name") or row.account_name)[:120]
|
||
row.updated_at = now(); s.add(row); s.commit()
|
||
# sidecar 在内存中保存当前 Agent,绑定变更后立即同步,避免后续消息仍路由到旧 Agent。
|
||
if "agent_id" in (body or {}):
|
||
adapter_url = str(os.getenv("YAWAN_WECHAT_ADAPTER_URL") or "").strip().rstrip("/")
|
||
if adapter_url:
|
||
try:
|
||
import urllib.request
|
||
payload = json.dumps({"agent_id": row.agent_id}).encode("utf-8")
|
||
req = urllib.request.Request(adapter_url + f"/accounts/{int(account_id)}", data=payload,
|
||
headers={"content-type": "application/json"}, method="PATCH")
|
||
with urllib.request.urlopen(req, timeout=2):
|
||
pass
|
||
except Exception:
|
||
logger.warning("wechat sidecar agent sync failed account_id=%s", account_id)
|
||
return {"ok": True, "id": row.id, "agent_id": row.agent_id, "account_name": row.account_name}
|
||
|
||
|
||
@app.delete("/api/wechat/bot/accounts/{account_id}")
|
||
def delete_wechat_bot_account(account_id: int, s: Session = Depends(get_session), request: Request = None):
|
||
user = request_user(request)
|
||
row = s.get(WeChatBotAccount, account_id)
|
||
if not user or not row or not _wechat_account_visible(row, user):
|
||
raise HTTPException(404, "微信连接不存在")
|
||
# 通知本机 iLink sidecar 停止轮询并清理会话;即使 sidecar 暂时不可用,
|
||
# 数据库状态也先更新,避免已删除账号继续接收消息。
|
||
adapter_url = str(os.getenv("YAWAN_WECHAT_ADAPTER_URL") or "").strip().rstrip("/")
|
||
if adapter_url:
|
||
try:
|
||
import urllib.request
|
||
req = urllib.request.Request(adapter_url + f"/accounts/{int(account_id)}", method="DELETE")
|
||
with urllib.request.urlopen(req, timeout=2):
|
||
pass
|
||
except Exception:
|
||
logger.warning("wechat sidecar account cleanup failed account_id=%s", account_id)
|
||
# 删除连接记录本身,避免刷新后已删除账号再次出现在列表;数字人、Agent
|
||
# 和微信外部账号不会受影响。
|
||
s.delete(row); s.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/wechat/bot/reply")
|
||
def wechat_bot_reply(body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
"""微信 Bot 桥接:外部 Bot 收发消息,系统 Agent 负责生成回复。
|
||
|
||
该接口故意不使用登录态,改用每个微信账号独立的 Bot 密钥和常量时间比较;
|
||
仅允许账号绑定的 Agent,避免公网请求任意调用模型。
|
||
conversation_id 只以哈希写入 AgentSession,保留上下文但不落外部用户标识。
|
||
"""
|
||
try:
|
||
account_id = int((body or {}).get("account_id") or 0)
|
||
except (TypeError, ValueError):
|
||
account_id = 0
|
||
# 多账号模式下禁止使用全局 Agent/Secret 回退,否则任意适配器都能串到
|
||
# 其他用户的 Agent 和会话。每个回调必须明确指定账号。
|
||
if not account_id:
|
||
raise HTTPException(422, "缺少 account_id")
|
||
account = s.get(WeChatBotAccount, account_id)
|
||
if not account:
|
||
raise HTTPException(404, "微信连接不存在")
|
||
if account.status != "connected":
|
||
raise HTTPException(409, "微信连接尚未完成扫码或已断开")
|
||
account_secret_hash = str(account.bot_secret_hash or "")
|
||
if not account_secret_hash:
|
||
raise HTTPException(503, "微信连接尚未配置账号密钥")
|
||
timestamp = str((request.headers.get("X-WeChat-Bot-Timestamp") if request else "") or "").strip()
|
||
signature = str((request.headers.get("X-WeChat-Bot-Signature") if request else "") or "").strip()
|
||
conversation_header = str((body or {}).get("conversation_id") or (body or {}).get("sender_id") or "").strip()
|
||
message_header = str((body or {}).get("message_id") or "").strip()
|
||
text_header = str((body or {}).get("text") or "").strip()
|
||
signed_ok = False
|
||
supplied_secret = str((request.headers.get("X-WeChat-Bot-Secret") if request else "") or "")
|
||
signing_secret = supplied_secret
|
||
secret_valid = bool(supplied_secret and hmac.compare_digest(
|
||
hashlib.sha256(supplied_secret.encode("utf-8")).hexdigest(), account_secret_hash))
|
||
if secret_valid and timestamp and signature:
|
||
try:
|
||
age = abs(int(time.time()) - int(timestamp))
|
||
payload = f"{timestamp}.{conversation_header}.{message_header}.{text_header}"
|
||
expected = hmac.new(signing_secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
||
signed_ok = age <= 300 and hmac.compare_digest(signature, expected)
|
||
except (TypeError, ValueError):
|
||
signed_ok = False
|
||
# 兼容旧适配器;新接入请使用时间戳签名头。
|
||
legacy_ok = secret_valid
|
||
if not signed_ok and not legacy_ok:
|
||
raise HTTPException(401, "微信 Bot 签名无效或已过期")
|
||
agent_id = int(account.agent_id or 0)
|
||
if not agent_id:
|
||
raise HTTPException(409, "该微信连接尚未绑定 Agent")
|
||
try:
|
||
requested_agent_id = int((body or {}).get("agent_id") or agent_id)
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(422, "agent_id 无效")
|
||
if requested_agent_id != agent_id:
|
||
raise HTTPException(403, "该 Agent 未授权给当前微信连接")
|
||
agent = s.get(Agent, agent_id)
|
||
if not agent or agent.status != "active" or agent.role not in {"production", "service"}:
|
||
raise HTTPException(404, "微信 Bot 绑定的 Agent 不存在或未启用")
|
||
owner = s.get(AppUser, int(account.owner_user_id or 0))
|
||
if (not owner or not owner.enabled or
|
||
int(owner.organization_id or 1) != int(account.organization_id or 1) or
|
||
not can_access_agent(s, agent, owner, "use")):
|
||
# 不泄露账号所属用户、组织或 Agent 的存在性。
|
||
raise HTTPException(403, "该微信连接没有可用的 Agent 权限")
|
||
text = str((body or {}).get("text") or "").strip()
|
||
if not text:
|
||
raise HTTPException(422, "消息内容不能为空")
|
||
if len(text) > 8000:
|
||
raise HTTPException(422, "单条微信消息不能超过 8000 字")
|
||
conversation = conversation_header
|
||
if not conversation:
|
||
raise HTTPException(422, "缺少 conversation_id")
|
||
# 使用配置密钥参与哈希,数据库中不保存微信 openid/微信号等外部标识。
|
||
conv_hash = hashlib.sha256((account_secret_hash + ":" + str(account.id) + ":" +
|
||
str(account.owner_user_id) + ":" + conversation).encode("utf-8")).hexdigest()
|
||
message_id = str((body or {}).get("message_id") or "").strip()[:160]
|
||
row = s.exec(select(AgentSession).where(
|
||
AgentSession.phase == "wechat", AgentSession.agent_id == agent.id,
|
||
AgentSession.trace_id == conv_hash)).first()
|
||
if not row:
|
||
row = AgentSession(run_id=0, campaign_id=0, segment_id=0, agent_id=agent.id,
|
||
agent_name=agent.name, phase="wechat", trace_id=conv_hash,
|
||
status="running", max_turns=1,
|
||
messages=[{"role": "system", "content":
|
||
(agent.rules or "你是一个可靠的中文对话助手。")[:12000]
|
||
+ "\n\n" + runtime_persona(s, agent, user=None)
|
||
+ "\n\n" + skills_prompt(s, agent.id)}])
|
||
if message_id and str((row.result_meta or {}).get("last_message_id") or "") == message_id:
|
||
cached_reply = str((row.result_meta or {}).get("last_reply") or "").strip()
|
||
if cached_reply:
|
||
return {"ok": True, "reply": cached_reply, "conversation_id": conversation,
|
||
"agent_id": agent.id, "agent_name": agent.name,
|
||
"model": (row.result_meta or {}).get("model", ""),
|
||
"trace_id": (row.result_meta or {}).get("trace_id", ""), "idempotent": True}
|
||
messages = list(row.messages or [])
|
||
messages.append({"role": "user", "content": text})
|
||
# 只保留系统提示和最近对话,防止长期会话无限增长。
|
||
messages = [messages[0], *messages[-20:]] if messages else [{"role": "user", "content": text}]
|
||
# 注入 Agent 已绑定知识库的相关片段;分类范围按知识库分别应用。
|
||
kb_context = []
|
||
for raw_kid in (agent.kb_ids or []):
|
||
try:
|
||
kid = int(raw_kid)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
mapping = agent.kb_category_ids or {}
|
||
categories = mapping.get(str(kid), mapping.get(kid, [])) if isinstance(mapping, dict) else []
|
||
try:
|
||
hits = rag_pipeline.search_kbs_remote(
|
||
s, [kid], text, k=4, user_id=int(owner.id or 0),
|
||
organization_id=int(account.organization_id or 1),
|
||
category_ids=[int(x) for x in (categories or []) if str(x).isdigit()])
|
||
except Exception as exc:
|
||
logger.warning("微信 Bot 知识库检索失败(继续对话):%s", type(exc).__name__)
|
||
hits = []
|
||
for hit in hits:
|
||
excerpt = re.sub(r"\s+", " ", str(hit.get("text") or "")).strip()[:900]
|
||
if excerpt:
|
||
source = str((hit.get("meta") or {}).get("file") or "")[:120]
|
||
kb_context.append(f"[{source}] {excerpt}")
|
||
if kb_context:
|
||
messages.insert(1, {"role": "system", "content":
|
||
"## 本轮知识库参考(仅用于回答当前问题,不能编造未出现的事实)\n" +
|
||
"\n".join(dict.fromkeys(kb_context))[:6000]})
|
||
provider_id = agent.model_provider_id or 0
|
||
model = agent.model_name or ""
|
||
if not provider_id or not model:
|
||
from .ai_assist import _pick_model
|
||
picked = _pick_model(s, int(agent.organization_id or 1))
|
||
if not picked:
|
||
raise HTTPException(502, "没有可用模型")
|
||
provider_id, model = picked
|
||
try:
|
||
bot_user_id = int(owner.id or 0)
|
||
except (TypeError, ValueError):
|
||
bot_user_id = 0
|
||
result = chat(s, messages, provider_id, model, agent_id=agent.id,
|
||
agent_name=f"微信·{agent.name}", user_id=bot_user_id,
|
||
organization_id=int(account.organization_id or 1))
|
||
if result.get("status") != "ok":
|
||
raise HTTPException(502, f"微信 Bot 对话失败:{result.get('error') or result.get('status')}")
|
||
reply = str(result.get("text") or "").strip()
|
||
if not reply:
|
||
raise HTTPException(502, "模型未返回有效回复")
|
||
row.messages = messages + [{"role": "assistant", "content": reply}]
|
||
row.result_meta = {"last_message_id": message_id, "last_reply": reply,
|
||
"model": result.get("model", ""), "trace_id": result.get("trace_id", "")}
|
||
row.status = "completed"; row.turn = int(row.turn or 0) + 1; row.updated_at = now()
|
||
s.add(row); s.commit()
|
||
return {"ok": True, "reply": reply, "conversation_id": conversation,
|
||
"agent_id": agent.id, "agent_name": agent.name,
|
||
"model": result.get("model", ""), "trace_id": result.get("trace_id", "")}
|
||
|
||
|
||
def _dh_manage(s: Session, dhid: int, request: Request):
|
||
user = request_user(request)
|
||
dh = require_human(s, dhid, user, "manage")
|
||
return dh, user
|
||
|
||
|
||
def _dh_persona_result(dh: DigitalHuman) -> dict:
|
||
return {"id": dh.id, "name": dh.name, "desc": dh.desc, "persona": dh.persona,
|
||
"persona_meta": dh.persona_meta or {},
|
||
"version": dh.version, "status": dh.status,
|
||
"persona_preview": persona_prompt(dh.persona, name=dh.name, version=dh.version)}
|
||
|
||
|
||
@app.put("/api/digital-humans/{dhid}")
|
||
def update_digital_human(dhid: int, body: dict, request: Request = None, s: Session = Depends(get_session)):
|
||
source = s.get(DigitalHuman, dhid)
|
||
revision = _global_revision(s, "digital_human", source, body, request) if source else None
|
||
if revision:
|
||
return {"ok": True, "pending": True, "revision_id": revision.id, "version": revision.version}
|
||
dh, _ = _dh_manage(s, dhid, request)
|
||
if "version" in body and body["version"] != dh.version:
|
||
raise HTTPException(409, "数字人已被更新,请刷新后再保存")
|
||
changed = False
|
||
if "persona" in body:
|
||
try:
|
||
patch = normalize_persona(body["persona"], partial=True)
|
||
except ValueError as exc:
|
||
raise HTTPException(422, str(exc)) from exc
|
||
merged = {**(dh.persona or {}), **patch}
|
||
if merged != dh.persona:
|
||
dh.persona = merged
|
||
changed = True
|
||
if "name" in body:
|
||
name = _required_text(body["name"], "数字人名称")[:60]
|
||
changed = changed or name != dh.name
|
||
dh.name = name
|
||
if "desc" in body:
|
||
dh.desc = str(body["desc"] or "")[:300]
|
||
if changed:
|
||
dh.version += 1
|
||
dh.status = "testing"
|
||
s.add(dh); s.commit(); s.refresh(dh)
|
||
_record_fork_revision(s, "digital_human", dhid, request)
|
||
return _dh_persona_result(dh)
|
||
|
||
|
||
@app.post("/api/digital-humans/{dhid}/import-agent-rules")
|
||
def import_digital_human_agent_rules(dhid: int, body: dict, request: Request = None,
|
||
s: Session = Depends(get_session)):
|
||
dh, user = _dh_manage(s, dhid, request)
|
||
try:
|
||
aid = int(body.get("agent_id") or 0)
|
||
except (ValueError, TypeError) as exc:
|
||
raise HTTPException(422, "请选择要导入的 Agent") from exc
|
||
agent = s.get(Agent, aid)
|
||
if not agent or not can_access_agent(s, agent, user, "manage"):
|
||
raise HTTPException(404, "Agent 不存在或没有管理权限")
|
||
if int(agent.organization_id or 1) != int(dh.organization_id or 1):
|
||
raise HTTPException(422, "只能导入同组织 Agent 的配置")
|
||
patch = dict(dh.persona or {})
|
||
def append(key: str, text: str):
|
||
text = str(text or "").strip()
|
||
if text and text not in str(patch.get(key) or ""):
|
||
patch[key] = (str(patch.get(key) or "") + "\n\n" + text).strip()
|
||
append("expression_rules", agent.rules)
|
||
template = s.get(AgentPromptTemplate, agent.prompt_template_id) if agent.prompt_template_id else None
|
||
if template:
|
||
append("expression_prompt", template.style)
|
||
for key, value in (agent.persona_json or {}).items():
|
||
if key in _DIGITAL_HUMAN_LABELS and isinstance(value, str):
|
||
append(key, value)
|
||
return update_digital_human(dhid, {"persona": patch, "version": body.get("version", dh.version)}, request, s)
|
||
|
||
|
||
def _merge_agent_config_into_digital_human(session: Session, agent: Agent,
|
||
digital_human_id: int | None) -> bool:
|
||
"""将 Agent 的历史表达配置并入绑定数字人档案,且保证重复保存不会重复追加。
|
||
|
||
Agent 仍保留 rules 字段以兼容历史任务快照,但绑定数字人后运行时唯一的人格来源是
|
||
数字人蒸馏档案。合并发生在同一事务中,供直接编辑和管理员确认全局版本共同使用。
|
||
"""
|
||
if not digital_human_id:
|
||
return False
|
||
human = session.get(DigitalHuman, int(digital_human_id))
|
||
if not human:
|
||
return False
|
||
persona = dict(human.persona or {})
|
||
changed = False
|
||
|
||
def append(key: str, value: object) -> None:
|
||
nonlocal changed
|
||
text = str(value or "").strip()
|
||
if not text:
|
||
return
|
||
current = str(persona.get(key) or "").strip()
|
||
if text == current or text in current:
|
||
return
|
||
persona[key] = (current + "\n\n" + text).strip() if current else text
|
||
changed = True
|
||
|
||
append("expression_rules", agent.rules)
|
||
template = session.get(AgentPromptTemplate, agent.prompt_template_id) if agent.prompt_template_id else None
|
||
if template:
|
||
append("expression_prompt", template.style)
|
||
for key, value in (agent.persona_json or {}).items():
|
||
if key in _DIGITAL_HUMAN_LABELS and isinstance(value, str):
|
||
append(key, value)
|
||
if changed:
|
||
human.persona = persona
|
||
human.version = int(human.version or 1) + 1
|
||
human.status = "testing"
|
||
session.add(human)
|
||
return changed
|
||
|
||
|
||
@app.get("/api/digital-humans/{dhid}/grants")
|
||
def dh_grants(dhid: int, request: Request = None, s: Session = Depends(get_session)):
|
||
"""数字人授权列表 + 可授权成员(owner/admin)。"""
|
||
from .models import DigitalHuman as DH, DigitalHumanGrant, AppUser
|
||
dh, user = _dh_manage(s, dhid, request)
|
||
grants = s.exec(select(DigitalHumanGrant).where(DigitalHumanGrant.dh_id == dhid)).all()
|
||
users = [item for item in s.exec(select(AppUser).where(AppUser.enabled == True)).all() # noqa: E712
|
||
if _grantable_user(item, user) and item.id != user.id]
|
||
user_ids = {item.id for item in users}
|
||
return {"grants": [{"id": g.id, "user_id": g.user_id, "username": next((u.username for u in users if u.id == g.user_id), str(g.user_id)), "permission": g.permission, "enabled": g.enabled} for g in grants if g.user_id in user_ids],
|
||
"users": [{"id": u.id, "username": u.username, "display_name": u.display_name} for u in users]}
|
||
|
||
|
||
@app.put("/api/digital-humans/{dhid}/visibility")
|
||
def dh_set_visibility(dhid: int, body: dict, request: Request = None, s: Session = Depends(get_session)):
|
||
from .models import DigitalHuman as DH
|
||
dh, _ = _dh_manage(s, dhid, request)
|
||
v = str(body.get("visibility") or "")
|
||
if v not in ("private", "shared", "global"):
|
||
raise HTTPException(422, "visibility 必须是 private|shared|global")
|
||
dh.visibility = v
|
||
s.add(dh); s.commit()
|
||
return {"ok": True, "visibility": v}
|
||
|
||
|
||
@app.post("/api/digital-humans/{dhid}/grants")
|
||
def dh_add_grant(dhid: int, body: dict, request: Request = None, s: Session = Depends(get_session)):
|
||
from .models import DigitalHuman as DH, DigitalHumanGrant
|
||
dh, user = _dh_manage(s, dhid, request)
|
||
uid = int(body.get("user_id") or 0)
|
||
perm = str(body.get("permission") or "use")
|
||
if perm not in ("read", "use"):
|
||
raise HTTPException(422, "permission 必须是 read|use")
|
||
if not uid:
|
||
raise HTTPException(422, "请选择成员")
|
||
target_user = s.get(AppUser, uid)
|
||
if not _grantable_user(target_user, user):
|
||
raise HTTPException(404, "用户不存在或不可授权")
|
||
existing = s.exec(select(DigitalHumanGrant).where(DigitalHumanGrant.dh_id == dhid, DigitalHumanGrant.user_id == uid)).first()
|
||
if existing:
|
||
existing.permission = perm
|
||
existing.enabled = True
|
||
s.add(existing)
|
||
else:
|
||
s.add(DigitalHumanGrant(dh_id=dhid, user_id=uid, permission=perm))
|
||
s.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.delete("/api/digital-humans/{dhid}/grants/{grant_id}")
|
||
def dh_del_grant(dhid: int, grant_id: int, request: Request = None, s: Session = Depends(get_session)):
|
||
from .models import DigitalHuman as DH, DigitalHumanGrant
|
||
_dh_manage(s, dhid, request)
|
||
g = s.get(DigitalHumanGrant, grant_id)
|
||
if g and g.dh_id == dhid:
|
||
s.delete(g); s.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/digital-humans/{dhid}/approve")
|
||
def approve_digital_human(dhid: int, request: Request = None, s: Session = Depends(get_session)):
|
||
"""人工测试审核通过 → 数字人生效,可被 Agent 绑定。"""
|
||
from .models import DigitalHuman as DH
|
||
dh, _owner = _dh_manage(s, dhid, request)
|
||
if not dh:
|
||
raise HTTPException(404, "数字人不存在")
|
||
if not isinstance(dh.persona, dict) or not any(str(v or "").strip() for v in dh.persona.values()):
|
||
raise HTTPException(422, "请先蒸馏或填写人格档案,再确认生效")
|
||
dh.status = "active"
|
||
s.add(dh); s.commit()
|
||
return {"ok": True, "status": "active", "version": dh.version}
|
||
|
||
|
||
@app.get("/api/digital-humans/{dhid}")
|
||
def get_digital_human(dhid: int, request: Request = None, s: Session = Depends(get_session)):
|
||
from .models import DigitalHuman as DH
|
||
user = request_user(request)
|
||
dh = require_human(s, dhid, user, "read")
|
||
bound = [a for a in s.exec(select(Agent).where(Agent.digital_human_id == dhid)).all()
|
||
if can_access_agent(s, a, user, "read")]
|
||
return {"id": dh.id, "name": dh.name, "desc": dh.desc, "status": dh.status,
|
||
"version": dh.version, "persona": dh.persona, "persona_meta": dh.persona_meta or {}, "source_files": _unique_source_names(dh.source_files or []),
|
||
"persona_preview": persona_prompt(dh.persona, name=dh.name, version=dh.version),
|
||
"can_manage": human_access(s, dh, user, "manage"),
|
||
"can_use": dh.status == "active" and human_access(s, dh, user, "use"),
|
||
"last_feedback": dh.last_feedback, "created_at": dh.created_at,
|
||
"visibility": dh.visibility, **owner_fields(s, dh.owner_user_id),
|
||
"bound_agents": [{"id": a.id, "name": a.name,
|
||
"digital_human_id": dh.id,
|
||
"persona_version": dh.version,
|
||
"transport": "runtime_snapshot.digital_human.persona",
|
||
"transport_note": "绑定后在任务启动时冻结版本并注入 Agent 运行上下文"} for a in bound]}
|
||
|
||
|
||
@app.delete("/api/digital-humans/{dhid}")
|
||
def delete_digital_human(dhid: int, request: Request = None, s: Session = Depends(get_session)):
|
||
from .models import DigitalHuman as DH
|
||
source = s.get(DH, dhid)
|
||
revision = _global_revision(s, "digital_human", source, {}, request, "delete") if source else None
|
||
if revision:
|
||
return {"ok": True, "pending": True, "revision_id": revision.id}
|
||
_dh_manage(s, dhid, request) # 仅所有者/管理员可删
|
||
dh = s.get(DH, dhid)
|
||
if not dh:
|
||
raise HTTPException(404, "数字人不存在")
|
||
# 一键解除所有 Agent 和成员授权后删除数字人。
|
||
_detach_digital_human_bindings(s, dhid)
|
||
_cleanup_fork(s, "digital_human", dhid)
|
||
s.delete(dh); s.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/agents/analyze-persona-upload")
|
||
async def analyze_persona_upload(files: list[UploadFile] = File(...), request: Request = None,
|
||
s: Session = Depends(get_session)):
|
||
"""数字人分析·文档版:上传 docx/pdf/txt/md/srt,服务端抽取文本后走同一分析管线。"""
|
||
import io
|
||
from .ai_assist import _pick_model
|
||
from .llm import chat as llm_chat
|
||
|
||
allowed = {".txt", ".md", ".srt", ".docx", ".pdf", ".doc"}
|
||
samples: list[str] = []
|
||
names: list[str] = []
|
||
for f in files:
|
||
safe = Path(f.filename or "file").name
|
||
suffix = Path(safe).suffix.lower()
|
||
if suffix not in allowed:
|
||
raise HTTPException(415, f"不支持的文件类型:{safe}(支持 docx/pdf/txt/md/srt)")
|
||
_limit = _upload_limit_bytes(request)
|
||
raw = await f.read(_limit + 1)
|
||
if len(raw) > _limit:
|
||
raise HTTPException(413, f"文件过大:{safe}(当前通道限 {_limit // (1024*1024)}MB)")
|
||
text = ""
|
||
if suffix == ".doc":
|
||
tmp_doc = Path(KB_DIR / "uploads") / f"ap_{uuid.uuid4().hex[:8]}.doc"
|
||
tmp_doc.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp_doc.write_bytes(raw)
|
||
try:
|
||
docx_path = _convert_doc_to_docx(tmp_doc)
|
||
raw = docx_path.read_bytes()
|
||
suffix = ".docx"
|
||
finally:
|
||
tmp_doc.unlink(missing_ok=True)
|
||
docx_path.unlink(missing_ok=True)
|
||
if suffix == ".docx":
|
||
try:
|
||
import docx as _docx
|
||
doc = _docx.Document(io.BytesIO(raw))
|
||
parts = [para.text for para in doc.paragraphs if para.text and para.text.strip()]
|
||
# 表格里的文字也要(聊天记录常贴在表格里)
|
||
for table in doc.tables:
|
||
for row in table.rows:
|
||
for cell in row.cells:
|
||
t = (cell.text or "").strip()
|
||
if t:
|
||
parts.append(t)
|
||
text = "\n".join(parts)
|
||
except Exception as exc:
|
||
raise HTTPException(422, f"解析 docx 失败:{safe}({exc})")
|
||
elif suffix == ".pdf":
|
||
try:
|
||
try:
|
||
import pypdf as _pdf
|
||
except ImportError:
|
||
import fitz as _fitx
|
||
doc = _fitx.open(stream=raw, filetype="pdf")
|
||
text = "\n".join(page.get_text() for page in doc)
|
||
doc.close()
|
||
else:
|
||
reader = _pdf.PdfReader(io.BytesIO(raw))
|
||
text = "\n".join((page.extract_text() or "") for page in reader.pages)
|
||
except Exception as exc:
|
||
raise HTTPException(422, f"解析 pdf 失败:{safe}({exc})")
|
||
else:
|
||
for enc in ("utf-8", "gb18030", "utf-16"):
|
||
try:
|
||
text = raw.decode(enc)
|
||
break
|
||
except (UnicodeDecodeError, UnicodeError):
|
||
continue
|
||
if not text:
|
||
raise HTTPException(422, f"无法识别文件编码:{safe}")
|
||
text = text.strip()
|
||
if text:
|
||
samples.append(_representative_sample(text, 8000))
|
||
names.append(safe)
|
||
if not samples:
|
||
raise HTTPException(422, "上传的文件里没有可分析的文本(扫描版 PDF 无文字层,请改用 docx 或文字版)")
|
||
organization_id, user_id = actor_fields(request)
|
||
# 文档分析会调用模型,放到线程池避免阻塞事件循环。
|
||
persona = await asyncio.to_thread(_dh_distill, s, organization_id, user_id, samples, names)
|
||
return {"persona": persona, "persona_meta": _distill_meta(samples, names), "sources": names}
|
||
|
||
|
||
@app.post("/api/agents/analyze-persona")
|
||
def analyze_persona(body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
"""数字人分析:上传本人文本(直播稿/聊天记录/笔记)→ AI 输出十一维人格档案 JSON。
|
||
不落库,前端人工核对后一键填入。"""
|
||
organization_id, user_id = actor_fields(request)
|
||
texts = body.get("texts")
|
||
samples: list[str] = []
|
||
if isinstance(texts, list):
|
||
samples = [_representative_sample(str(t or ""), 6000) for t in texts if str(t or "").strip()]
|
||
single = str(body.get("text") or "").strip()
|
||
if single:
|
||
samples.insert(0, _representative_sample(single, 8000))
|
||
samples = [t for t in samples if t]
|
||
if not samples:
|
||
raise HTTPException(422, "请先上传或粘贴这个人的直播稿、聊天记录等文本")
|
||
from .llm import chat as llm_chat
|
||
from .ai_assist import _pick_model
|
||
persona = _dh_distill(s, organization_id, user_id, samples, ["粘贴文本"])
|
||
return {"persona": persona, "persona_meta": _distill_meta(samples, ["粘贴文本"])}
|
||
|
||
|
||
@app.post("/api/agents/{aid}/test-run")
|
||
def agent_test_run(aid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
"""单节点试运行(借鉴 Coze Test Run):给一段测试文本,返回该 Agent 的生成结果预览。
|
||
不建段、不落库、不计入任务。"""
|
||
a = s.get(Agent, aid)
|
||
if not a or not can_access_agent(s, a, request_user(request), "read"):
|
||
raise HTTPException(404, "Agent 不存在")
|
||
sample = str(body.get("sample") or "").strip() or "开场:欢迎姐妹们来到今晚的直播,今天我们讲盆底肌训练。"
|
||
if a.role != "production":
|
||
raise HTTPException(422, "只有生产 Agent 支持试运行")
|
||
if not (a.kb_ids or []):
|
||
raise HTTPException(422, "该 Agent 未挂接知识库,先到 Agent 详情挂接")
|
||
from .llm import chat as llm_chat
|
||
provider_id = a.model_provider_id or 0
|
||
model = a.model_name or ""
|
||
if not provider_id or not model:
|
||
from .ai_assist import _pick_model
|
||
try:
|
||
picked = _pick_model(s, int(a.organization_id or 1))
|
||
except TypeError: # 兼容旧插件的一参数选择器
|
||
picked = _pick_model(s)
|
||
if not picked:
|
||
raise HTTPException(502, "没有可用模型")
|
||
provider_id, model = picked
|
||
persona_text = runtime_persona(s, a, user=request_user(request))
|
||
messages = [
|
||
{"role": "system", "content": (a.rules or "你是直播内容创作助手,用大白话单段流写作。")[:4000] + "\n\n" + persona_text},
|
||
{"role": "user", "content": f"【试运行测试导图】\n{sample[:1200]}\n\n请按你的职责生成一段内容(试运行,直接输出正文)。"},
|
||
]
|
||
_user = request_user(request)
|
||
_uid = int(_user.id or 0) if _user else 0
|
||
result = llm_chat(s, messages, provider_id, model, agent_name=f"{a.name}·试运行",
|
||
user_id=_uid, organization_id=a.organization_id)
|
||
if result.get("status") != "ok":
|
||
raise HTTPException(502, f"试运行失败:{result.get('error') or result.get('status')}")
|
||
return {"text": (result.get("text") or "").strip(), "model": result.get("model", ""),
|
||
"duration_ms": result.get("duration_ms", 0),
|
||
"tokens": (result.get("tokens") or {}) if isinstance(result.get("tokens"), dict) else {}}
|
||
|
||
|
||
@app.post("/api/ai-assist/mindmap")
|
||
def ai_assist_mindmap(body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
"""任务创建助手:目标 + 可选素材/模板导图/人工建议 → AI 产出导图文本。
|
||
不落库,前端人工审核编辑后再建任务。advice + previous 为重生成通道。"""
|
||
organization_id, user_id = actor_fields(request)
|
||
objective = str(body.get("objective") or "").strip()
|
||
advice = str(body.get("advice") or "").strip()
|
||
previous = str(body.get("previous") or "").strip()
|
||
material = str(body.get("material") or "").strip()
|
||
duration = int(body.get("duration_minutes") or 0)
|
||
if not objective and not previous:
|
||
raise HTTPException(422, "请先填写任务目标")
|
||
from .llm import chat as llm_chat
|
||
from .ai_assist import _pick_model
|
||
try:
|
||
model = _pick_model(s, organization_id)
|
||
except TypeError:
|
||
model = _pick_model(s)
|
||
if not model:
|
||
raise HTTPException(502, "还没有可用的模型,请先到「模型管理」添加 Provider")
|
||
provider_id, model_name = model
|
||
rules = [
|
||
"你是雅文老师(女性性商教育主播)的直播内容策划助理。",
|
||
"输出一份直播导图:每行一个节点,格式「标签 时长:主题」,标签从 开场/心法/技法/功法/情欲内容/产品销售/钩子/收尾/互动 中选。",
|
||
"节点按直播时间顺序排列;总时长要覆盖指定时长(如未指定按 90 分钟)。",
|
||
"内容要具体到可执行的动作和话题,不要空话;符合雅文老师大胆直白、大白话授课的风格。",
|
||
"只输出导图行,不要解释、不要 markdown 标题。",
|
||
]
|
||
if advice:
|
||
rules.append(f"人工修改意见(最高优先级,必须落实):{advice}")
|
||
if previous:
|
||
rules.append("上一版导图如下,按意见修改后输出完整新版:\n" + previous[:4000])
|
||
if material:
|
||
rules.append("参考素材(可提炼节点):\n" + material[:6000])
|
||
prompt = f"直播目标:{objective or '(按上一版与意见重做)'}\n直播总时长:{duration or 90} 分钟\n请输出导图。"
|
||
messages = [{"role": "system", "content": "\n".join(rules)},
|
||
{"role": "user", "content": prompt}]
|
||
try:
|
||
result = llm_chat(s, messages, provider_id, model_name,
|
||
agent_name="AI 导图助手", user_id=user_id, organization_id=organization_id)
|
||
except Exception as exc:
|
||
raise HTTPException(502, f"AI 生成导图失败:{exc}") from exc
|
||
text = result.get("text") if isinstance(result, dict) else str(result)
|
||
if result.get("status") not in (None, "ok"):
|
||
raise HTTPException(502, f"AI 生成导图失败:{result.get('error') or result.get('status')}")
|
||
return {"mindmap": (text or "").strip(), "model": result.get("model", "")}
|
||
|
||
|
||
@app.post("/api/ai-assist/draft")
|
||
def ai_assist_draft(body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
"""小白友好:大白话描述 → 结构化创建草稿。AI 只出草稿,用户确认后才落库。"""
|
||
organization_id, user_id = actor_fields(request)
|
||
advice = str(body.get("advice") or "").strip()
|
||
previous = body.get("previous_draft") or {}
|
||
try:
|
||
draft = ai_draft(s, str(body.get("kind") or ""), str(body.get("description") or ""),
|
||
user_id=user_id, organization_id=organization_id,
|
||
advice=advice, previous=previous if isinstance(previous, dict) else None)
|
||
except ValueError as exc:
|
||
raise HTTPException(422, str(exc)) from exc
|
||
if advice:
|
||
draft["_applied_advice"] = advice
|
||
# 草稿顺手过一遍冲突检测,用户在确认前就知道会不会打架
|
||
scan_kind = {"agent": "agent", "skill": "skill", "template": "template"}.get(str(body.get("kind") or ""))
|
||
scan_payload = dict(draft)
|
||
if scan_kind == "agent":
|
||
scan_payload["rules"] = draft.get("rules") or []
|
||
if scan_kind == "skill":
|
||
scan_payload["instructions"] = draft.get("instructions") or []
|
||
scan = conflict_check(s, scan_kind, scan_payload) if scan_kind else {"conflicts": [], "warnings": []}
|
||
return {**draft, "conflicts": scan["conflicts"], "warnings": scan["warnings"]}
|
||
|
||
|
||
@app.post("/api/ai-assist/conflicts")
|
||
def ai_assist_conflicts(body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
"""创建/保存前的冲突检测:Agent × 技能 × 模板 重复名/互斥表述。"""
|
||
kind = str(body.get("kind") or "")
|
||
payload = body.get("payload") or {}
|
||
exclude_id = int(body.get("exclude_id") or 0)
|
||
if kind not in {"agent", "skill", "template"}:
|
||
raise HTTPException(422, "不支持的资源类型")
|
||
return conflict_check(s, kind, payload, exclude_id)
|
||
|
||
|
||
@app.post("/api/agents")
|
||
def create_agent(agent: dict, s: Session = Depends(get_session), request: Request = None):
|
||
name = _required_text(agent.get("name"), "Agent 名称")
|
||
content_tags = list(dict.fromkeys(agent.get("content_tags") or []))
|
||
kb_ids = list(dict.fromkeys(agent.get("kb_ids") or []))
|
||
kb_category_ids = _validate_agent_category_mapping(s, kb_ids, agent.get("kb_category_ids") or {})
|
||
skill_ids = list(dict.fromkeys(agent.get("skill_ids") or []))
|
||
allowed_tools = _allowed_tools(agent.get("allowed_tools"))
|
||
provider_id = agent.get("model_provider_id")
|
||
model_catalog_id = agent.get("model_catalog_id")
|
||
if model_catalog_id is not None:
|
||
try:
|
||
catalog, catalog_provider = resolve_catalog_model(s, int(model_catalog_id))
|
||
except (ValueError, TypeError) as exc:
|
||
raise HTTPException(422, str(exc)) from exc
|
||
provider_id = catalog_provider.id
|
||
agent["model_name"] = catalog.model_name
|
||
model_catalog_id = catalog.id
|
||
prompt_template_id = agent.get("prompt_template_id")
|
||
if prompt_template_id is None:
|
||
prompt_template_id = _default_prompt_template_id(s, request)
|
||
if prompt_template_id is not None:
|
||
template = s.get(AgentPromptTemplate, prompt_template_id)
|
||
if not template or not template.enabled or not can_access(template, request_user(request), "read"):
|
||
raise HTTPException(422, "提示模板不存在或已停用")
|
||
_validate_agent_refs(s, content_tags, kb_ids, provider_id, skill_ids, request)
|
||
for kb_id in kb_ids:
|
||
require_kb(s, int(kb_id), request, "use")
|
||
_validate_agent_model(s, provider_id, str(agent.get("model_name") or ""))
|
||
_validate_user_model_selection(s, provider_id, str(agent.get("model_name") or ""),
|
||
model_catalog_id, request)
|
||
if model_catalog_id is None:
|
||
model_catalog_id = _catalog_id_for_model(s, provider_id, str(agent.get("model_name") or ""))
|
||
role = agent.get("role", "production")
|
||
fusion_agent_ids = _fusion_agent_ids(s, agent.get("fusion_agent_ids", []), role,
|
||
user=request_user(request))
|
||
if (role == "production"
|
||
and agent.get("status", "active") == "active" and not kb_ids):
|
||
raise HTTPException(422, "启用内容生产 Agent 前必须挂接至少一个知识库")
|
||
organization_id, owner_user_id = actor_fields(request)
|
||
digital_human_id = binding_id(s, agent.get("digital_human_id"), request_user(request))
|
||
a = Agent(name=name, role=role,
|
||
desc=str(agent.get("desc") or ""), content_tags=content_tags,
|
||
kb_ids=kb_ids, kb_category_ids=kb_category_ids, fusion_agent_ids=fusion_agent_ids, model_provider_id=provider_id,
|
||
model_name=str(agent.get("model_name") or ""),
|
||
prompt_template_id=prompt_template_id,
|
||
model_catalog_id=model_catalog_id,
|
||
allowed_tools=allowed_tools,
|
||
organization_id=organization_id, owner_user_id=owner_user_id,
|
||
visibility=visibility_for_create(request, agent.get("visibility", "private")),
|
||
status=agent.get("status", "active"),
|
||
rules=agent.get("rules") or DEFAULT_NEW_AGENT_RULES,
|
||
digital_human_id=digital_human_id)
|
||
s.add(a); s.commit(); s.refresh(a)
|
||
_sync_agent_skills(s, a.id, skill_ids)
|
||
s.commit()
|
||
return {"id": a.id}
|
||
|
||
@app.get("/api/agents/{aid}")
|
||
def get_agent(aid: int, s: Session = Depends(get_session), request: Request = None):
|
||
a = s.get(Agent, aid)
|
||
if not a or not can_access_agent(s, a, request_user(request), "read"):
|
||
raise HTTPException(404)
|
||
return {"id": a.id, "name": a.name, "role": a.role, "desc": a.desc,
|
||
"content_tags": a.content_tags, "kb_ids": a.kb_ids,
|
||
"kb_category_ids": a.kb_category_ids or {},
|
||
"fusion_agent_ids": a.fusion_agent_ids or [],
|
||
"allowed_tools": _allowed_tools(a.allowed_tools),
|
||
"fusion_agent_names": [target.name for target in (
|
||
s.get(Agent, fusion_id) for fusion_id in (a.fusion_agent_ids or [])
|
||
) if target],
|
||
"skill_ids": agent_skill_ids(s, a.id),
|
||
"model_provider_id": a.model_provider_id, "model_name": a.model_name,
|
||
"model_catalog_id": a.model_catalog_id,
|
||
"organization_id": a.organization_id, **owner_fields(s, a.owner_user_id),
|
||
"visibility": a.visibility,
|
||
"prompt_template_id": a.prompt_template_id,
|
||
"prompt_template_name": (s.get(AgentPromptTemplate, a.prompt_template_id).name
|
||
if a.prompt_template_id and s.get(AgentPromptTemplate, a.prompt_template_id) else "默认模板"),
|
||
"rules": a.rules, "rules_version": a.rules_version, "status": a.status,
|
||
"builtin": a.builtin,
|
||
"persona": a.persona_json or {},
|
||
"digital_human_id": a.digital_human_id,
|
||
"digital_human_name": (s.get(DigitalHuman, a.digital_human_id).name if a.digital_human_id and s.get(DigitalHuman, a.digital_human_id) else ""),
|
||
"runtime_snapshot": a.runtime_snapshot or {}}
|
||
|
||
@app.put("/api/agents/{aid}")
|
||
def update_agent(aid: int, agent: dict, s: Session = Depends(get_session), request: Request = None):
|
||
a = s.get(Agent, aid)
|
||
revision = _global_revision(s, "agent", a, agent, request) if a else None
|
||
if revision:
|
||
return {"ok": True, "pending": True, "revision_id": revision.id, "version": revision.version}
|
||
if not a or not can_access_agent(s, a, request_user(request), "manage"):
|
||
raise HTTPException(404)
|
||
content_tags = agent.get("content_tags", a.content_tags or [])
|
||
kb_ids = agent.get("kb_ids", a.kb_ids or [])
|
||
kb_category_ids = _validate_agent_category_mapping(s, kb_ids, agent.get("kb_category_ids", a.kb_category_ids or {}))
|
||
skill_ids = list(dict.fromkeys(agent.get("skill_ids", agent_skill_ids(s, a.id)) or []))
|
||
allowed_tools = _allowed_tools(agent.get("allowed_tools", a.allowed_tools))
|
||
provider_id = agent.get("model_provider_id", a.model_provider_id)
|
||
model_catalog_id = agent.get("model_catalog_id", a.model_catalog_id)
|
||
if model_catalog_id is not None:
|
||
try:
|
||
catalog, catalog_provider = resolve_catalog_model(s, int(model_catalog_id))
|
||
except (ValueError, TypeError) as exc:
|
||
raise HTTPException(422, str(exc)) from exc
|
||
provider_id = catalog_provider.id
|
||
agent["model_name"] = catalog.model_name
|
||
model_catalog_id = catalog.id
|
||
prompt_template_id = agent.get("prompt_template_id", a.prompt_template_id)
|
||
if prompt_template_id is None:
|
||
prompt_template_id = _default_prompt_template_id(s, request)
|
||
if prompt_template_id is not None:
|
||
template = s.get(AgentPromptTemplate, prompt_template_id)
|
||
if not template or not template.enabled or not can_access(template, request_user(request), "read"):
|
||
raise HTTPException(422, "提示模板不存在或已停用")
|
||
_validate_agent_refs(s, content_tags, kb_ids, provider_id, skill_ids, request)
|
||
for kb_id in kb_ids:
|
||
require_kb(s, int(kb_id), request, "use")
|
||
_validate_agent_model(s, provider_id, str(agent.get("model_name", a.model_name) or ""))
|
||
_validate_user_model_selection(s, provider_id, str(agent.get("model_name", a.model_name) or ""),
|
||
model_catalog_id, request)
|
||
if model_catalog_id is None:
|
||
model_catalog_id = _catalog_id_for_model(s, provider_id, str(agent.get("model_name", a.model_name) or ""))
|
||
role = agent.get("role", a.role)
|
||
status = agent.get("status", a.status)
|
||
fusion_agent_ids = _fusion_agent_ids(s, agent.get("fusion_agent_ids", a.fusion_agent_ids or []), role, a.id,
|
||
request_user(request))
|
||
if role == "production" and status == "active" and not kb_ids:
|
||
raise HTTPException(422, "启用内容生产 Agent 前必须挂接至少一个知识库")
|
||
if "digital_human_id" in agent:
|
||
agent["digital_human_id"] = binding_id(s, agent["digital_human_id"], request_user(request))
|
||
elif a.digital_human_id:
|
||
binding_id(s, a.digital_human_id, request_user(request))
|
||
if "name" in agent:
|
||
agent["name"] = _required_text(agent.get("name"), "Agent 名称")
|
||
if "visibility" in agent:
|
||
agent["visibility"] = visibility_for_create(request, agent.get("visibility"))
|
||
agent["fusion_agent_ids"] = fusion_agent_ids
|
||
agent["kb_category_ids"] = kb_category_ids
|
||
for k in ("name", "role", "desc", "content_tags", "kb_ids", "kb_category_ids", "fusion_agent_ids", "allowed_tools", "model_provider_id", "model_name",
|
||
"prompt_template_id", "model_catalog_id", "visibility",
|
||
"status", "rules", "persona_json", "digital_human_id"):
|
||
if k in agent:
|
||
if k == "rules" and agent[k] != a.rules:
|
||
a.rules_version += 1
|
||
if k == "persona_json":
|
||
v = agent[k]
|
||
if isinstance(v, dict):
|
||
a.persona_json = {str(kk)[:40]: str(vv)[:4000] for kk, vv in v.items()}
|
||
continue
|
||
if k == "digital_human_id":
|
||
v = agent[k]
|
||
a.digital_human_id = int(v) if v else None
|
||
continue
|
||
setattr(a, k, agent[k])
|
||
# 数字人是绑定 Agent 的唯一人格来源。保存绑定或更新 Agent 旧规则时,
|
||
# 自动将历史表达配置并入数字人蒸馏档案,后续需要重新测试后才会再次生效。
|
||
_merge_agent_config_into_digital_human(s, a, a.digital_human_id)
|
||
s.add(a); s.commit()
|
||
_sync_agent_skills(s, a.id, skill_ids)
|
||
s.commit()
|
||
_record_fork_revision(s, "agent", a.id, request)
|
||
return {"ok": True}
|
||
|
||
@app.post("/api/agents/{aid}/publish")
|
||
def publish_agent(aid: int, s: Session = Depends(get_session), request: Request = None):
|
||
"""发布 = 冻结当前配置为运行快照(借鉴 Coze 草稿/发布分离)。
|
||
在跑任务与已建任务优先用快照,之后的编辑不再影响它们。"""
|
||
import datetime as _dt
|
||
a = s.get(Agent, aid)
|
||
if not a or not can_access_agent(s, a, request_user(request), "manage"):
|
||
raise HTTPException(404, "Agent 不存在")
|
||
provider = s.get(ModelProvider, a.model_provider_id) if a.model_provider_id else None
|
||
if a.model_provider_id and (not provider or not provider.enabled):
|
||
raise HTTPException(422, "模型 Provider 已停用,无法发布")
|
||
snapshot = {
|
||
"primary": freeze_agent_snapshot(s, a, user=request_user(request)),
|
||
"published_at": _dt.datetime.now().isoformat(timespec="seconds"),
|
||
"published_version": (a.rules_version or 1),
|
||
}
|
||
a.runtime_snapshot = snapshot
|
||
s.add(a); s.commit()
|
||
return {"ok": True, "version": snapshot["published_version"],
|
||
"message": f"已发布 v{snapshot['published_version']},在跑任务不受后续编辑影响"}
|
||
|
||
|
||
@app.delete("/api/agents/{aid}")
|
||
def delete_agent(aid: int, s: Session = Depends(get_session), request: Request = None):
|
||
agent = s.get(Agent, aid)
|
||
revision = _global_revision(s, "agent", agent, {}, request, "delete") if agent else None
|
||
if revision:
|
||
return {"ok": True, "pending": True, "revision_id": revision.id}
|
||
if not agent or not can_access_agent(s, agent, request_user(request), "manage"):
|
||
raise HTTPException(404, "Agent 不存在")
|
||
if s.exec(select(Segment).where(Segment.agent_id == aid)).first():
|
||
raise HTTPException(409, "Agent 已产生历史任务,不能删除,可以停用")
|
||
# 删除前自动解除融合、画布、技能、知识库消费、记忆和授权关系。
|
||
_detach_agent_bindings(s, aid)
|
||
_cleanup_fork(s, "agent", aid)
|
||
s.delete(agent); s.commit()
|
||
return {"ok": True}
|
||
|
||
# ---------- 技能库 ----------
|
||
@app.get("/api/skills")
|
||
def list_skills(s: Session = Depends(get_session), request: Request = None):
|
||
user = request_user(request)
|
||
return [{"id": skill.id, "name": skill.name, "desc": skill.desc,
|
||
"instructions": skill.instructions, "enabled": skill.enabled,
|
||
"builtin": skill.builtin, "version": skill.version, **owner_fields(s, skill.owner_user_id),
|
||
"visibility": skill.visibility,
|
||
"grants": [{"id": grant.id, "user_id": grant.user_id,
|
||
"username": (s.get(AppUser, grant.user_id).username if s.get(AppUser, grant.user_id) else f"用户#{grant.user_id}"),
|
||
"display_name": (s.get(AppUser, grant.user_id).display_name if s.get(AppUser, grant.user_id) else ""),
|
||
"permission": grant.permission, "enabled": grant.enabled, "created_at": grant.created_at}
|
||
for grant in s.exec(select(AgentSkillGrant).where(AgentSkillGrant.skill_id == skill.id)).all()
|
||
if _grantable_user(s.get(AppUser, grant.user_id), user)],
|
||
"agent_count": len(s.exec(select(AgentSkillBinding).where(
|
||
AgentSkillBinding.skill_id == skill.id)).all()),
|
||
"agent_ids": [b.agent_id for b in s.exec(select(AgentSkillBinding).where(AgentSkillBinding.skill_id == skill.id)).all()],
|
||
"agent_names": [a.name for a in (s.get(Agent, b.agent_id) for b in s.exec(select(AgentSkillBinding).where(AgentSkillBinding.skill_id == skill.id)).all()) if a]}
|
||
for skill in s.exec(select(AgentSkill).order_by(AgentSkill.id)).all()
|
||
if not _hidden_for_user(s, "skill", int(skill.id or 0), request) and can_access(skill, user, "read",
|
||
(skill_grant(s, int(skill.id or 0), user).permission
|
||
if skill.visibility == "shared" and skill_grant(s, int(skill.id or 0), user) else None))]
|
||
|
||
@app.post("/api/skills")
|
||
def create_skill(body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
name = _required_text(body.get("name"), "技能名称")
|
||
if s.exec(select(AgentSkill).where(AgentSkill.name == name)).first():
|
||
raise HTTPException(409, "技能名称已存在")
|
||
organization_id, owner_user_id = actor_fields(request)
|
||
skill = AgentSkill(name=name, desc=str(body.get("desc") or ""),
|
||
instructions=_required_text(body.get("instructions"), "技能说明"),
|
||
enabled=bool(body.get("enabled", True)), organization_id=organization_id,
|
||
owner_user_id=owner_user_id,
|
||
visibility=visibility_for_create(request, body.get("visibility", "private")))
|
||
s.add(skill); s.commit(); s.refresh(skill)
|
||
if "agent_ids" in body:
|
||
_sync_skill_agents(s, skill.id, body.get("agent_ids") or [], request)
|
||
s.commit()
|
||
return {"id": skill.id}
|
||
|
||
@app.post("/api/skills/import")
|
||
async def import_skill_markdown(file: UploadFile | None = File(None),
|
||
files: list[UploadFile] | None = File(None),
|
||
s: Session = Depends(get_session), request: Request = None):
|
||
"""从 Markdown 文件创建技能。
|
||
|
||
支持可选的 YAML front matter(name/title、desc/description),没有 front
|
||
matter 时使用第一个一级标题作为名称,文件名作为兜底。正文会原样保存为
|
||
技能说明,方便把已有的 rules.md/技能文档直接带入 Agent 工作区。
|
||
"""
|
||
# Older clients used the plural ``files`` field (the shared upload helper),
|
||
# while the first implementation expected ``file``. Accept both so a
|
||
# Markdown import never fails because of a multipart field-name mismatch.
|
||
upload = file or (files[0] if files else None)
|
||
if upload is None:
|
||
raise HTTPException(422, "请选择要导入的 Markdown 文件")
|
||
filename = str(upload.filename or "技能.md")
|
||
if not filename.lower().endswith((".md", ".markdown")):
|
||
raise HTTPException(415, "只支持导入 Markdown 文件(.md)")
|
||
payload = await upload.read()
|
||
if not payload:
|
||
raise HTTPException(422, "Markdown 文件不能为空")
|
||
try:
|
||
text = payload.decode("utf-8-sig")
|
||
except UnicodeDecodeError:
|
||
raise HTTPException(422, "Markdown 文件必须使用 UTF-8 编码")
|
||
text = text.replace("\r\n", "\n").replace("\r", "\n").strip()
|
||
metadata: dict[str, str] = {}
|
||
body_text = text
|
||
if text.startswith("---\n"):
|
||
end = text.find("\n---", 4)
|
||
if end >= 0:
|
||
header = text[4:end]
|
||
body_text = text[end + 4:].lstrip("\n")
|
||
for line in header.splitlines():
|
||
key, sep, value = line.partition(":")
|
||
if sep and key.strip().lower() in {"name", "title", "desc", "description"}:
|
||
metadata[key.strip().lower()] = value.strip().strip("'\"")
|
||
heading = re.search(r"^\s*#\s+(.+?)\s*$", body_text, re.MULTILINE)
|
||
name = (metadata.get("name") or metadata.get("title") or (heading.group(1).strip() if heading else ""))
|
||
if not name:
|
||
name = Path(filename).stem.strip() or "导入技能"
|
||
# 技能说明保留完整 Markdown,去掉标题只会损失原文结构;标题也有助于阅读。
|
||
instructions = _required_text(body_text, "技能说明")
|
||
desc = metadata.get("desc") or metadata.get("description") or "从 Markdown 文件导入"
|
||
if s.exec(select(AgentSkill).where(AgentSkill.name == name)).first():
|
||
raise HTTPException(409, f"技能名称已存在:{name}")
|
||
organization_id, owner_user_id = actor_fields(request)
|
||
skill = AgentSkill(name=name[:200], desc=desc[:500], instructions=instructions,
|
||
enabled=True, organization_id=organization_id,
|
||
owner_user_id=owner_user_id,
|
||
visibility=visibility_for_create(request, "private"))
|
||
s.add(skill); s.commit(); s.refresh(skill)
|
||
return {"id": skill.id, "name": skill.name, "desc": skill.desc}
|
||
|
||
@app.get("/api/skills/{sid}")
|
||
def get_skill(sid: int, s: Session = Depends(get_session), request: Request = None):
|
||
skill = require_skill(s, sid, request, "read")
|
||
bindings = s.exec(select(AgentSkillBinding).where(AgentSkillBinding.skill_id == sid)).all()
|
||
return {"id": skill.id, "name": skill.name, "desc": skill.desc,
|
||
"instructions": skill.instructions, "enabled": skill.enabled,
|
||
"builtin": skill.builtin, "version": skill.version, "visibility": skill.visibility,
|
||
"agent_ids": [b.agent_id for b in bindings],
|
||
"agent_names": [a.name for a in (s.get(Agent, b.agent_id) for b in bindings) if a]}
|
||
|
||
@app.put("/api/skills/{sid}")
|
||
def update_skill(sid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
skill = s.get(AgentSkill, sid)
|
||
revision = _global_revision(s, "skill", skill, body, request) if skill else None
|
||
if revision:
|
||
return {"ok": True, "pending": True, "revision_id": revision.id, "version": revision.version}
|
||
if not skill or not can_access(skill, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "技能不存在")
|
||
if "name" in body:
|
||
name = _required_text(body.get("name"), "技能名称")
|
||
duplicate = s.exec(select(AgentSkill).where(AgentSkill.name == name)).first()
|
||
if duplicate and duplicate.id != sid:
|
||
raise HTTPException(409, "技能名称已存在")
|
||
skill.name = name
|
||
if "desc" in body:
|
||
skill.desc = str(body.get("desc") or "")
|
||
if "instructions" in body:
|
||
instructions = _required_text(body.get("instructions"), "技能说明")
|
||
if instructions != skill.instructions:
|
||
skill.instructions = instructions
|
||
skill.version += 1
|
||
if "enabled" in body:
|
||
skill.enabled = bool(body["enabled"])
|
||
if "visibility" in body:
|
||
skill.visibility = visibility_for_create(request, body.get("visibility"))
|
||
s.add(skill); s.commit()
|
||
if "agent_ids" in body:
|
||
_sync_skill_agents(s, sid, body.get("agent_ids") or [], request)
|
||
s.commit()
|
||
_record_fork_revision(s, "skill", sid, request)
|
||
return {"ok": True}
|
||
|
||
@app.put("/api/skills/{sid}/agents")
|
||
def update_skill_agents(sid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
"""Set one or many Agents that can use a skill from the skill library."""
|
||
skill = s.get(AgentSkill, sid)
|
||
if not skill or not can_access(skill, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "技能不存在")
|
||
raw = body.get("agent_ids", body.get("agent_id", []))
|
||
if isinstance(raw, (int, str)):
|
||
raw = [raw]
|
||
_sync_skill_agents(s, sid, raw or [], request)
|
||
s.commit()
|
||
_record_fork_revision(s, "skill", sid, request)
|
||
ids = [int(item) for item in (raw or [])]
|
||
return {"ok": True, "agent_ids": ids,
|
||
"agent_names": [a.name for a in (s.get(Agent, aid) for aid in ids) if a]}
|
||
|
||
@app.delete("/api/skills/{sid}")
|
||
def delete_skill(sid: int, s: Session = Depends(get_session), request: Request = None):
|
||
skill = s.get(AgentSkill, sid)
|
||
revision = _global_revision(s, "skill", skill, {}, request, "delete") if skill else None
|
||
if revision:
|
||
return {"ok": True, "pending": True, "revision_id": revision.id}
|
||
if not skill or not can_access(skill, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "技能不存在")
|
||
# 一键解除所有 Agent 挂接后删除技能。
|
||
for binding in s.exec(select(AgentSkillBinding).where(AgentSkillBinding.skill_id == sid)).all():
|
||
s.delete(binding)
|
||
for grant in s.exec(select(AgentSkillGrant).where(AgentSkillGrant.skill_id == sid)).all():
|
||
s.delete(grant)
|
||
_cleanup_fork(s, "skill", sid)
|
||
s.delete(skill); s.commit()
|
||
return {"ok": True}
|
||
|
||
# ---------- 技能资源授权 ----------
|
||
def _agent_grant_payload(grant: AgentGrant, user: AppUser | None) -> dict:
|
||
return {"id": grant.id, "user_id": grant.user_id,
|
||
"username": user.username if user else f"用户#{grant.user_id}",
|
||
"display_name": user.display_name if user else "",
|
||
"permission": grant.permission, "enabled": grant.enabled,
|
||
"created_at": grant.created_at}
|
||
|
||
@app.get("/api/admin/agents")
|
||
def list_admin_agents(s: Session = Depends(get_session), request: Request = None):
|
||
actor = _tenant_user(request)
|
||
query = select(Agent).order_by(Agent.id.desc())
|
||
if actor:
|
||
query = query.where(Agent.organization_id.in_([0, int(actor.organization_id or 1)]))
|
||
rows = []
|
||
for agent in s.exec(query).all():
|
||
grants = s.exec(select(AgentGrant).where(AgentGrant.agent_id == agent.id).order_by(AgentGrant.id)).all()
|
||
grants = [g for g in grants if _grantable_user(s.get(AppUser, g.user_id), actor)]
|
||
rows.append({"id": agent.id, "name": agent.name, "role": agent.role, "desc": agent.desc,
|
||
"status": agent.status, "builtin": agent.builtin,
|
||
"organization_id": agent.organization_id, **owner_fields(s, agent.owner_user_id),
|
||
"visibility": agent.visibility,
|
||
"grants": [_agent_grant_payload(g, s.get(AppUser, g.user_id)) for g in grants]})
|
||
return rows
|
||
|
||
@app.post("/api/admin/agents/{aid}/grants")
|
||
def grant_agent(aid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
agent = s.get(Agent, aid)
|
||
if not agent or not _same_tenant(agent, _tenant_user(request)):
|
||
raise HTTPException(404, "Agent 不存在")
|
||
user_id = _body_int(body, "user_id", "成员编号")
|
||
target_user = s.get(AppUser, user_id)
|
||
actor = _tenant_user(request)
|
||
if not _grantable_user(target_user, actor):
|
||
raise HTTPException(404, "用户不存在或已停用")
|
||
permission = str(body.get("permission") or "use").strip().lower()
|
||
if permission not in _GRANT_PERMISSIONS:
|
||
raise HTTPException(422, "授权权限无效")
|
||
grant = s.exec(select(AgentGrant).where(AgentGrant.agent_id == aid,
|
||
AgentGrant.user_id == user_id)).first()
|
||
grant = grant or AgentGrant(agent_id=aid, user_id=user_id)
|
||
grant.permission = permission; grant.enabled = True
|
||
if agent.visibility != "global":
|
||
agent.visibility = "shared"
|
||
s.add(grant); s.add(agent); s.commit(); s.refresh(grant)
|
||
return _agent_grant_payload(grant, target_user)
|
||
|
||
@app.delete("/api/admin/agents/{aid}/grants/{grant_id}")
|
||
def revoke_agent(aid: int, grant_id: int, s: Session = Depends(get_session), request: Request = None):
|
||
agent = s.get(Agent, aid)
|
||
grant = s.get(AgentGrant, grant_id)
|
||
if not agent or not _same_tenant(agent, _tenant_user(request)) or not grant or grant.agent_id != aid:
|
||
raise HTTPException(404, "授权记录不存在")
|
||
s.delete(grant); s.commit()
|
||
return {"ok": True}
|
||
|
||
@app.post("/api/admin/agents/{aid}/publish-global")
|
||
def publish_agent_global(aid: int, s: Session = Depends(get_session), request: Request = None):
|
||
agent = s.get(Agent, aid)
|
||
if not agent or not _same_tenant(agent, _tenant_user(request)):
|
||
raise HTTPException(404, "Agent 不存在")
|
||
agent.visibility = "global"; s.add(agent); s.commit()
|
||
return {"ok": True, "visibility": agent.visibility}
|
||
|
||
@app.get("/api/admin/agents/{aid}/export")
|
||
def export_agent(aid: int, s: Session = Depends(get_session), request: Request = None):
|
||
agent = s.get(Agent, aid)
|
||
if not agent or not _same_tenant(agent, _tenant_user(request)):
|
||
raise HTTPException(404, "Agent 不存在")
|
||
_deny_global_user_export(agent, request, s)
|
||
payload = json.dumps({"id": agent.id, "name": agent.name, "role": agent.role,
|
||
"desc": agent.desc, "visibility": agent.visibility}, ensure_ascii=False, indent=2)
|
||
return StreamingResponse(io.BytesIO(payload.encode("utf-8")), media_type="application/json; charset=utf-8",
|
||
headers={"Content-Disposition": f'attachment; filename="agent-{aid}.json"'})
|
||
|
||
# ---------- 技能资源授权 ----------
|
||
def _skill_grant_payload(grant: AgentSkillGrant, user: AppUser | None) -> dict:
|
||
return {"id": grant.id, "user_id": grant.user_id,
|
||
"username": user.username if user else f"用户#{grant.user_id}",
|
||
"display_name": user.display_name if user else "",
|
||
"permission": grant.permission, "enabled": grant.enabled,
|
||
"created_at": grant.created_at}
|
||
|
||
@app.get("/api/admin/skills")
|
||
def list_admin_skills(s: Session = Depends(get_session), request: Request = None):
|
||
actor = _tenant_user(request)
|
||
query = select(AgentSkill).order_by(AgentSkill.id.desc())
|
||
if actor:
|
||
query = query.where(AgentSkill.organization_id.in_([0, int(actor.organization_id or 1)]))
|
||
rows = []
|
||
for skill in s.exec(query).all():
|
||
grants = s.exec(select(AgentSkillGrant).where(AgentSkillGrant.skill_id == skill.id).order_by(AgentSkillGrant.id)).all()
|
||
grants = [g for g in grants if _grantable_user(s.get(AppUser, g.user_id), actor)]
|
||
rows.append({"id": skill.id, "name": skill.name, "desc": skill.desc,
|
||
"instructions": skill.instructions, "enabled": skill.enabled,
|
||
"builtin": skill.builtin, "version": skill.version,
|
||
"organization_id": skill.organization_id, **owner_fields(s, skill.owner_user_id),
|
||
"visibility": skill.visibility,
|
||
"agent_count": len(s.exec(select(AgentSkillBinding).where(AgentSkillBinding.skill_id == skill.id)).all()),
|
||
"grants": [_skill_grant_payload(g, s.get(AppUser, g.user_id)) for g in grants]})
|
||
return rows
|
||
|
||
@app.post("/api/admin/skills/{sid}/grants")
|
||
def grant_skill(sid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
skill = s.get(AgentSkill, sid)
|
||
if not skill or not _same_tenant(skill, _tenant_user(request)):
|
||
raise HTTPException(404, "技能不存在")
|
||
try:
|
||
user_id = int(body.get("user_id"))
|
||
except (TypeError, ValueError) as exc:
|
||
raise HTTPException(422, "user_id 必须是有效用户") from exc
|
||
user = s.get(AppUser, user_id)
|
||
actor = _tenant_user(request)
|
||
if not _grantable_user(user, actor):
|
||
raise HTTPException(404, "用户不存在或已停用")
|
||
permission = str(body.get("permission") or "use").strip().lower()
|
||
if permission not in _GRANT_PERMISSIONS:
|
||
raise HTTPException(422, "permission 必须是 read、use、manage 或 export")
|
||
grant = s.exec(select(AgentSkillGrant).where(AgentSkillGrant.skill_id == sid,
|
||
AgentSkillGrant.user_id == user_id)).first()
|
||
if not grant:
|
||
grant = AgentSkillGrant(skill_id=sid, user_id=user_id, permission=permission)
|
||
else:
|
||
grant.permission = permission; grant.enabled = True
|
||
if skill.visibility != "global":
|
||
skill.visibility = "shared"
|
||
s.add(grant); s.add(skill); s.commit(); s.refresh(grant)
|
||
return _skill_grant_payload(grant, user)
|
||
|
||
@app.delete("/api/admin/skills/{sid}/grants/{grant_id}")
|
||
def revoke_skill(sid: int, grant_id: int, s: Session = Depends(get_session), request: Request = None):
|
||
skill = s.get(AgentSkill, sid)
|
||
grant = s.get(AgentSkillGrant, grant_id)
|
||
if not skill or not _same_tenant(skill, _tenant_user(request)) or not grant or grant.skill_id != sid:
|
||
raise HTTPException(404, "授权记录不存在")
|
||
s.delete(grant); s.commit()
|
||
return {"ok": True}
|
||
|
||
@app.post("/api/admin/skills/{sid}/publish-global")
|
||
def publish_skill_global(sid: int, s: Session = Depends(get_session), request: Request = None):
|
||
skill = s.get(AgentSkill, sid)
|
||
if not skill or not _same_tenant(skill, _tenant_user(request)):
|
||
raise HTTPException(404, "技能不存在")
|
||
skill.visibility = "global"; s.add(skill); s.commit()
|
||
return {"ok": True, "visibility": skill.visibility}
|
||
|
||
@app.get("/api/admin/skills/{sid}/export")
|
||
def export_skill(sid: int, s: Session = Depends(get_session), request: Request = None):
|
||
skill = s.get(AgentSkill, sid)
|
||
if not skill or not _same_tenant(skill, _tenant_user(request)):
|
||
raise HTTPException(404, "技能不存在")
|
||
_deny_global_user_export(skill, request, s)
|
||
payload = f"# {skill.name}\n\n{skill.instructions}\n"
|
||
return StreamingResponse(io.BytesIO(payload.encode("utf-8")), media_type="text/markdown; charset=utf-8",
|
||
headers={"Content-Disposition": f'attachment; filename="skill-{sid}.md"'})
|
||
|
||
# ---------- 提示模板 ----------
|
||
def _prompt_template_payload(template: AgentPromptTemplate) -> dict:
|
||
return {
|
||
"id": template.id, "name": template.name, "desc": template.desc,
|
||
"role_prompt": template.role_prompt, "task_template": template.task_template,
|
||
"constraints": template.constraints or [], "examples": template.examples or [],
|
||
"output_schema": template.output_schema or {}, "style": template.style,
|
||
"version": template.version, "enabled": template.enabled, "builtin": template.builtin,
|
||
"created_at": template.created_at, "updated_at": template.updated_at,
|
||
}
|
||
|
||
|
||
@app.get("/api/prompt-templates")
|
||
def list_prompt_templates(s: Session = Depends(get_session), request: Request = None):
|
||
rows = [template for template in s.exec(select(AgentPromptTemplate).order_by(AgentPromptTemplate.id)).all()
|
||
if can_access(template, request_user(request), "read")]
|
||
return [{**_prompt_template_payload(template),
|
||
"agent_count": len(s.exec(select(Agent).where(
|
||
Agent.prompt_template_id == template.id)).all())}
|
||
for template in rows]
|
||
|
||
|
||
@app.post("/api/prompt-templates")
|
||
def create_prompt_template(body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
try:
|
||
payload = normalize_template_payload(body)
|
||
except ValueError as exc:
|
||
raise HTTPException(422, str(exc)) from exc
|
||
if s.exec(select(AgentPromptTemplate).where(
|
||
AgentPromptTemplate.name == payload["name"])).first():
|
||
raise HTTPException(409, "提示模板名称已存在")
|
||
payload.setdefault("role_prompt", "你是{agent_name},负责{agent_role}类型的内容任务。")
|
||
payload.setdefault("task_template", "标题:{title}\n内容标签:{content_tag}\n导图节点:{mindmap_node}")
|
||
payload.setdefault("constraints", [])
|
||
payload.setdefault("examples", [])
|
||
payload.setdefault("output_schema", {})
|
||
payload.setdefault("style", "")
|
||
organization_id, owner_user_id = actor_fields(request)
|
||
template = AgentPromptTemplate(**payload, organization_id=organization_id,
|
||
owner_user_id=owner_user_id,
|
||
visibility=visibility_for_create(request, body.get("visibility", "private")))
|
||
s.add(template); s.commit(); s.refresh(template)
|
||
return {"id": template.id, "version": template.version}
|
||
|
||
|
||
@app.get("/api/prompt-templates/{tid}")
|
||
def get_prompt_template(tid: int, s: Session = Depends(get_session), request: Request = None):
|
||
template = s.get(AgentPromptTemplate, tid)
|
||
if not template or not can_access(template, request_user(request), "read"):
|
||
raise HTTPException(404, "提示模板不存在")
|
||
return _prompt_template_payload(template)
|
||
|
||
|
||
@app.put("/api/prompt-templates/{tid}")
|
||
def update_prompt_template(tid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
template = s.get(AgentPromptTemplate, tid)
|
||
if not template or not can_access(template, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "提示模板不存在")
|
||
try:
|
||
payload = normalize_template_payload(body, partial=True)
|
||
except ValueError as exc:
|
||
raise HTTPException(422, str(exc)) from exc
|
||
if "name" in payload:
|
||
duplicate = s.exec(select(AgentPromptTemplate).where(
|
||
AgentPromptTemplate.name == payload["name"])).first()
|
||
if duplicate and duplicate.id != tid:
|
||
raise HTTPException(409, "提示模板名称已存在")
|
||
content_keys = {"name", "desc", "role_prompt", "task_template", "constraints", "examples",
|
||
"output_schema", "style"}
|
||
changed = any(getattr(template, key) != payload[key] for key in content_keys if key in payload)
|
||
for key, value in payload.items():
|
||
setattr(template, key, value)
|
||
if changed:
|
||
template.version += 1
|
||
template.updated_at = now()
|
||
s.add(template); s.commit()
|
||
return {"ok": True, "version": template.version}
|
||
|
||
|
||
@app.delete("/api/prompt-templates/{tid}")
|
||
def delete_prompt_template(tid: int, s: Session = Depends(get_session), request: Request = None):
|
||
template = s.get(AgentPromptTemplate, tid)
|
||
if not template or not can_access(template, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "提示模板不存在")
|
||
if s.exec(select(Agent).where(Agent.prompt_template_id == tid)).first():
|
||
raise HTTPException(409, "提示模板正在被 Agent 使用,请先解除绑定")
|
||
s.delete(template); s.commit()
|
||
return {"ok": True}
|
||
|
||
# ---------- 用户模型目录 ----------
|
||
def _model_catalog_payload(item: ModelCatalog, provider: ModelProvider | None) -> dict:
|
||
return {
|
||
"id": item.id, "provider_id": item.provider_id,
|
||
"provider_name": provider.name if provider else "未知 Provider",
|
||
"model_name": item.model_name, "display_name": item.display_name or item.model_name,
|
||
"capabilities": list(item.capabilities or []), "capability_status": item.capability_status,
|
||
"rate_version": item.rate_version, "input_rate": item.input_rate,
|
||
"output_rate": item.output_rate, "enabled": item.enabled,
|
||
}
|
||
|
||
@app.get("/api/models/available")
|
||
def list_available_models(s: Session = Depends(get_session)):
|
||
"""用户端唯一模型入口,只返回启用目录,不暴露 Provider 地址或凭据。"""
|
||
rows = s.exec(select(ModelCatalog).where(ModelCatalog.enabled == True)
|
||
.order_by(ModelCatalog.provider_id, ModelCatalog.id)).all() # noqa: E712
|
||
result = []
|
||
for item in rows:
|
||
provider = s.get(ModelProvider, item.provider_id)
|
||
if provider and provider.enabled and item.model_name in (provider.models or []):
|
||
result.append(_model_catalog_payload(item, provider))
|
||
return result
|
||
|
||
@app.get("/api/admin/models")
|
||
def list_model_catalog(s: Session = Depends(get_session)):
|
||
rows = s.exec(select(ModelCatalog).order_by(ModelCatalog.provider_id, ModelCatalog.id)).all()
|
||
return [_model_catalog_payload(item, s.get(ModelProvider, item.provider_id)) for item in rows]
|
||
|
||
@app.put("/api/admin/models/{model_id}")
|
||
def update_model_catalog(model_id: int, body: dict, s: Session = Depends(get_session)):
|
||
item = s.get(ModelCatalog, model_id)
|
||
if not item:
|
||
raise HTTPException(404, "模型目录项不存在")
|
||
if "display_name" in body:
|
||
item.display_name = _required_text(body.get("display_name"), "模型显示名称")
|
||
if "capabilities" in body:
|
||
capabilities = body.get("capabilities")
|
||
if not isinstance(capabilities, list) or any(not str(value).strip() for value in capabilities):
|
||
raise HTTPException(422, "能力标签必须是字符串列表")
|
||
item.capabilities = list(dict.fromkeys(str(value).strip() for value in capabilities))[:20]
|
||
if "capability_status" in body:
|
||
status = str(body.get("capability_status") or "untested")
|
||
if status not in {"untested", "supported", "partial", "blocked"}:
|
||
raise HTTPException(422, "能力状态无效")
|
||
item.capability_status = status
|
||
if "enabled" in body:
|
||
item.enabled = bool(body.get("enabled"))
|
||
if "input_rate" in body:
|
||
item.input_rate = max(0.0, _body_float(body, "input_rate", "输入费率"))
|
||
if "output_rate" in body:
|
||
item.output_rate = max(0.0, _body_float(body, "output_rate", "输出费率"))
|
||
item.updated_at = now()
|
||
s.add(item); s.commit(); s.refresh(item)
|
||
return _model_catalog_payload(item, s.get(ModelProvider, item.provider_id))
|
||
|
||
# ---------- Provider ----------
|
||
@app.get("/api/settings/model-default")
|
||
def get_default_model(s: Session = Depends(get_session)):
|
||
setting = s.get(AppSetting, "default_model_provider_id")
|
||
provider_id = int(setting.value) if setting and setting.value.isdigit() else None
|
||
provider = s.get(ModelProvider, provider_id) if provider_id else None
|
||
if not provider or not provider.enabled:
|
||
provider = s.exec(select(ModelProvider).where(ModelProvider.enabled == True)
|
||
.order_by(ModelProvider.builtin.desc(), ModelProvider.id)).first() # noqa: E712
|
||
return {"provider_id": provider.id if provider else None,
|
||
"model_name": provider.default_model if provider else ""}
|
||
|
||
@app.put("/api/settings/model-default")
|
||
def set_default_model(body: dict, s: Session = Depends(get_session)):
|
||
provider_id = body.get("provider_id")
|
||
provider = s.get(ModelProvider, provider_id) if provider_id else None
|
||
if not provider or not provider.enabled:
|
||
raise HTTPException(422, "请选择一个启用的 Provider")
|
||
model_name = str(body.get("model_name") or provider.default_model)
|
||
if model_name not in (provider.models or []):
|
||
raise HTTPException(422, "默认模型必须在 Provider 模型列表中")
|
||
provider.default_model = model_name
|
||
setting = s.get(AppSetting, "default_model_provider_id") or AppSetting(key="default_model_provider_id")
|
||
setting.value = str(provider.id)
|
||
s.add(provider); s.add(setting); s.commit()
|
||
return {"ok": True}
|
||
|
||
@app.get("/api/providers")
|
||
def list_providers(s: Session = Depends(get_session)):
|
||
return [{"id": p.id, "name": p.name, "base_url": p.base_url,
|
||
"api_key": secret_store.mask_secret(p.api_key),
|
||
"secret_backend": "keychain" if (p.api_key or "").startswith("keychain:") else "database",
|
||
"models": p.models, "default_model": p.default_model, "enabled": p.enabled,
|
||
"builtin": p.builtin, "note": p.note}
|
||
for p in s.exec(select(ModelProvider)).all()]
|
||
|
||
@app.post("/api/providers/discover-models")
|
||
def discover_provider_models(body: dict, s: Session = Depends(get_session)):
|
||
base_url = _validate_provider_url(body.get("base_url"))
|
||
api_key = _provider_key(body.get('api_key'))
|
||
if not api_key and body.get('provider_id'):
|
||
provider = s.get(ModelProvider, _body_int(body, 'provider_id', 'Provider'))
|
||
if not provider:
|
||
raise HTTPException(404, 'Provider 不存在')
|
||
# Never forward a stored key to an edited, unverified destination.
|
||
if base_url != provider.base_url:
|
||
raise HTTPException(422, '服务地址已修改,请填写该地址对应的 API Key')
|
||
api_key = secret_store.resolve_secret(provider.api_key, f'provider-{provider.id}')
|
||
try:
|
||
models = _discover_provider_models(base_url, api_key)
|
||
except ValueError as exc:
|
||
raise HTTPException(502, f"无法读取 Provider 模型列表:{exc}")
|
||
return {"models": models, "default_model": models[0]}
|
||
|
||
@app.post("/api/providers")
|
||
def create_provider(p: dict, s: Session = Depends(get_session)):
|
||
name = _required_text(p.get("name"), "Provider 名称")
|
||
base_url = _validate_provider_url(p.get("base_url"))
|
||
api_key = _provider_key(p.get("api_key"))
|
||
if not api_key:
|
||
raise HTTPException(422, '请填写 API Key')
|
||
manual_models = [str(model).strip() for model in (p.get("models") or []) if str(model).strip()]
|
||
discovery = "auto"
|
||
try:
|
||
models = _discover_provider_models(base_url, api_key)
|
||
except ValueError as exc:
|
||
if not manual_models:
|
||
raise HTTPException(502, f"自动获取模型失败:{exc}。请检查 Base URL 与 API Key,或手动填写模型列表。")
|
||
models = manual_models
|
||
discovery = "manual"
|
||
default_model = str(p.get("default_model") or "")
|
||
if default_model not in models:
|
||
default_model = models[0]
|
||
mp = ModelProvider(name=name, base_url=base_url, models=models,
|
||
default_model=default_model,
|
||
note=str(p.get("note") or ""), enabled=bool(p.get("enabled", True)))
|
||
s.add(mp); s.flush()
|
||
# P0-1:密钥优先入 macOS 钥匙串,DB 只存引用;不可用时降级存明文。
|
||
try:
|
||
mp.api_key = secret_store.store_secret(f"provider-{mp.id}", api_key)
|
||
except Exception:
|
||
s.rollback()
|
||
raise HTTPException(503, '密钥保存失败,请检查当前环境的密钥存储服务;Provider 尚未添加') from None
|
||
s.add(mp); s.commit(); s.refresh(mp)
|
||
sync_provider_catalog(s, mp); s.commit()
|
||
return {"id": mp.id, "models": models, "model_discovery": discovery,
|
||
"secret_backend": "keychain" if mp.api_key.startswith("keychain:") else "database"}
|
||
|
||
@app.put("/api/providers/{pid}")
|
||
def update_provider(pid: int, p: dict, s: Session = Depends(get_session)):
|
||
mp = s.get(ModelProvider, pid)
|
||
if not mp:
|
||
raise HTTPException(404, "Provider 不存在")
|
||
if "name" in p:
|
||
p["name"] = _required_text(p.get("name"), "Provider 名称")
|
||
if "base_url" in p:
|
||
p["base_url"] = _validate_provider_url(p.get("base_url"))
|
||
if "models" in p:
|
||
p["models"] = [str(model).strip() for model in (p.get("models") or []) if str(model).strip()]
|
||
if not p["models"]:
|
||
raise HTTPException(422, "至少配置一个模型")
|
||
if "default_model" in p:
|
||
requested_default = str(p.get("default_model") or "")
|
||
available_models = p.get("models", mp.models or [])
|
||
if requested_default and requested_default not in available_models:
|
||
raise HTTPException(422, "默认模型必须在模型列表中")
|
||
p["default_model"] = requested_default or (available_models[0] if available_models else "")
|
||
if str(p.get("api_key") or "").startswith("***") or p.get("api_key") == "":
|
||
p.pop("api_key", None)
|
||
# P0-1:更新时传入的新密钥入钥匙串,DB 存引用。
|
||
new_key = p.pop("api_key", None)
|
||
if new_key:
|
||
new_key = _provider_key(new_key)
|
||
for k in ("name", "base_url", "models", "default_model", "enabled", "note"):
|
||
if k in p:
|
||
setattr(mp, k, p[k])
|
||
if new_key:
|
||
try:
|
||
mp.api_key = secret_store.store_secret(f"provider-{mp.id}", str(new_key))
|
||
except Exception:
|
||
s.rollback()
|
||
raise HTTPException(503, '密钥保存失败,原配置已保留;请检查当前环境的密钥存储服务') from None
|
||
s.add(mp); sync_provider_catalog(s, mp); s.commit()
|
||
return {"ok": True,
|
||
"secret_backend": "keychain" if (mp.api_key or "").startswith("keychain:") else "database"}
|
||
|
||
@app.delete("/api/providers/{pid}")
|
||
def delete_provider(pid: int, s: Session = Depends(get_session), request: Request = None):
|
||
"""Provider 可删除(含内置);被 Agent 引用中的默认模型链路不受影响时才允许。"""
|
||
provider = s.get(ModelProvider, pid)
|
||
if not provider or not can_access(provider, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "Provider 不存在")
|
||
from .secret_store import delete_secret
|
||
delete_secret(f"provider-{provider.id}")
|
||
# 同步清掉模型目录,避免遗留无来源目录
|
||
for item in s.exec(select(ModelCatalog).where(ModelCatalog.provider_id == pid)).all():
|
||
s.delete(item)
|
||
s.delete(provider)
|
||
s.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/providers/{pid}/test")
|
||
def test_provider(pid: int, s: Session = Depends(get_session), request: Request = None):
|
||
provider = s.get(ModelProvider, pid)
|
||
if not provider:
|
||
raise HTTPException(404, "Provider 不存在")
|
||
model = provider.default_model or (provider.models[0] if provider.models else "")
|
||
if not model:
|
||
raise HTTPException(422, "Provider 未配置模型")
|
||
organization_id, user_id = actor_fields(request)
|
||
result = chat(s, [{"role": "user", "content": "只回复 OK"}], pid, model,
|
||
agent_name="Provider 连通性测试", max_retries=0, temperature=0,
|
||
user_id=user_id, organization_id=organization_id)
|
||
s.commit()
|
||
if result["status"] != "ok":
|
||
raise HTTPException(502, result.get("error", "模型连通性测试失败"))
|
||
return {"ok": True, "model": model, "duration_ms": result.get("duration_ms", 0)}
|
||
|
||
@app.post("/api/providers/{pid}/refresh-models")
|
||
def refresh_provider_models(pid: int, s: Session = Depends(get_session)):
|
||
"""使用已保存凭据重新读取 Provider 模型清单,避免编辑后列表长期过期。"""
|
||
provider = s.get(ModelProvider, pid)
|
||
if not provider:
|
||
raise HTTPException(404, "Provider 不存在")
|
||
try:
|
||
models = _discover_provider_models(provider.base_url,
|
||
secret_store.resolve_secret(provider.api_key, f"provider-{provider.id}"))
|
||
except ValueError as exc:
|
||
raise HTTPException(502, f"无法刷新 Provider 模型列表:{exc}")
|
||
provider.models = models
|
||
if provider.default_model not in models:
|
||
provider.default_model = models[0]
|
||
s.add(provider); sync_provider_catalog(s, provider); s.commit()
|
||
return {"ok": True, "models": models, "default_model": provider.default_model}
|
||
|
||
# ---------- 知识库 ----------
|
||
@app.get("/api/kbs")
|
||
def list_kbs(s: Session = Depends(get_session), request: Request = None):
|
||
allowed = visible_kb_ids(s, request, "read")
|
||
return [{"id": k.id, "name": k.name, "desc": k.desc, "index_status": k.index_status,
|
||
"file_count": k.file_count, "chunk_count": k.chunk_count,
|
||
"organization_id": k.organization_id, **owner_fields(s, k.owner_user_id),
|
||
"visibility": k.visibility,
|
||
"created_at": k.created_at}
|
||
for k in s.exec(select(KnowledgeBase).where(KnowledgeBase.id.in_(allowed)).order_by(KnowledgeBase.id)).all()]
|
||
|
||
@app.post("/api/kbs")
|
||
def create_kb(kb: dict, s: Session = Depends(get_session), request: Request = None):
|
||
name = _required_text(kb.get("name"), "知识库名称")
|
||
if s.exec(select(KnowledgeBase).where(KnowledgeBase.name == name)).first():
|
||
raise HTTPException(409, "知识库名称已存在")
|
||
organization_id, owner_user_id = actor_fields(request)
|
||
try:
|
||
chunk_target = max(0, min(8000, int(kb.get("chunk_target") or 0)))
|
||
except (TypeError, ValueError):
|
||
chunk_target = 0
|
||
chunk_mode = str(kb.get("chunk_mode") or "auto").strip()
|
||
if chunk_mode not in ("auto", "heading"):
|
||
chunk_mode = "auto"
|
||
k = KnowledgeBase(name=name, desc=str(kb.get("desc") or ""),
|
||
chunk_target=chunk_target, chunk_mode=chunk_mode,
|
||
organization_id=organization_id, owner_user_id=owner_user_id,
|
||
visibility=visibility_for_create(request, kb.get("visibility", "private")))
|
||
s.add(k); s.commit(); s.refresh(k)
|
||
return {"id": k.id}
|
||
|
||
@app.get("/api/kbs/{kid}")
|
||
def get_kb(kid: int, s: Session = Depends(get_session), request: Request = None):
|
||
kb = require_kb(s, kid, request, "read")
|
||
source = Path(kb.source_dir) if kb.source_dir else None
|
||
disk_files = sorted(path.name for path in source.iterdir() if path.is_file()) if source and source.exists() else []
|
||
records = {file.file_name: file for file in s.exec(select(KnowledgeFile).where(KnowledgeFile.kb_id == kid)).all()}
|
||
# 向量索引属于可恢复的附属服务,详情页不能因为一次后端连接失败而整页 500。
|
||
# 优先读取实时计数;失败时使用数据库中上次成功索引的计数,并标记日志,
|
||
# 这样 Chroma/Qdrant 临时不可用时知识库仍可浏览和继续重试。
|
||
try:
|
||
file_chunk_counts = rag_pipeline.store.count_by_file(kid) # 文件级块数(向量库实时统计)
|
||
real_chunk_count = sum(file_chunk_counts.values()) or rag_pipeline.store.count(kid) # 库级实时
|
||
except Exception as exc:
|
||
file_chunk_counts = {}
|
||
real_chunk_count = int(kb.chunk_count or 0)
|
||
try:
|
||
logger.warning("知识库 %s 向量计数暂不可用,使用已保存计数 %s:%s", kid, real_chunk_count, exc)
|
||
except Exception:
|
||
pass
|
||
files = [{"name": file_name, "chunk_count": file_chunk_counts.get(file_name, 0),
|
||
"category_id": records.get(file_name).category_id if records.get(file_name) else None,
|
||
"category_path": rag_pipeline.category_path(s, (records[file_name].category_id if records[file_name].category_id is not None else records[file_name].ai_category_id)) if file_name in records else "未分类",
|
||
"ai_category_id": records.get(file_name).ai_category_id if records.get(file_name) else None,
|
||
"classification_status": records.get(file_name).classification_status if records.get(file_name) else "unclassified",
|
||
"ai_confidence": records.get(file_name).ai_confidence if records.get(file_name) else 0,
|
||
"ai_reason": records.get(file_name).ai_reason if records.get(file_name) else ""}
|
||
for file_name in disk_files]
|
||
return {"id": kb.id, "name": kb.name, "desc": kb.desc,
|
||
"index_status": kb.index_status, "file_count": kb.file_count,
|
||
"chunk_count": real_chunk_count, "created_at": kb.created_at, "files": files,
|
||
"visibility": kb.visibility, **owner_fields(s, kb.owner_user_id),
|
||
"categories": _kb_category_tree(s, kid)}
|
||
|
||
@app.get("/api/kbs/{kid}/reading-status")
|
||
def get_kb_reading_status(kid: int, s: Session = Depends(get_session), request: Request = None):
|
||
"""Return the persisted automatic full-reading status for one library.
|
||
|
||
The status is deliberately separate from indexing: a library can be
|
||
searchable while its reusable knowledge briefs are still being prepared.
|
||
Access follows the same library read grant as the knowledge detail page.
|
||
"""
|
||
kb = require_kb(s, kid, request, "read")
|
||
from .training_reading import read_status
|
||
return read_status(kb)
|
||
|
||
@app.post("/api/kbs/{kid}/categories")
|
||
def create_kb_category(kid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
require_kb(s, kid, request, "manage")
|
||
name = _required_text(body.get("name"), "分类名称")
|
||
parent_id = body.get("parent_id")
|
||
_validate_category(s, kid, None, parent_id)
|
||
duplicate = s.exec(select(KnowledgeCategory).where(
|
||
KnowledgeCategory.kb_id == kid, KnowledgeCategory.parent_id == parent_id,
|
||
KnowledgeCategory.name == name
|
||
)).first()
|
||
if duplicate:
|
||
raise HTTPException(409, "同级分类名称已存在")
|
||
category = KnowledgeCategory(kb_id=kid, parent_id=parent_id, name=name,
|
||
desc=str(body.get("desc") or ""))
|
||
s.add(category); s.commit(); s.refresh(category)
|
||
_record_fork_revision(s, "knowledge_base", kid, request)
|
||
return {"id": category.id}
|
||
|
||
@app.put("/api/kbs/{kid}/categories/{category_id}")
|
||
def update_kb_category(kid: int, category_id: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
require_kb(s, kid, request, "manage")
|
||
category = s.get(KnowledgeCategory, category_id)
|
||
if not category or category.kb_id != kid:
|
||
raise HTTPException(404, "分类不存在")
|
||
if "parent_id" in body:
|
||
parent_id = body.get("parent_id")
|
||
if parent_id == category.id:
|
||
raise HTTPException(422, "分类不能设为自己的父级")
|
||
_validate_category(s, kid, None, parent_id)
|
||
ancestor = s.get(KnowledgeCategory, parent_id) if parent_id else None
|
||
seen = {category.id}
|
||
while ancestor:
|
||
if ancestor.id in seen:
|
||
raise HTTPException(422, "分类移动会造成循环层级")
|
||
seen.add(ancestor.id)
|
||
ancestor = s.get(KnowledgeCategory, ancestor.parent_id) if ancestor.parent_id else None
|
||
category.parent_id = parent_id
|
||
if "name" in body:
|
||
category.name = _required_text(body.get("name"), "分类名称")
|
||
if "desc" in body:
|
||
category.desc = str(body.get("desc") or "")
|
||
s.add(category); s.commit()
|
||
_record_fork_revision(s, "knowledge_base", kid, request)
|
||
return {"ok": True}
|
||
|
||
@app.delete("/api/kbs/{kid}/categories/{category_id}")
|
||
def delete_kb_category(kid: int, category_id: int, s: Session = Depends(get_session), request: Request = None):
|
||
require_kb(s, kid, request, "manage")
|
||
category = s.get(KnowledgeCategory, category_id)
|
||
if not category or category.kb_id != kid:
|
||
raise HTTPException(404, "分类不存在")
|
||
if s.exec(select(KnowledgeCategory).where(KnowledgeCategory.parent_id == category_id)).first():
|
||
raise HTTPException(409, "请先删除或移动子分类")
|
||
for file in s.exec(select(KnowledgeFile).where(KnowledgeFile.category_id == category_id)).all():
|
||
file.category_id = None
|
||
s.add(file)
|
||
s.delete(category); s.commit()
|
||
_record_fork_revision(s, "knowledge_base", kid, request)
|
||
return {"ok": True}
|
||
|
||
@app.put("/api/kbs/{kid}/files/{filename}/category")
|
||
def move_kb_file_category(kid: int, filename: str, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
kb = require_kb(s, kid, request, "manage")
|
||
source = Path(kb.source_dir).resolve() if kb.source_dir else None
|
||
path = (source / filename).resolve() if source else None
|
||
if not source or not path or source not in path.parents or not path.is_file():
|
||
raise HTTPException(404, "文件不存在")
|
||
category_id = body.get("category_id")
|
||
_validate_category(s, kid, category_id)
|
||
file = s.exec(select(KnowledgeFile).where(
|
||
KnowledgeFile.kb_id == kid, KnowledgeFile.file_name == filename
|
||
)).first() or KnowledgeFile(kb_id=kid, file_name=filename)
|
||
file.category_id = category_id
|
||
file.classification_status = "human_confirmed"
|
||
s.add(file); s.commit()
|
||
try:
|
||
rag_pipeline.rebuild_kb(s, kid)
|
||
except Exception as exc:
|
||
raise HTTPException(500, f"文件已移动,但更新索引失败:{exc}")
|
||
_record_fork_revision(s, "knowledge_base", kid, request)
|
||
return {"ok": True, "category_path": rag_pipeline.category_path(s, category_id)}
|
||
|
||
@app.post("/api/kbs/{kid}/files/{filename}/classification/confirm")
|
||
def confirm_kb_file_classification(kid: int, filename: str, s: Session = Depends(get_session), request: Request = None):
|
||
require_kb(s, kid, request, "manage")
|
||
file = s.exec(select(KnowledgeFile).where(
|
||
KnowledgeFile.kb_id == kid, KnowledgeFile.file_name == filename
|
||
)).first()
|
||
if not file or file.ai_category_id is None:
|
||
raise HTTPException(404, "没有可确认的 AI 分类建议")
|
||
file.category_id = file.ai_category_id
|
||
file.classification_status = "human_confirmed"
|
||
s.add(file); s.commit()
|
||
rag_pipeline.rebuild_kb(s, kid)
|
||
_record_fork_revision(s, "knowledge_base", kid, request)
|
||
return {"ok": True, "category_path": rag_pipeline.category_path(s, file.category_id)}
|
||
|
||
@app.post("/api/kbs/{kid}/classify")
|
||
def classify_kb(kid: int, body: dict | None = None, s: Session = Depends(get_session), request: Request = None):
|
||
"""基于文件内容生成 AI 分类建议;人工确认过的文件不会被覆盖。"""
|
||
from .agents.coordinator import _provider_model
|
||
kb = require_kb(s, kid, request, "manage")
|
||
source = Path(kb.source_dir).resolve() if kb.source_dir else None
|
||
if not source or not source.exists():
|
||
return {"status": "empty", "suggestions": []}
|
||
roots = _kb_category_tree(s, kid)
|
||
category_names = [node["name"] for node in roots]
|
||
suggestions = []
|
||
errors: list[str] = []
|
||
try:
|
||
coordinator = s.exec(select(Agent).where(
|
||
Agent.role == "coordinator",
|
||
Agent.organization_id == int(kb.organization_id or 1),
|
||
).order_by(Agent.id)).first()
|
||
if not coordinator:
|
||
raise ValueError("没有配置任务拆解 Agent")
|
||
provider, model = _provider_model(s, coordinator)
|
||
except Exception as exc:
|
||
return {"status": "error", "suggestions": [], "error": f"分类模型不可用:{str(exc)[:240]}"}
|
||
for path in rag_pipeline._disk_files(source):
|
||
try:
|
||
record = s.exec(select(KnowledgeFile).where(
|
||
KnowledgeFile.kb_id == kid, KnowledgeFile.file_name == path.name
|
||
)).first()
|
||
if record and record.classification_status == "human_confirmed" and not (body or {}).get("force"):
|
||
continue
|
||
units = rag_pipeline.parse.parse_file(path)
|
||
sample = "\n".join(str(unit.get("text") or "") for unit in units)[:6000]
|
||
prompt = f"""你是知识库分类器。请根据文件内容生成真实业务分类,不要使用“一级/二级”等抽象词。\n已有知识领域:{category_names}\n文件名:{path.name}\n内容:{sample}\n只输出 JSON:{{\"path\":[\"知识领域\",\"知识主题\",\"知识子主题\"],\"confidence\":0.0,\"reason\":\"一句话\"}}"""
|
||
organization_id, user_id = actor_fields(request)
|
||
result = chat(s, [{"role": "user", "content": prompt}], provider.id, model,
|
||
agent_name="知识库 AI 分类", max_retries=1, temperature=0.1,
|
||
user_id=user_id, organization_id=organization_id)
|
||
s.commit()
|
||
if result["status"] != "ok":
|
||
errors.append(f"{path.name}:{result.get('error', '模型未返回有效结果')}")
|
||
continue
|
||
import json, re
|
||
raw = result["text"].strip()
|
||
match = re.search(r"\{[\s\S]*\}", raw)
|
||
payload = json.loads(match.group(0) if match else raw)
|
||
path_names = [str(item).strip() for item in payload.get("path", []) if str(item).strip()][:3]
|
||
if not path_names:
|
||
errors.append(f"{path.name}:模型未返回分类路径")
|
||
continue
|
||
parent_id = None
|
||
for name in path_names:
|
||
category = s.exec(select(KnowledgeCategory).where(
|
||
KnowledgeCategory.kb_id == kid, KnowledgeCategory.parent_id == parent_id,
|
||
KnowledgeCategory.name == name
|
||
)).first()
|
||
if not category:
|
||
category = KnowledgeCategory(kb_id=kid, parent_id=parent_id, name=name)
|
||
s.add(category); s.commit(); s.refresh(category)
|
||
parent_id = category.id
|
||
record = record or KnowledgeFile(kb_id=kid, file_name=path.name)
|
||
record.ai_category_id = parent_id
|
||
confidence = float(payload.get("confidence") or 0)
|
||
record.ai_confidence = min(1.0, max(0.0, confidence)) if math.isfinite(confidence) else 0.0
|
||
record.ai_reason = str(payload.get("reason") or "")
|
||
record.classification_status = "ai_suggested"
|
||
s.add(record); s.commit()
|
||
suggestions.append({"file_name": path.name, "category_id": parent_id,
|
||
"category_path": rag_pipeline.category_path(s, parent_id),
|
||
"confidence": record.ai_confidence, "reason": record.ai_reason})
|
||
except Exception as exc:
|
||
s.rollback()
|
||
errors.append(f"{path.name}:分类处理失败:{str(exc)[:180]}")
|
||
continue
|
||
try:
|
||
rag_pipeline.rebuild_kb(s, kid)
|
||
except Exception as exc:
|
||
s.rollback()
|
||
errors.append(f"索引重建失败:{str(exc)[:180]}")
|
||
status = "ok" if not errors else ("partial" if suggestions else "error")
|
||
result = {"status": status, "suggestions": suggestions}
|
||
if errors:
|
||
result["errors"] = errors[:20]
|
||
result["error"] = ";".join(errors[:3])
|
||
return result
|
||
|
||
@app.get("/api/kbs/{kid}/files/{filename:path}")
|
||
def preview_kb_file(kid: int, filename: str, s: Session = Depends(get_session), request: Request = None):
|
||
"""返回知识库源文件的安全预览,供管理端审阅已入库内容。"""
|
||
kb = require_kb(s, kid, request, "read")
|
||
source = Path(kb.source_dir).resolve() if kb.source_dir else None
|
||
if not source or not source.exists():
|
||
raise HTTPException(404, "知识库尚未导入文件")
|
||
path = (source / filename).resolve()
|
||
if source not in path.parents or not path.is_file():
|
||
raise HTTPException(404, "文件不存在")
|
||
if path.suffix.lower() not in rag_pipeline.SUPPORTED_SUFFIXES:
|
||
raise HTTPException(415, "该文件类型不支持预览")
|
||
try:
|
||
units = rag_pipeline.parse.parse_file(path)
|
||
text = "\n\n".join(
|
||
(f"## {unit.get('meta', {}).get('heading')}\n" if unit.get('meta', {}).get('heading') else "")
|
||
+ str(unit.get("text") or "")
|
||
for unit in units
|
||
).strip()
|
||
except Exception as exc:
|
||
raise HTTPException(500, f"文件解析失败: {exc}")
|
||
return {"name": path.name, "type": path.suffix.lower(), "text": text[:50000],
|
||
"truncated": len(text) > 50000, "characters": len(text)}
|
||
|
||
@app.get("/api/kb-metrics")
|
||
def kbs_metrics_overview(s: Session = Depends(get_session), request: Request = None):
|
||
"""Hermes 融合(交付报告 P2-3):知识库消费指标(命中率/重复消费率/引用覆盖率)。"""
|
||
from .kb_metrics import kb_metrics_overview
|
||
user = request_user(request)
|
||
# 普通用户只看到自己发起任务产生的消费指标;管理员仍可看本组织全量。
|
||
return kb_metrics_overview(
|
||
s,
|
||
owner_user_id=(None if user is None or user.role == "admin" else int(user.id or 0)),
|
||
organization_id=(int(user.organization_id or 1) if user else None),
|
||
kb_ids=(visible_kb_ids(s, request, "read") if user is not None and user.role != "admin" else None),
|
||
)
|
||
|
||
|
||
def _kb_file_path(kb, filename: str) -> Path:
|
||
"""解析并校验知识库内文件路径;不存在/越界时抛 404。"""
|
||
source = Path(kb.source_dir).resolve() if kb.source_dir else None
|
||
path = (source / filename).resolve() if source else None
|
||
if not source or not path or source not in path.parents or not path.is_file():
|
||
raise HTTPException(404, "文件不存在")
|
||
return path
|
||
|
||
|
||
def _kb_usage_rows_for_request(session: Session, kb_id: int, request: Request | None) -> list[KnowledgeUsage]:
|
||
"""Return consumption rows visible to the current user.
|
||
|
||
KnowledgeUsage predates tenant ownership and is keyed by Agent/Task. Resolve
|
||
the owner through the task first (the same global Agent may be used by many
|
||
users), then fall back to the Agent owner for legacy rows. This keeps the
|
||
knowledge page and release actions from exposing another user's history.
|
||
"""
|
||
rows = session.exec(select(KnowledgeUsage).where(KnowledgeUsage.kb_id == kb_id)
|
||
.order_by(KnowledgeUsage.used_at.desc())).all()
|
||
user = request_user(request)
|
||
if user is None:
|
||
return rows
|
||
kb = session.get(KnowledgeBase, kb_id)
|
||
if not kb or int(kb.organization_id or 1) not in {0, int(user.organization_id or 1)}:
|
||
return []
|
||
# Resolve ownership in bounded batches. A campaign is authoritative when
|
||
# present; legacy rows without a campaign fall back to the Agent owner.
|
||
campaign_ids = {int(row.campaign_id or 0) for row in rows if row.campaign_id}
|
||
agent_ids = {int(row.agent_id or 0) for row in rows if row.agent_id}
|
||
campaigns = {item.id: item for item in session.exec(select(Campaign).where(Campaign.id.in_(campaign_ids))).all()} if campaign_ids else {}
|
||
agents = {item.id: item for item in session.exec(select(Agent).where(Agent.id.in_(agent_ids))).all()} if agent_ids else {}
|
||
uid, org = int(user.id or 0), int(user.organization_id or 1)
|
||
visible = []
|
||
for row in rows:
|
||
campaign = campaigns.get(row.campaign_id)
|
||
agent = agents.get(row.agent_id)
|
||
context_org = int((campaign or agent).organization_id or 1) if (campaign or agent) else org
|
||
if context_org not in {0, org}:
|
||
continue
|
||
owner = int((campaign.owner_user_id if campaign else (agent.owner_user_id if agent else 0)) or 0)
|
||
if user.role == "admin" or owner == uid:
|
||
visible.append(row)
|
||
return visible
|
||
|
||
|
||
@app.delete("/api/kbs/{kid}/files/{filename}")
|
||
def delete_kb_file(kid: int, filename: str, s: Session = Depends(get_session), request: Request = None):
|
||
"""删除单个知识文件:物理文件 + 数据库记录 + 向量索引同步清理,然后重建索引。"""
|
||
kb = require_kb(s, kid, request, "manage")
|
||
path = _kb_file_path(kb, filename)
|
||
try:
|
||
path.unlink()
|
||
except Exception as exc:
|
||
raise HTTPException(500, f"文件删除失败:{exc}")
|
||
for record in s.exec(select(KnowledgeFile).where(
|
||
KnowledgeFile.kb_id == kid, KnowledgeFile.file_name == filename)).all():
|
||
s.delete(record)
|
||
s.commit()
|
||
try:
|
||
rag_pipeline.rebuild_kb(s, kid)
|
||
except Exception as exc:
|
||
raise HTTPException(500, f"文件已删除,但更新索引失败:{exc}")
|
||
_record_fork_revision(s, "knowledge_base", kid, request)
|
||
return {"ok": True, "deleted": filename}
|
||
|
||
|
||
@app.put("/api/kbs/{kid}/files/{filename}/content")
|
||
def update_kb_file_content(kid: int, filename: str, body: dict, s: Session = Depends(get_session),
|
||
request: Request = None):
|
||
"""直接编辑知识文件内容(纯文本),保存后自动重建索引。"""
|
||
kb = require_kb(s, kid, request, "manage")
|
||
path = _kb_file_path(kb, filename)
|
||
if path.suffix.lower() not in {".txt", ".md"}:
|
||
raise HTTPException(415, "仅支持在线编辑 txt / md 文件;其他类型请删除后重新导入")
|
||
content = body.get("content")
|
||
if not isinstance(content, str) or not content.strip():
|
||
raise HTTPException(422, "文件内容不能为空")
|
||
try:
|
||
path.write_text(content, encoding="utf-8")
|
||
except Exception as exc:
|
||
raise HTTPException(500, f"文件保存失败:{exc}")
|
||
file_record = s.exec(select(KnowledgeFile).where(
|
||
KnowledgeFile.kb_id == kid, KnowledgeFile.file_name == filename)).first()
|
||
if file_record:
|
||
file_record.revision = (file_record.revision or 1) + 1
|
||
s.add(file_record)
|
||
s.commit()
|
||
try:
|
||
rag_pipeline.rebuild_kb(s, kid)
|
||
except Exception as exc:
|
||
raise HTTPException(500, f"文件已保存,但更新索引失败:{exc}")
|
||
_record_fork_revision(s, "knowledge_base", kid, request)
|
||
return {"ok": True, "revision": file_record.revision if file_record else 1}
|
||
|
||
|
||
@app.put("/api/kbs/{kid}/files/{filename}/move")
|
||
def move_kb_file(kid: int, filename: str, body: dict, s: Session = Depends(get_session),
|
||
request: Request = None):
|
||
"""把文件移动到另一个知识库(跨库迁移,向量索引同步重建)。"""
|
||
kb = require_kb(s, kid, request, "manage")
|
||
target_id = _body_int(body, "target_kb_id", "目标知识库")
|
||
target_kb = require_kb(s, target_id, request, "manage")
|
||
if target_id == kid:
|
||
raise HTTPException(422, "目标知识库不能是当前知识库")
|
||
path = _kb_file_path(kb, filename)
|
||
category_id = body.get("category_id")
|
||
try:
|
||
rag_pipeline.import_kb_files(s, target_id, [str(path)])
|
||
except Exception as exc:
|
||
raise HTTPException(500, f"移入目标知识库失败:{exc}")
|
||
if category_id is not None:
|
||
record = s.exec(select(KnowledgeFile).where(
|
||
KnowledgeFile.kb_id == target_id, KnowledgeFile.file_name == filename)).first()
|
||
if record:
|
||
record.category_id = int(category_id)
|
||
s.add(record)
|
||
s.commit()
|
||
rag_pipeline.rebuild_kb(s, target_id)
|
||
# 从源库删除
|
||
try:
|
||
path.unlink()
|
||
except Exception as exc:
|
||
raise HTTPException(500, f"已移入目标库,但源文件删除失败:{exc}")
|
||
for record in s.exec(select(KnowledgeFile).where(
|
||
KnowledgeFile.kb_id == kid, KnowledgeFile.file_name == filename)).all():
|
||
s.delete(record)
|
||
s.commit()
|
||
try:
|
||
rag_pipeline.rebuild_kb(s, kid)
|
||
except Exception as exc:
|
||
raise HTTPException(500, f"迁移完成,但源库索引更新失败:{exc}")
|
||
return {"ok": True, "moved": filename, "target_kb_id": target_id}
|
||
|
||
@app.get("/api/kbs/{kid}/usage")
|
||
def kb_usage(kid: int, s: Session = Depends(get_session), request: Request = None):
|
||
kb = require_kb(s, kid, request, "read")
|
||
user = request_user(request)
|
||
can_release = bool(user is None or user.role == "admin" or int(kb.owner_user_id or 0) == int(user.id or 0)
|
||
or s.exec(select(KnowledgeBaseGrant).where(KnowledgeBaseGrant.kb_id == kid,
|
||
KnowledgeBaseGrant.user_id == user.id, KnowledgeBaseGrant.permission == "manage",
|
||
KnowledgeBaseGrant.enabled == True)).first()) if user else True # noqa: E712
|
||
usages = _kb_usage_rows_for_request(s, kid, request)
|
||
out = []
|
||
for usage in usages:
|
||
seg = s.get(Segment, usage.segment_id) if usage.segment_id else None
|
||
camp = s.get(Campaign, usage.campaign_id) if usage.campaign_id else None
|
||
out.append({"id": usage.id, "agent_id": usage.agent_id,
|
||
"agent_name": (s.get(Agent, usage.agent_id).name if s.get(Agent, usage.agent_id) else f"Agent#{usage.agent_id}"),
|
||
"chunk_id": usage.chunk_id, "file_name": usage.file_name,
|
||
"heading": usage.heading, "excerpt": usage.excerpt,
|
||
"campaign_id": usage.campaign_id,
|
||
"campaign_name": camp.name if camp else "",
|
||
"segment_id": usage.segment_id,
|
||
"segment_title": seg.title if seg else "",
|
||
"status": usage.status, "used_at": usage.used_at, "released_at": usage.released_at,
|
||
"kb_id": kb.id, "kb_name": kb.name, "can_release": can_release})
|
||
return out
|
||
|
||
@app.get("/api/kb-usages")
|
||
def all_kb_usage(s: Session = Depends(get_session), request: Request = None):
|
||
"""Return consumption rows across every knowledge base visible to the caller.
|
||
|
||
The knowledge page uses this aggregate endpoint for its standalone
|
||
consumption tab so it does not need to issue one request per knowledge
|
||
base. ``kb_usage`` already applies the per-user visibility filter and
|
||
enriches rows with Agent/task context.
|
||
"""
|
||
user = request_user(request)
|
||
if user and user.role == "admin":
|
||
# Tenant administrators see their tenant plus explicitly global KBs;
|
||
# do not rely on can_access here because its legacy ``0`` fallback
|
||
# treats global organization ids as tenant 1.
|
||
org = int(user.organization_id or 1)
|
||
visible = {int(kb.id) for kb in s.exec(select(KnowledgeBase).where(
|
||
KnowledgeBase.organization_id.in_([0, org]))).all() if kb.id}
|
||
else:
|
||
visible = visible_kb_ids(s, request, "read")
|
||
if not visible:
|
||
return []
|
||
kbs = {kb.id: kb for kb in s.exec(select(KnowledgeBase).where(KnowledgeBase.id.in_(visible))).all()}
|
||
usages = s.exec(select(KnowledgeUsage).where(KnowledgeUsage.kb_id.in_(visible)).order_by(KnowledgeUsage.used_at.desc())).all()
|
||
campaigns = {x.id: x for x in s.exec(select(Campaign).where(Campaign.id.in_({int(u.campaign_id) for u in usages if u.campaign_id}))).all()} if usages else {}
|
||
agents = {x.id: x for x in s.exec(select(Agent).where(Agent.id.in_({int(u.agent_id) for u in usages if u.agent_id}))).all()} if usages else {}
|
||
segments = {x.id: x for x in s.exec(select(Segment).where(Segment.id.in_({int(u.segment_id) for u in usages if u.segment_id}))).all()} if usages else {}
|
||
org = int(user.organization_id or 1) if user else None
|
||
manage_kb_ids = set(kbs)
|
||
if user and user.role != "admin":
|
||
manage_kb_ids = {kid for kid in kbs if int(kbs[kid].owner_user_id or 0) == int(user.id or 0)}
|
||
manage_kb_ids.update(g.kb_id for g in s.exec(select(KnowledgeBaseGrant).where(
|
||
KnowledgeBaseGrant.kb_id.in_(visible), KnowledgeBaseGrant.user_id == user.id,
|
||
KnowledgeBaseGrant.enabled == True, KnowledgeBaseGrant.permission == "manage")).all()) # noqa: E712
|
||
out = []
|
||
for u in usages:
|
||
kb = kbs.get(u.kb_id); campaign = campaigns.get(u.campaign_id); agent = agents.get(u.agent_id)
|
||
if not kb or not agent:
|
||
continue
|
||
context = campaign or agent
|
||
context_org = int(getattr(context, "organization_id", 1) or 1)
|
||
if user and context_org not in {0, org}:
|
||
continue
|
||
owner = int((campaign.owner_user_id if campaign else agent.owner_user_id) or 0)
|
||
if user and user.role != "admin" and owner != int(user.id or 0):
|
||
continue
|
||
# Release requires manage access to this knowledge base; avoid exposing
|
||
# an apparently actionable button when the user only has read/use.
|
||
can_release = bool(user is None or user.role == "admin" or kb.id in manage_kb_ids)
|
||
seg = segments.get(u.segment_id)
|
||
out.append({"id": u.id, "agent_id": u.agent_id, "agent_name": agent.name,
|
||
"chunk_id": u.chunk_id, "file_name": u.file_name, "heading": u.heading,
|
||
"excerpt": u.excerpt, "campaign_id": u.campaign_id,
|
||
"campaign_name": campaign.name if campaign else "", "segment_id": u.segment_id,
|
||
"segment_title": seg.title if seg else "", "status": u.status,
|
||
"used_at": u.used_at, "released_at": u.released_at,
|
||
"kb_id": kb.id, "kb_name": kb.name, "can_release": can_release})
|
||
return out
|
||
|
||
@app.get("/api/kbs/{kid}/usage/summary")
|
||
def kb_usage_summary(kid: int, request: Request = None, s: Session = Depends(get_session)):
|
||
"""消费透明化:这个库的知识被谁用了多少、还剩多少可用。"""
|
||
kb = require_kb(s, kid, request, "read")
|
||
usages = _kb_usage_rows_for_request(s, kid, request)
|
||
by_agent: dict = {}
|
||
consumed_ids: set = set()
|
||
for u in usages:
|
||
st = by_agent.setdefault(u.agent_id, {"agent_id": u.agent_id, "used": 0, "released": 0})
|
||
if u.status == "available":
|
||
st["released"] += 1
|
||
else:
|
||
st["used"] += 1
|
||
if u.status == "consumed":
|
||
consumed_ids.add(u.chunk_id)
|
||
for aid, st in by_agent.items():
|
||
a = s.get(Agent, aid)
|
||
st["agent_name"] = a.name if a else f"Agent#{aid}"
|
||
# Use the already filtered rows; querying the table again here would leak
|
||
# another user's recent consumption into a private user's summary.
|
||
recent = usages[:8]
|
||
recent_list = [{"agent_name": (s.get(Agent, u.agent_id).name if s.get(Agent, u.agent_id) else f"Agent#{u.agent_id}"),
|
||
"heading": u.heading, "file_name": u.file_name, "excerpt": u.excerpt[:80],
|
||
"status": u.status, "used_at": u.used_at,
|
||
"campaign": (s.get(Campaign, u.campaign_id).name if u.campaign_id and s.get(Campaign, u.campaign_id) else ""),
|
||
"segment": (s.get(Segment, u.segment_id).title if u.segment_id and s.get(Segment, u.segment_id) else "")}
|
||
for u in recent]
|
||
total = kb.chunk_count or 0
|
||
locked = len(consumed_ids)
|
||
return {"kb_name": kb.name, "total_chunks": total,
|
||
"consumed": locked, "released": sum(1 for u in usages if u.status == "available"),
|
||
"remaining": max(0, total - locked),
|
||
"by_agent": sorted(by_agent.values(), key=lambda x: -x["used"]),
|
||
"recent": recent_list}
|
||
|
||
|
||
@app.post("/api/kb-usages/{usage_id}/release")
|
||
def release_kb_usage(usage_id: int, s: Session = Depends(get_session), request: Request = None):
|
||
usage = s.get(KnowledgeUsage, usage_id)
|
||
if not usage:
|
||
raise HTTPException(404, "知识消费记录不存在")
|
||
require_kb(s, usage.kb_id, request, "manage")
|
||
if usage.id not in {row.id for row in _kb_usage_rows_for_request(s, usage.kb_id, request)}:
|
||
raise HTTPException(404, "知识消费记录不存在")
|
||
usage.status = "available"
|
||
usage.released_at = now()
|
||
s.add(usage); s.commit()
|
||
return {"ok": True}
|
||
|
||
@app.post("/api/kbs/{kid}/usage/release-batch")
|
||
def release_kb_usage_batch(kid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
"""批量重新开放知识消费记录;usage_ids 为空时开放该库全部已消费记录。"""
|
||
require_kb(s, kid, request, "manage")
|
||
raw_ids = body.get("usage_ids") if isinstance(body, dict) else None
|
||
if raw_ids is not None and (not isinstance(raw_ids, list) or any(
|
||
isinstance(value, bool) or not str(value).isdigit() or int(value) < 1 for value in raw_ids)):
|
||
raise HTTPException(422, '请选择有效的消费记录')
|
||
requested = {int(value) for value in (raw_ids or []) if str(value).isdigit()}
|
||
query = select(KnowledgeUsage).where(KnowledgeUsage.kb_id == kid,
|
||
KnowledgeUsage.status == "consumed")
|
||
rows = s.exec(query).all()
|
||
visible = {row.id for row in _kb_usage_rows_for_request(s, kid, request)}
|
||
rows = [row for row in rows if row.id in visible]
|
||
if requested:
|
||
rows = [row for row in rows if row.id in requested]
|
||
released = 0
|
||
for usage in rows:
|
||
usage.status = "available"
|
||
usage.released_at = now()
|
||
s.add(usage)
|
||
released += 1
|
||
s.commit()
|
||
return {"ok": True, "released": released}
|
||
|
||
@app.put("/api/kbs/{kid}")
|
||
def update_kb(kid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
source = s.get(KnowledgeBase, kid)
|
||
revision = _global_revision(s, "knowledge_base", source, body, request) if source else None
|
||
if revision:
|
||
return {"ok": True, "pending": True, "revision_id": revision.id, "version": revision.version}
|
||
kb = require_kb(s, kid, request, "manage")
|
||
if "name" in body:
|
||
name = _required_text(body.get("name"), "知识库名称")
|
||
duplicate = s.exec(select(KnowledgeBase).where(KnowledgeBase.name == name)).first()
|
||
if duplicate and duplicate.id != kid:
|
||
raise HTTPException(409, "知识库名称已存在")
|
||
kb.name = name
|
||
if "desc" in body:
|
||
kb.desc = str(body.get("desc") or "")
|
||
if "visibility" in body:
|
||
kb.visibility = visibility_for_create(request, body.get("visibility"))
|
||
s.add(kb); s.commit()
|
||
_record_fork_revision(s, "knowledge_base", kid, request)
|
||
return {"ok": True}
|
||
|
||
@app.delete("/api/kbs/{kid}")
|
||
def delete_kb(kid: int, s: Session = Depends(get_session), request: Request = None):
|
||
source = s.get(KnowledgeBase, kid)
|
||
revision = _global_revision(s, "knowledge_base", source, {}, request, "delete") if source else None
|
||
if revision:
|
||
return {"ok": True, "pending": True, "revision_id": revision.id}
|
||
kb = require_kb(s, kid, request, "manage")
|
||
# 一键移除所有 Agent 的知识库/分类挂接,再清理索引和文件。
|
||
_detach_kb_bindings(s, kid)
|
||
rag_store.reset_kb(kid)
|
||
for usage in s.exec(select(KnowledgeUsage).where(KnowledgeUsage.kb_id == kid)).all():
|
||
s.delete(usage)
|
||
for file in s.exec(select(KnowledgeFile).where(KnowledgeFile.kb_id == kid)).all():
|
||
s.delete(file)
|
||
for category in s.exec(select(KnowledgeCategory).where(KnowledgeCategory.kb_id == kid)).all():
|
||
s.delete(category)
|
||
for grant in s.exec(select(KnowledgeBaseGrant).where(KnowledgeBaseGrant.kb_id == kid)).all():
|
||
s.delete(grant)
|
||
if kb.source_dir:
|
||
source = Path(kb.source_dir).resolve()
|
||
root = KB_DIR.resolve()
|
||
if source != root and root in source.parents and source.exists():
|
||
shutil.rmtree(source)
|
||
_cleanup_fork(s, "knowledge_base", kid)
|
||
s.delete(kb); s.commit()
|
||
return {"ok": True}
|
||
|
||
# ---------- 管理端资源授权 ----------
|
||
_GRANT_PERMISSIONS = {"read", "use", "manage", "export"}
|
||
|
||
|
||
def _is_super_admin(user: AppUser | None) -> bool:
|
||
"""超级管理员不作为资源授权对象;全局资源本身无需给管理员单独授权。"""
|
||
return bool(user and user.role == "admin")
|
||
|
||
|
||
def _grantable_user(user: AppUser | None, actor: AppUser | None = None) -> bool:
|
||
return bool(user and user.enabled and not _is_super_admin(user)
|
||
and (actor is None or user.organization_id == actor.organization_id))
|
||
|
||
|
||
def _grant_payload(grant: KnowledgeBaseGrant | MemoryGrant, user: AppUser | None) -> dict:
|
||
return {"id": grant.id, "user_id": grant.user_id,
|
||
"username": user.username if user else f"用户#{grant.user_id}",
|
||
"display_name": user.display_name if user else "", "permission": grant.permission,
|
||
"enabled": grant.enabled, "created_at": grant.created_at}
|
||
|
||
|
||
def _same_tenant(resource: object, user: AppUser | None) -> bool:
|
||
# 直接调用业务函数的单元测试没有 HTTP 身份;真实请求一定会经过认证中间件。
|
||
if user is None:
|
||
return True
|
||
return int(getattr(resource, "organization_id", 1) or 1) in {0, int(user.organization_id or 1)}
|
||
|
||
|
||
def _tenant_user(request: Request | None) -> AppUser | None:
|
||
return getattr(request.state, "user", None) if request else None
|
||
|
||
|
||
_REVISION_TYPES = {
|
||
"knowledge_base": KnowledgeBase,
|
||
"agent": Agent,
|
||
"skill": AgentSkill,
|
||
"canvas": Canvas,
|
||
"memory": Memory,
|
||
"digital_human": DigitalHuman,
|
||
}
|
||
|
||
|
||
def _global_revision(session: Session, resource_type: str, resource: object,
|
||
payload: dict | None, request: Request | None,
|
||
operation: str = "update") -> ResourceRevision | None:
|
||
"""Record a user's edit/delete of a global resource without mutating it."""
|
||
actor = request_user(request)
|
||
if not actor or actor.role == "admin" or str(getattr(resource, "visibility", "")) != "global":
|
||
return None
|
||
if int(getattr(resource, "organization_id", 1) or 1) not in {0, int(actor.organization_id or 1)}:
|
||
raise HTTPException(404, "资源不存在")
|
||
# Keep only JSON data supplied by the caller; never persist ORM objects.
|
||
safe_payload = payload if isinstance(payload, dict) else {}
|
||
pending = session.exec(select(ResourceRevision).where(
|
||
ResourceRevision.resource_type == resource_type,
|
||
ResourceRevision.resource_id == int(getattr(resource, "id", 0) or 0),
|
||
ResourceRevision.owner_user_id == int(actor.id or 0),
|
||
ResourceRevision.status == "pending")).first()
|
||
if pending:
|
||
pending.payload = safe_payload
|
||
pending.operation = operation
|
||
pending.version += 1
|
||
session.add(pending); session.commit(); session.refresh(pending)
|
||
return pending
|
||
base_version = int(getattr(resource, "version", 1) or 1)
|
||
revision = ResourceRevision(resource_type=resource_type,
|
||
resource_id=int(getattr(resource, "id", 0) or 0),
|
||
owner_user_id=int(actor.id or 0),
|
||
organization_id=int(actor.organization_id or 1),
|
||
operation=operation, base_version=base_version,
|
||
payload=safe_payload)
|
||
session.add(revision); session.commit(); session.refresh(revision)
|
||
return revision
|
||
|
||
|
||
@app.get("/api/admin/resource-revisions")
|
||
def list_resource_revisions(s: Session = Depends(get_session), request: Request = None):
|
||
"""管理员查看用户对全局资源提交的修改/删除版本。"""
|
||
actor = _tenant_user(request)
|
||
query = select(ResourceRevision).order_by(ResourceRevision.id.desc())
|
||
if actor:
|
||
query = query.where(ResourceRevision.organization_id.in_([0, int(actor.organization_id or 1)]))
|
||
rows = s.exec(query).all()
|
||
result = []
|
||
for row in rows:
|
||
model = _REVISION_TYPES.get(row.resource_type)
|
||
result.append({"id": row.id, "resource_type": row.resource_type, "resource_id": row.resource_id,
|
||
"operation": row.operation, "base_version": row.base_version, "version": row.version,
|
||
"status": row.status, "payload": row.payload, "owner_user_id": row.owner_user_id,
|
||
"copy_resource_id": row.fork_resource_id or None,
|
||
"resource_name": (getattr(s.get(model, row.resource_id), "name", "") if model else ""),
|
||
"owner_name": ((s.get(AppUser, row.owner_user_id).display_name or s.get(AppUser, row.owner_user_id).username)
|
||
if s.get(AppUser, row.owner_user_id) else f"用户#{row.owner_user_id}"),
|
||
"created_at": row.created_at, "reviewed_at": row.reviewed_at, "review_note": row.review_note})
|
||
return result
|
||
|
||
|
||
@app.post("/api/admin/resource-revisions/{revision_id}/approve")
|
||
def approve_resource_revision(revision_id: int, body: dict | None = None,
|
||
s: Session = Depends(get_session), request: Request = None):
|
||
actor = _tenant_user(request)
|
||
if actor and actor.role != "admin":
|
||
raise HTTPException(403, "只有管理员可以确认版本")
|
||
row = s.get(ResourceRevision, revision_id)
|
||
if not row or row.status != "pending":
|
||
raise HTTPException(404, "待确认版本不存在")
|
||
if actor and int(row.organization_id or 1) not in {0, int(actor.organization_id or 1)}:
|
||
raise HTTPException(404, "待确认版本不存在")
|
||
model = _REVISION_TYPES.get(row.resource_type)
|
||
resource = s.get(model, row.resource_id) if model else None
|
||
if not resource:
|
||
row.status = "rejected"; row.review_note = "原资源已不存在"
|
||
else:
|
||
if row.base_fingerprint and _resource_fingerprint(_resource_snapshot(s, row.resource_type, resource)) != row.base_fingerprint:
|
||
row.status = "rejected"; row.review_note = "全局资源已被其他版本更新,无法覆盖"
|
||
row.reviewer_user_id = int(actor.id or 0) if actor else 0; row.reviewed_at = now()
|
||
s.add(row); s.commit()
|
||
raise HTTPException(409, "全局资源已发生变化,请基于最新版本重新提交")
|
||
if row.fork_resource_id and row.resource_type == "knowledge_base":
|
||
fork_kb = s.get(KnowledgeBase, row.fork_resource_id)
|
||
if fork_kb and fork_kb.source_dir and resource.source_dir:
|
||
src_dir, dst_dir = Path(fork_kb.source_dir), Path(resource.source_dir)
|
||
if src_dir.exists() and dst_dir.exists():
|
||
for src_file in src_dir.iterdir():
|
||
if src_file.is_file():
|
||
shutil.copy2(src_file, dst_dir / src_file.name)
|
||
# Copy classification metadata by filename; category IDs are global IDs.
|
||
def _map_category_id(src_id):
|
||
if not src_id:
|
||
return None
|
||
source_cat = s.get(KnowledgeCategory, src_id)
|
||
if not source_cat:
|
||
return None
|
||
chain = []
|
||
cur = source_cat
|
||
while cur:
|
||
chain.append(cur); cur = s.get(KnowledgeCategory, cur.parent_id) if cur.parent_id else None
|
||
parent = None
|
||
for item in reversed(chain):
|
||
target = s.exec(select(KnowledgeCategory).where(
|
||
KnowledgeCategory.kb_id == resource.id, KnowledgeCategory.parent_id == parent,
|
||
KnowledgeCategory.name == item.name)).first()
|
||
if not target:
|
||
target = KnowledgeCategory(kb_id=resource.id, parent_id=parent, name=item.name,
|
||
desc=item.desc, sort_order=item.sort_order)
|
||
s.add(target); s.flush()
|
||
parent = target.id
|
||
return parent
|
||
for cat in (row.payload or {}).get("categories", []):
|
||
_map_category_id(cat.get("id"))
|
||
for src_file in s.exec(select(KnowledgeFile).where(KnowledgeFile.kb_id == fork_kb.id)).all():
|
||
dst_file = s.exec(select(KnowledgeFile).where(KnowledgeFile.kb_id == resource.id,
|
||
KnowledgeFile.file_name == src_file.file_name)).first()
|
||
if dst_file:
|
||
dst_file.category_id = _map_category_id(src_file.category_id)
|
||
dst_file.ai_category_id = _map_category_id(src_file.ai_category_id)
|
||
dst_file.revision = (dst_file.revision or 1) + 1; s.add(dst_file)
|
||
else:
|
||
s.add(KnowledgeFile(kb_id=resource.id, file_name=src_file.file_name,
|
||
category_id=_map_category_id(src_file.category_id), ai_category_id=_map_category_id(src_file.ai_category_id),
|
||
content_hash=src_file.content_hash, revision=src_file.revision))
|
||
s.commit()
|
||
try:
|
||
rag_pipeline.rebuild_kb(s, int(resource.id))
|
||
except Exception as exc:
|
||
raise HTTPException(500, f"版本已确认,但全局知识库索引更新失败:{str(exc)[:180]}")
|
||
if row.operation == "delete":
|
||
# 删除提案只对提交者隐藏;管理员确认后仍保留全局资源,避免误删影响全员。
|
||
row.status = "approved"
|
||
row.review_note = "已记录为个人隐藏,不影响全局资源"
|
||
else:
|
||
allowed = {k for k in (row.payload or {}) if k in {
|
||
"name", "desc", "instructions", "enabled", "nodes", "edges", "supervisor_agent_id",
|
||
"supervisor_mode", "persona", "persona_json", "rules", "content_tags", "kb_ids",
|
||
"kb_category_ids", "digital_human_id", "status", "title", "description", "source_ref", "tags", "privacy",
|
||
"persona_meta", "source_files", "layer", "version",
|
||
}}
|
||
fork_copy = s.get(model, row.fork_resource_id) if row.fork_resource_id else None
|
||
for key in allowed:
|
||
value = row.payload.get(key)
|
||
if row.resource_type == "agent" and key == "digital_human_id":
|
||
# 全局 Agent 版本确认时也校验数字人授权与生效状态。
|
||
value = binding_id(s, value, actor)
|
||
# Fork labels are an implementation detail. Do not rename the
|
||
# global source to "(我的副本)" when a user only edited the copy.
|
||
if key == "name" and fork_copy and value == getattr(fork_copy, "name", None):
|
||
continue
|
||
if key == "persona" and hasattr(resource, "persona"):
|
||
resource.persona = value if isinstance(value, dict) else resource.persona
|
||
elif hasattr(resource, key):
|
||
setattr(resource, key, value)
|
||
if row.resource_type == "agent" and "skill_ids" in (row.payload or {}):
|
||
_sync_agent_skills(s, int(resource.id), row.payload.get("skill_ids") or [])
|
||
if row.resource_type == "skill" and "agent_ids" in (row.payload or {}):
|
||
_sync_skill_agents(s, int(resource.id), row.payload.get("agent_ids") or [], request)
|
||
if row.resource_type == "agent" and isinstance(resource, Agent):
|
||
# 用户对全局 Agent 的绑定/规则修改经管理员确认后,同样并入数字人档案。
|
||
_merge_agent_config_into_digital_human(s, resource, resource.digital_human_id)
|
||
if hasattr(resource, "version"):
|
||
resource.version = int(getattr(resource, "version", 1) or 1) + 1
|
||
s.add(resource)
|
||
if row.resource_type == "knowledge_base":
|
||
try:
|
||
rag_pipeline.rebuild_kb(s, int(row.resource_id))
|
||
except Exception as exc:
|
||
row.review_note = f"已更新全局配置,但索引重建失败:{str(exc)[:180]}"
|
||
row.status = "approved"
|
||
row.reviewer_user_id = int(actor.id or 0) if actor else 0
|
||
row.reviewed_at = now()
|
||
if body and body.get("note"):
|
||
row.review_note = str(body.get("note"))[:500]
|
||
s.add(row); s.commit()
|
||
return {"ok": True, "revision_id": row.id, "status": row.status,
|
||
"resource_id": row.resource_id, "resource_type": row.resource_type}
|
||
|
||
|
||
@app.post("/api/admin/resource-revisions/{revision_id}/reject")
|
||
def reject_resource_revision(revision_id: int, body: dict | None = None,
|
||
s: Session = Depends(get_session), request: Request = None):
|
||
actor = _tenant_user(request)
|
||
if actor and actor.role != "admin":
|
||
raise HTTPException(403, "只有管理员可以驳回版本")
|
||
row = s.get(ResourceRevision, revision_id)
|
||
if not row or row.status != "pending":
|
||
raise HTTPException(404, "待确认版本不存在")
|
||
if actor and int(row.organization_id or 1) not in {0, int(actor.organization_id or 1)}:
|
||
raise HTTPException(404, "待确认版本不存在")
|
||
row.status = "rejected"; row.reviewer_user_id = int(actor.id or 0) if actor else 0
|
||
row.reviewed_at = now(); row.review_note = str((body or {}).get("note") or "管理员驳回")[:500]
|
||
s.add(row); s.commit()
|
||
return {"ok": True, "revision_id": row.id, "status": row.status}
|
||
|
||
|
||
def _require_fork_source(session: Session, model, rid: int, request: Request | None):
|
||
actor = request_user(request)
|
||
row = session.get(model, rid)
|
||
if not row or not actor or int(getattr(row, "organization_id", 1) or 1) not in {0, int(actor.organization_id or 1)}:
|
||
raise HTTPException(404, "资源不存在")
|
||
if not can_access(row, actor, "read"):
|
||
raise HTTPException(404, "资源不存在或当前用户没有访问权限")
|
||
return row, actor
|
||
|
||
|
||
def _deny_global_user_export(resource: object, request: Request | None, session: Session | None = None):
|
||
actor = request_user(request)
|
||
is_fork = False
|
||
try:
|
||
db = session or Session(_db.engine)
|
||
is_fork = bool(actor and db.exec(select(ResourceFork).where(
|
||
ResourceFork.copy_id == int(getattr(resource, "id", 0) or 0),
|
||
ResourceFork.owner_user_id == int(actor.id or 0))).first())
|
||
if session is None:
|
||
db.close()
|
||
except Exception:
|
||
is_fork = False
|
||
if actor and actor.role != "admin" and (str(getattr(resource, "visibility", "")) == "global" or is_fork):
|
||
raise HTTPException(403, "全局资源不可导出,请先创建自己的副本")
|
||
|
||
|
||
def _hidden_for_user(session: Session, resource_type: str, resource_id: int,
|
||
request: Request | None) -> bool:
|
||
actor = request_user(request)
|
||
if not actor or actor.role == "admin":
|
||
return False
|
||
row = session.exec(select(ResourceRevision).where(
|
||
ResourceRevision.resource_type == resource_type,
|
||
ResourceRevision.resource_id == int(resource_id),
|
||
ResourceRevision.owner_user_id == int(actor.id or 0),
|
||
ResourceRevision.operation == "delete",
|
||
ResourceRevision.status.in_(["pending", "approved"]),
|
||
).order_by(ResourceRevision.id.desc())).first()
|
||
return row is not None
|
||
|
||
|
||
def _resource_snapshot(session: Session, resource_type: str, resource: object) -> dict:
|
||
"""Create a JSON-safe immutable snapshot for review and rollback."""
|
||
fields = {"name", "desc", "instructions", "enabled", "nodes", "edges", "supervisor_agent_id",
|
||
"supervisor_mode", "persona", "persona_meta", "status", "version", "source_files",
|
||
"rules", "content_tags", "kb_ids", "kb_category_ids", "layer", "title", "description",
|
||
"content", "source_ref", "privacy", "tags", "chunk_target", "chunk_mode", "index_status"}
|
||
result = {key: getattr(resource, key) for key in fields if hasattr(resource, key)}
|
||
if resource_type == "knowledge_base":
|
||
kid = int(getattr(resource, "id", 0) or 0)
|
||
result["files"] = [{"file_name": f.file_name, "category_id": f.category_id,
|
||
"ai_category_id": f.ai_category_id, "content_hash": f.content_hash,
|
||
"revision": f.revision}
|
||
for f in session.exec(select(KnowledgeFile).where(KnowledgeFile.kb_id == kid)).all()]
|
||
result["categories"] = [{"id": c.id, "parent_id": c.parent_id, "name": c.name,
|
||
"desc": c.desc, "sort_order": c.sort_order}
|
||
for c in session.exec(select(KnowledgeCategory).where(KnowledgeCategory.kb_id == kid)).all()]
|
||
if resource_type == "agent":
|
||
result["skill_ids"] = agent_skill_ids(session, int(getattr(resource, "id", 0) or 0))
|
||
if resource_type == "skill":
|
||
result["agent_ids"] = [b.agent_id for b in session.exec(select(AgentSkillBinding).where(
|
||
AgentSkillBinding.skill_id == int(getattr(resource, "id", 0) or 0))).all()]
|
||
return result
|
||
|
||
|
||
def _resource_fingerprint(snapshot: dict) -> str:
|
||
return hashlib.sha256(json.dumps(snapshot, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
|
||
|
||
|
||
def _register_fork(session: Session, resource_type: str, source: object, copy: object, actor: AppUser):
|
||
snap = _resource_snapshot(session, resource_type, source)
|
||
session.add(ResourceFork(resource_type=resource_type, source_id=int(source.id), copy_id=int(copy.id),
|
||
owner_user_id=int(actor.id), organization_id=int(actor.organization_id or 1),
|
||
base_version=int(getattr(source, "version", 1) or 1),
|
||
base_fingerprint=_resource_fingerprint(snap)))
|
||
session.commit()
|
||
|
||
|
||
def _record_fork_revision(session: Session, resource_type: str, copy_id: int, request: Request | None):
|
||
actor = request_user(request)
|
||
if not actor:
|
||
return None
|
||
fork = session.exec(select(ResourceFork).where(ResourceFork.resource_type == resource_type,
|
||
ResourceFork.copy_id == int(copy_id),
|
||
ResourceFork.owner_user_id == int(actor.id))).first()
|
||
if not fork:
|
||
return None
|
||
model = _REVISION_TYPES.get(resource_type)
|
||
resource = session.get(model, copy_id) if model else None
|
||
if not resource:
|
||
return None
|
||
snapshot = _resource_snapshot(session, resource_type, resource)
|
||
# Replace a previous pending proposal with a fresh immutable snapshot.
|
||
prev = session.exec(select(ResourceRevision).where(
|
||
ResourceRevision.resource_type == resource_type, ResourceRevision.resource_id == fork.source_id,
|
||
ResourceRevision.fork_resource_id == int(copy_id), ResourceRevision.owner_user_id == int(actor.id),
|
||
ResourceRevision.status == "pending")).first()
|
||
if prev:
|
||
prev.status = "rejected"; prev.review_note = "已被更新版本替代"; session.add(prev)
|
||
rev = ResourceRevision(resource_type=resource_type, resource_id=fork.source_id,
|
||
fork_resource_id=int(copy_id), owner_user_id=int(actor.id),
|
||
organization_id=int(actor.organization_id or 1), operation="update",
|
||
base_version=fork.base_version, base_fingerprint=fork.base_fingerprint,
|
||
version=(prev.version + 1 if prev else 1), payload=snapshot)
|
||
session.add(rev); session.commit(); session.refresh(rev)
|
||
return rev
|
||
|
||
|
||
def _cleanup_fork(session: Session, resource_type: str, copy_id: int):
|
||
fork = session.exec(select(ResourceFork).where(ResourceFork.resource_type == resource_type,
|
||
ResourceFork.copy_id == int(copy_id))).first()
|
||
if fork:
|
||
for rev in session.exec(select(ResourceRevision).where(ResourceRevision.fork_resource_id == int(copy_id),
|
||
ResourceRevision.status == "pending")).all():
|
||
rev.status = "rejected"; rev.review_note = "个人副本已删除"; session.add(rev)
|
||
session.delete(fork)
|
||
|
||
|
||
def _detach_agent_bindings(session: Session, agent_id: int):
|
||
"""解除 Agent 与所有可解绑资源的关系。
|
||
|
||
删除 Agent 时调用,保证不会因为技能、知识库、画布、数字人或授权
|
||
关系而遗留孤立绑定。历史任务段落仍由删除接口单独保护。
|
||
"""
|
||
for other in session.exec(select(Agent)).all():
|
||
if other.id == agent_id:
|
||
continue
|
||
changed = False
|
||
fusion = [int(x) for x in (other.fusion_agent_ids or []) if str(x).isdigit() and int(x) != agent_id]
|
||
if fusion != list(other.fusion_agent_ids or []):
|
||
other.fusion_agent_ids = fusion; changed = True
|
||
if changed:
|
||
session.add(other)
|
||
# 画布节点/总监引用是 JSON 关系,删除节点引用后画布仍可正常打开。
|
||
for canvas in session.exec(select(Canvas)).all():
|
||
nodes = [node for node in (canvas.nodes or [])
|
||
if not isinstance(node, dict) or int(node.get("agent_id") or 0) != agent_id]
|
||
supervisor = canvas.supervisor_agent_id
|
||
if supervisor == agent_id:
|
||
supervisor = None
|
||
if len(nodes) != len(canvas.nodes or []) or supervisor != canvas.supervisor_agent_id:
|
||
canvas.nodes = nodes; canvas.supervisor_agent_id = supervisor; session.add(canvas)
|
||
for row in session.exec(select(AgentSkillBinding).where(AgentSkillBinding.agent_id == agent_id)).all():
|
||
session.delete(row)
|
||
for row in session.exec(select(AgentGrant).where(AgentGrant.agent_id == agent_id)).all():
|
||
session.delete(row)
|
||
for row in session.exec(select(KnowledgeUsage).where(KnowledgeUsage.agent_id == agent_id)).all():
|
||
session.delete(row)
|
||
# 记忆的 agent_id 为必填字段;0 表示脱离 Agent 的历史记忆,内容保留。
|
||
for memory in session.exec(select(Memory).where(Memory.agent_id == agent_id)).all():
|
||
memory.agent_id = 0; session.add(memory)
|
||
for pending in session.exec(select(PendingMemory).where(PendingMemory.target_agent_id == agent_id)).all():
|
||
session.delete(pending)
|
||
|
||
|
||
def _detach_kb_bindings(session: Session, kb_id: int):
|
||
"""从所有 Agent 的知识库及分类选择中移除指定知识库。"""
|
||
for agent in session.exec(select(Agent)).all():
|
||
ids = [int(x) for x in (agent.kb_ids or []) if str(x).isdigit() and int(x) != kb_id]
|
||
cats = dict(agent.kb_category_ids or {})
|
||
cats.pop(str(kb_id), None); cats.pop(kb_id, None)
|
||
if ids != list(agent.kb_ids or []) or cats != (agent.kb_category_ids or {}):
|
||
agent.kb_ids = ids; agent.kb_category_ids = cats; session.add(agent)
|
||
|
||
|
||
def _detach_digital_human_bindings(session: Session, dh_id: int):
|
||
for agent in session.exec(select(Agent).where(Agent.digital_human_id == dh_id)).all():
|
||
agent.digital_human_id = None; session.add(agent)
|
||
for grant in session.exec(select(DigitalHumanGrant).where(DigitalHumanGrant.dh_id == dh_id)).all():
|
||
session.delete(grant)
|
||
|
||
|
||
def _detach_memory_bindings(session: Session, memory_id: int):
|
||
for grant in session.exec(select(MemoryGrant).where(MemoryGrant.memory_id == memory_id)).all():
|
||
session.delete(grant)
|
||
# 历史版本链指向被删记忆时改为根节点,避免后续恢复/查询出现悬挂引用。
|
||
for child in session.exec(select(Memory).where(Memory.supersedes_id == memory_id)).all():
|
||
child.supersedes_id = 0; session.add(child)
|
||
|
||
|
||
def _fork_label(session: Session, model, name: str, owner_id: int) -> str:
|
||
base = f"{name}(我的副本)"
|
||
candidate, n = base, 2
|
||
while session.exec(select(model).where(model.name == candidate)).first():
|
||
candidate, n = f"{base} {n}", n + 1
|
||
return candidate
|
||
|
||
|
||
@app.post("/api/agents/{aid}/fork")
|
||
def fork_agent(aid: int, s: Session = Depends(get_session), request: Request = None):
|
||
source, actor = _require_fork_source(s, Agent, aid, request)
|
||
clone = Agent(name=_fork_label(s, Agent, source.name, actor.id), role=source.role, desc=source.desc,
|
||
content_tags=list(source.content_tags or []), kb_ids=list(source.kb_ids or []),
|
||
kb_category_ids=dict(source.kb_category_ids or {}), fusion_agent_ids=list(source.fusion_agent_ids or []),
|
||
model_provider_id=source.model_provider_id, model_name=source.model_name,
|
||
model_catalog_id=source.model_catalog_id, prompt_template_id=source.prompt_template_id,
|
||
allowed_tools=list(source.allowed_tools or []), rules=source.rules, rules_version=source.rules_version,
|
||
persona_json=dict(source.persona_json or {}), digital_human_id=source.digital_human_id,
|
||
status=source.status, organization_id=actor.organization_id, owner_user_id=actor.id, visibility="private")
|
||
s.add(clone); s.commit(); s.refresh(clone)
|
||
# 技能挂接属于 Agent 配置快照,副本必须带上原有挂接关系,且后续编辑只影响副本。
|
||
for binding in s.exec(select(AgentSkillBinding).where(AgentSkillBinding.agent_id == source.id)).all():
|
||
s.add(AgentSkillBinding(agent_id=clone.id, skill_id=binding.skill_id))
|
||
s.commit()
|
||
_register_fork(s, "agent", source, clone, actor)
|
||
return {"id": clone.id, "source_id": aid, "visibility": "private"}
|
||
|
||
|
||
@app.post("/api/skills/{sid}/fork")
|
||
def fork_skill(sid: int, s: Session = Depends(get_session), request: Request = None):
|
||
source, actor = _require_fork_source(s, AgentSkill, sid, request)
|
||
clone = AgentSkill(name=_fork_label(s, AgentSkill, source.name, actor.id), desc=source.desc,
|
||
instructions=source.instructions, enabled=source.enabled,
|
||
organization_id=actor.organization_id, owner_user_id=actor.id, visibility="private")
|
||
s.add(clone); s.commit(); s.refresh(clone)
|
||
for binding in s.exec(select(AgentSkillBinding).where(AgentSkillBinding.skill_id == sid)).all():
|
||
s.add(AgentSkillBinding(agent_id=binding.agent_id, skill_id=clone.id))
|
||
s.commit()
|
||
_register_fork(s, "skill", source, clone, actor)
|
||
return {"id": clone.id, "source_id": sid, "visibility": "private"}
|
||
|
||
|
||
@app.post("/api/kbs/{kid}/fork")
|
||
def fork_knowledge_base(kid: int, s: Session = Depends(get_session), request: Request = None):
|
||
source, actor = _require_fork_source(s, KnowledgeBase, kid, request)
|
||
clone = KnowledgeBase(name=_fork_label(s, KnowledgeBase, source.name, actor.id), desc=source.desc,
|
||
chunk_target=source.chunk_target, chunk_mode=source.chunk_mode,
|
||
organization_id=actor.organization_id, owner_user_id=actor.id, visibility="private")
|
||
s.add(clone); s.commit(); s.refresh(clone)
|
||
# 复制源目录及文件记录,防止用户编辑/删除副本时修改全局文件。
|
||
if source.source_dir and Path(source.source_dir).exists():
|
||
target = KB_DIR / f"{clone.id}_{re.sub(r'[^a-zA-Z0-9_-]+', '_', clone.name)[:60]}"
|
||
shutil.copytree(source.source_dir, target, dirs_exist_ok=True)
|
||
clone.source_dir = str(target)
|
||
clone.file_count = source.file_count; clone.chunk_count = source.chunk_count; clone.index_status = source.index_status
|
||
s.add(clone)
|
||
categories = s.exec(select(KnowledgeCategory).where(KnowledgeCategory.kb_id == kid).order_by(KnowledgeCategory.id)).all()
|
||
category_ids = {}
|
||
for cat in categories:
|
||
copied = KnowledgeCategory(kb_id=clone.id, parent_id=category_ids.get(cat.parent_id), name=cat.name,
|
||
desc=cat.desc, sort_order=cat.sort_order)
|
||
s.add(copied); s.flush(); category_ids[cat.id] = copied.id
|
||
for item in s.exec(select(KnowledgeFile).where(KnowledgeFile.kb_id == kid)).all():
|
||
s.add(KnowledgeFile(kb_id=clone.id, file_name=item.file_name,
|
||
category_id=category_ids.get(item.category_id),
|
||
ai_category_id=category_ids.get(item.ai_category_id),
|
||
classification_status=item.classification_status,
|
||
ai_confidence=item.ai_confidence, ai_reason=item.ai_reason,
|
||
content_hash=item.content_hash, revision=item.revision))
|
||
s.commit()
|
||
try:
|
||
rag_pipeline.rebuild_kb(s, int(clone.id))
|
||
except Exception:
|
||
# 副本仍可编辑;索引状态会在首次使用时重建。
|
||
pass
|
||
_register_fork(s, "knowledge_base", source, clone, actor)
|
||
return {"id": clone.id, "source_id": kid, "visibility": "private"}
|
||
|
||
|
||
@app.post("/api/canvases/{cid}/fork")
|
||
def fork_canvas(cid: int, s: Session = Depends(get_session), request: Request = None):
|
||
source, actor = _require_fork_source(s, Canvas, cid, request)
|
||
clone = Canvas(name=_fork_label(s, Canvas, source.name, actor.id), nodes=list(source.nodes or []), edges=list(source.edges or []),
|
||
supervisor_agent_id=source.supervisor_agent_id, supervisor_mode=source.supervisor_mode,
|
||
organization_id=actor.organization_id, owner_user_id=actor.id, visibility="private")
|
||
s.add(clone); s.commit(); s.refresh(clone)
|
||
_register_fork(s, "canvas", source, clone, actor)
|
||
return {"id": clone.id, "source_id": cid, "visibility": "private"}
|
||
|
||
|
||
@app.post("/api/memories/{mid}/fork")
|
||
def fork_memory(mid: int, s: Session = Depends(get_session), request: Request = None):
|
||
source, actor = _require_fork_source(s, Memory, mid, request)
|
||
clone = Memory(agent_id=source.agent_id, layer=source.layer, title=f"{source.title}(我的副本)",
|
||
description=source.description, content=source.content, source_ref=source.source_ref,
|
||
privacy=source.privacy, tags=list(source.tags or []), status=source.status,
|
||
organization_id=actor.organization_id, owner_user_id=actor.id, visibility="private")
|
||
s.add(clone); s.commit(); s.refresh(clone)
|
||
_register_fork(s, "memory", source, clone, actor)
|
||
return {"id": clone.id, "source_id": mid, "visibility": "private"}
|
||
|
||
|
||
@app.post("/api/digital-humans/{dhid}/fork")
|
||
def fork_digital_human(dhid: int, s: Session = Depends(get_session), request: Request = None):
|
||
source, actor = _require_fork_source(s, DigitalHuman, dhid, request)
|
||
clone = DigitalHuman(name=f"{source.name}(我的副本)", desc=source.desc,
|
||
persona=dict(source.persona or {}), persona_meta=dict(source.persona_meta or {}),
|
||
status=source.status, version=source.version,
|
||
source_files=list(source.source_files or []), last_feedback=source.last_feedback,
|
||
organization_id=actor.organization_id, owner_user_id=actor.id, visibility="private")
|
||
s.add(clone); s.commit(); s.refresh(clone)
|
||
_register_fork(s, "digital_human", source, clone, actor)
|
||
return {"id": clone.id, "source_id": dhid, "visibility": "private"}
|
||
|
||
|
||
@app.get("/api/admin/knowledge-bases")
|
||
def list_admin_knowledge_bases(s: Session = Depends(get_session), request: Request = None):
|
||
organization_id = int(_tenant_user(request).organization_id or 1) if request else None
|
||
rows = []
|
||
query = select(KnowledgeBase).order_by(KnowledgeBase.id.desc())
|
||
if organization_id is not None:
|
||
query = query.where(KnowledgeBase.organization_id.in_([0, organization_id]))
|
||
for kb in s.exec(query).all():
|
||
grants = s.exec(select(KnowledgeBaseGrant).where(
|
||
KnowledgeBaseGrant.kb_id == kb.id).order_by(KnowledgeBaseGrant.id)).all()
|
||
if organization_id is not None:
|
||
grants = [grant for grant in grants if _grantable_user(s.get(AppUser, grant.user_id)) and
|
||
s.get(AppUser, grant.user_id).organization_id == organization_id]
|
||
else:
|
||
grants = [grant for grant in grants if _grantable_user(s.get(AppUser, grant.user_id))]
|
||
rows.append({"id": kb.id, "name": kb.name, "desc": kb.desc,
|
||
"organization_id": kb.organization_id, **owner_fields(s, kb.owner_user_id),
|
||
"visibility": kb.visibility, "index_status": kb.index_status,
|
||
"file_count": kb.file_count, "chunk_count": kb.chunk_count,
|
||
"grants": [_grant_payload(item, s.get(AppUser, item.user_id)) for item in grants]})
|
||
return rows
|
||
|
||
|
||
@app.post("/api/admin/knowledge-bases/{kid}/grants")
|
||
def grant_knowledge_base(kid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
kb = s.get(KnowledgeBase, kid)
|
||
if not kb or not _same_tenant(kb, _tenant_user(request)):
|
||
raise HTTPException(404, "知识库不存在")
|
||
try:
|
||
user_id = int(body.get("user_id"))
|
||
except (TypeError, ValueError) as exc:
|
||
raise HTTPException(422, "user_id 必须是有效用户") from exc
|
||
user = s.get(AppUser, user_id)
|
||
actor = _tenant_user(request)
|
||
if not _grantable_user(user, actor):
|
||
raise HTTPException(404, "用户不存在或已停用")
|
||
permission = str(body.get("permission") or "use").strip().lower()
|
||
if permission not in _GRANT_PERMISSIONS:
|
||
raise HTTPException(422, "permission 必须是 read、use、manage 或 export")
|
||
grant = s.exec(select(KnowledgeBaseGrant).where(
|
||
KnowledgeBaseGrant.kb_id == kid, KnowledgeBaseGrant.user_id == user_id)).first()
|
||
if not grant:
|
||
grant = KnowledgeBaseGrant(kb_id=kid, user_id=user_id, permission=permission)
|
||
else:
|
||
grant.permission = permission
|
||
grant.enabled = True
|
||
if kb.visibility != "global":
|
||
kb.visibility = "shared"
|
||
s.add(grant); s.add(kb); s.commit(); s.refresh(grant)
|
||
return _grant_payload(grant, user)
|
||
|
||
|
||
@app.delete("/api/admin/knowledge-bases/{kid}/grants/{grant_id}")
|
||
def revoke_knowledge_base(kid: int, grant_id: int, s: Session = Depends(get_session), request: Request = None):
|
||
kb = s.get(KnowledgeBase, kid)
|
||
grant = s.get(KnowledgeBaseGrant, grant_id)
|
||
if not kb or not _same_tenant(kb, _tenant_user(request)) or not grant or grant.kb_id != kid:
|
||
raise HTTPException(404, "授权记录不存在")
|
||
s.delete(grant); s.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/admin/knowledge-bases/{kid}/publish-global")
|
||
def publish_knowledge_base_global(kid: int, s: Session = Depends(get_session), request: Request = None):
|
||
kb = s.get(KnowledgeBase, kid)
|
||
if not kb or not _same_tenant(kb, _tenant_user(request)):
|
||
raise HTTPException(404, "知识库不存在")
|
||
kb.visibility = "global"
|
||
s.add(kb); s.commit()
|
||
return {"ok": True, "visibility": kb.visibility}
|
||
|
||
@app.get("/api/admin/knowledge-bases/{kid}/export")
|
||
def export_knowledge_base(kid: int, s: Session = Depends(get_session), request: Request = None):
|
||
kb = s.get(KnowledgeBase, kid)
|
||
if not kb or not _same_tenant(kb, _tenant_user(request)):
|
||
raise HTTPException(404, "知识库不存在")
|
||
_deny_global_user_export(kb, request, s)
|
||
payload = io.BytesIO()
|
||
with zipfile.ZipFile(payload, "w", zipfile.ZIP_DEFLATED) as archive:
|
||
archive.writestr("metadata.json", json.dumps({"id": kb.id, "name": kb.name,
|
||
"desc": kb.desc, "visibility": kb.visibility,
|
||
"file_count": kb.file_count}, ensure_ascii=False, indent=2))
|
||
source = Path(kb.source_dir).resolve() if kb.source_dir else None
|
||
root = KB_DIR.resolve()
|
||
if source and source.is_dir() and root in source.parents:
|
||
for path in source.rglob("*"):
|
||
if path.is_file() and path.suffix.lower() in rag_pipeline.SUPPORTED_SUFFIXES:
|
||
archive.write(path, f"files/{path.name}")
|
||
payload.seek(0)
|
||
return StreamingResponse(payload, media_type="application/zip",
|
||
headers={"Content-Disposition": f'attachment; filename="kb-{kid}.zip"'})
|
||
|
||
# ---------------- 文件夹批量导入(zip,含音视频转写,后台执行) ----------------
|
||
|
||
_import_jobs: dict[str, dict] = {}
|
||
_import_jobs_lock = __import__("threading").Lock()
|
||
|
||
|
||
def _run_folder_import(job_id: str, kid: int, zip_path: str, category_id: int | None):
|
||
import shutil as _shutil
|
||
import threading as _threading # noqa: F401
|
||
from sqlmodel import Session as _Session
|
||
from app.db import engine as _engine
|
||
from app.rag import pipeline as _pipeline
|
||
|
||
def report_progress(name: str, n: int):
|
||
with _import_jobs_lock:
|
||
job = _import_jobs.get(job_id)
|
||
if job is not None:
|
||
job["current"] = name
|
||
job["files_done"] = job.get("files_done", 0) + (1 if n >= 0 else 0)
|
||
total = job.get("total") or 0
|
||
done = job.get("files_done", 0)
|
||
else:
|
||
total, done = 0, 0
|
||
# 实时进度事件(前端 SSE/轮询)
|
||
try:
|
||
from .models import KnowledgeBase as _KB
|
||
from sqlmodel import select as _select
|
||
with _Session(_engine) as _s:
|
||
kb_row = _s.exec(_select(_KB).where(_KB.id == kid)).first()
|
||
owner = int(kb_row.owner_user_id or 0) if kb_row else 0
|
||
_progress.publish(owner, "kb_upload", kb_id=kid, job_id=job_id,
|
||
current=name, files_done=done, total=total,
|
||
pct=int(done * 100 / total) if total else 0)
|
||
except Exception:
|
||
pass
|
||
|
||
workdir = KB_DIR / "folder_import" / job_id
|
||
try:
|
||
workdir.mkdir(parents=True, exist_ok=True)
|
||
import zipfile as _zipfile
|
||
with _zipfile.ZipFile(zip_path) as zf:
|
||
# 防 zip 炸弹 / 路径穿越
|
||
bad = [n for n in zf.namelist() if n.startswith("/") or ".." in Path(n).parts]
|
||
if bad:
|
||
raise ValueError(f"压缩包内含非法路径: {bad[:3]}")
|
||
zf.extractall(workdir)
|
||
# 递归收集全部文件
|
||
entries = []
|
||
for path in sorted(workdir.rglob("*")):
|
||
if path.is_file() and "__MACOSX" not in str(path) and not path.name.startswith("._"):
|
||
rel = path.relative_to(workdir)
|
||
entries.append(f"{rel}\t{path}")
|
||
with _import_jobs_lock:
|
||
_import_jobs[job_id]["total"] = len(entries)
|
||
if not entries:
|
||
raise ValueError("压缩包里没有可导入的文件")
|
||
with _Session(_engine) as session:
|
||
pair_list = [(line.rsplit("\t", 1)[0], line.rsplit("\t", 1)[1]) for line in entries]
|
||
res = _pipeline.import_kb_files(session, kid, [abs_path for _, abs_path in pair_list], progress_cb=report_progress)
|
||
if category_id is not None and res.get("imported_files", 0):
|
||
for _, abs_path in pair_list:
|
||
if Path(abs_path).name not in res.get("imported_file_names", []):
|
||
continue
|
||
record = session.exec(select(KnowledgeFile).where(
|
||
KnowledgeFile.kb_id == kid,
|
||
KnowledgeFile.file_name == Path(abs_path).name)).first()
|
||
if record:
|
||
record.category_id = category_id
|
||
session.add(record)
|
||
session.commit()
|
||
_pipeline.rebuild_kb(session, kid)
|
||
with _import_jobs_lock:
|
||
_import_jobs[job_id].update(status="done", result=res,
|
||
done_at=__import__("datetime").datetime.now().isoformat(timespec="seconds"))
|
||
except Exception as exc:
|
||
with _import_jobs_lock:
|
||
_import_jobs[job_id].update(status="error", error=str(exc)[:300])
|
||
finally:
|
||
_shutil.rmtree(workdir, ignore_errors=True)
|
||
Path(zip_path).unlink(missing_ok=True)
|
||
|
||
|
||
@app.post("/api/kbs/{kid}/import-folder")
|
||
async def import_kb_folder(kid: int, file: UploadFile = File(...), category_id: int | None = Form(None),
|
||
s: Session = Depends(get_session), request: Request = None):
|
||
require_kb(s, kid, request, "manage")
|
||
_validate_category(s, kid, category_id)
|
||
if not (file.filename or "").lower().endswith(".zip"):
|
||
raise HTTPException(415, "请上传 zip 压缩包(把整个文件夹压成 zip 拖进来)")
|
||
KB_DIR.mkdir(parents=True, exist_ok=True)
|
||
job_id = uuid.uuid4().hex[:12]
|
||
zip_path = KB_DIR / "uploads" / f"folder_{job_id}.zip"
|
||
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
||
# 局域网直连 10GB;公网(CF)90MB
|
||
max_bytes = _upload_limit_bytes(request)
|
||
size = 0
|
||
with open(zip_path, "wb") as out:
|
||
while chunk := await file.read(8 * 1024 * 1024):
|
||
size += len(chunk)
|
||
if size > max_bytes:
|
||
out.close()
|
||
zip_path.unlink(missing_ok=True)
|
||
raise HTTPException(413, f"压缩包不能超过 {max_bytes // (1024*1024)}MB(当前网络通道上限),请分卷上传")
|
||
out.write(chunk)
|
||
if size == 0:
|
||
zip_path.unlink(missing_ok=True)
|
||
raise HTTPException(422, "压缩包是空的")
|
||
with _import_jobs_lock:
|
||
_import_jobs[job_id] = {"status": "running", "kb_id": kid, "total": 0,
|
||
"current": "解压中…", "files_done": 0, "created": time.time()}
|
||
__import__("threading").Thread(target=_run_folder_import,
|
||
args=(job_id, kid, str(zip_path), category_id), daemon=True).start()
|
||
return {"job_id": job_id}
|
||
|
||
|
||
@app.get("/api/kbs/{kid}/import-progress/{job_id}")
|
||
def import_kb_progress(kid: int, job_id: str, s: Session = Depends(get_session), request: Request = None):
|
||
require_kb(s, kid, request, "manage")
|
||
with _import_jobs_lock:
|
||
job = dict(_import_jobs.get(job_id) or {})
|
||
if not job or job.get("kb_id") != kid:
|
||
raise HTTPException(404, "任务不存在或已过期")
|
||
return job
|
||
|
||
|
||
def _convert_doc_to_docx(path: Path) -> Path:
|
||
"""老版 Word .doc → .docx:macOS 用 textutil,其他平台尝试 libreoffice。返回 docx 路径(原 doc 保留)。"""
|
||
out = path.with_suffix('.docx')
|
||
if out.exists():
|
||
return out
|
||
if shutil.which('textutil'):
|
||
r = subprocess.run(['textutil', '-convert', 'docx', str(path), '-output', str(out)],
|
||
capture_output=True, text=True, timeout=120)
|
||
if r.returncode == 0 and out.exists():
|
||
return out
|
||
raise ValueError(f'.doc 转换失败:{r.stderr[:150]}')
|
||
if shutil.which('libreoffice') or shutil.which('soffice'):
|
||
soffice = shutil.which('libreoffice') or shutil.which('soffice')
|
||
r = subprocess.run([soffice, '--headless', '--convert-to', 'docx', '--outdir',
|
||
str(path.parent), str(path)], capture_output=True, text=True, timeout=180)
|
||
if out.exists():
|
||
return out
|
||
raise ValueError(f'.doc 转换失败:{r.stderr[:150]}')
|
||
raise ValueError('服务器缺少 .doc 转换工具(textutil/libreoffice)')
|
||
|
||
|
||
@app.post("/api/utils/extract-text")
|
||
async def extract_text(files: list[UploadFile] = File(...), request: Request = None):
|
||
"""通用文档抽文本:docx/doc/pdf/txt/md/srt → 纯文本(导图导入等前端场景用)。"""
|
||
import io
|
||
results = []
|
||
for f in files:
|
||
safe = Path(f.filename or "file").name
|
||
suffix = Path(safe).suffix.lower()
|
||
raw = await f.read(20 * 1024 * 1024 + 1)
|
||
text = ""
|
||
if suffix == ".docx":
|
||
import docx as _docx
|
||
doc = _docx.Document(io.BytesIO(raw))
|
||
text = "\n".join(x.text for x in doc.paragraphs if x.text and x.text.strip())
|
||
elif suffix == ".doc":
|
||
tmp_doc = Path(KB_DIR / "uploads") / f"ex_{uuid.uuid4().hex[:8]}.doc"
|
||
tmp_doc.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp_doc.write_bytes(raw)
|
||
try:
|
||
text = _convert_doc_to_docx(tmp_doc).read_text(errors="ignore")[:1] # 转换成功后用 docx 读
|
||
import docx as _docx
|
||
doc = _docx.Document(str(_convert_doc_to_docx(tmp_doc)))
|
||
text = "\n".join(x.text for x in doc.paragraphs if x.text and x.text.strip())
|
||
except ValueError as exc:
|
||
raise HTTPException(422, str(exc))
|
||
finally:
|
||
tmp_doc.unlink(missing_ok=True)
|
||
elif suffix == ".pdf":
|
||
import pypdf as _pdf
|
||
reader = _pdf.PdfReader(io.BytesIO(raw))
|
||
text = "\n".join((pg.extract_text() or "") for pg in reader.pages)
|
||
else:
|
||
for enc in ("utf-8", "gb18030", "utf-16"):
|
||
try:
|
||
text = raw.decode(enc)
|
||
break
|
||
except (UnicodeDecodeError, UnicodeError):
|
||
continue
|
||
results.append({"file": safe, "text": text.strip()[:60000]})
|
||
return {"results": results}
|
||
|
||
|
||
def _is_lan_client(request: Request) -> bool:
|
||
"""局域网/本机直连客户端(不经过 Cloudflare)→ 传输限制大幅放宽。"""
|
||
client_ip = request.client.host if request.client else ""
|
||
if client_ip in ("127.0.0.1", "::1", "localhost"):
|
||
return True
|
||
# RFC1918 私网段
|
||
import ipaddress as _ip
|
||
try:
|
||
ip = _ip.ip_address(client_ip)
|
||
return ip.is_private
|
||
except ValueError:
|
||
return False
|
||
|
||
|
||
def _upload_limit_bytes(request: Request, base: int = 90 * 1024 * 1024) -> int:
|
||
"""内网直连:10GB;公网(经 CF):90MB。"""
|
||
return 10 * 1024 * 1024 * 1024 if _is_lan_client(request) else base
|
||
|
||
|
||
# ---------- 分块上传(公网大文件:绕开单请求大小限制,支持 20GB) ----------
|
||
import uuid as _uuid
|
||
_CHUNK_UPLOADS: dict = {} # compatibility cache; metadata is persisted below
|
||
_CHUNK_UPLOADS_LOCK = __import__("threading").Lock()
|
||
_CHUNK_SIZE_DEFAULT = 8 * 1024 * 1024
|
||
# Cloudflare's per-request limit is well below this chunk size. The total
|
||
# object may be much larger because it is assembled server-side, up to 20 GiB.
|
||
_CHUNK_MAX_BYTES = 20 * 1024 * 1024 * 1024
|
||
_CHUNK_STATE = KB_DIR / "uploads" / "chunked-sessions.json"
|
||
def _safe_upload_name(name):
|
||
return Path(str(name or "file")).name
|
||
|
||
def _restore_chunk_job(uid: str):
|
||
"""Restore a resumable session after a worker restart (metadata is JSON)."""
|
||
try:
|
||
state = json.loads(_CHUNK_STATE.read_text()) if _CHUNK_STATE.exists() else {}
|
||
raw = state.get(uid)
|
||
if not isinstance(raw, dict):
|
||
return None
|
||
tmp = Path(str(raw.get("tmp") or "")).resolve()
|
||
upload_root = (KB_DIR / "uploads").resolve()
|
||
if upload_root not in tmp.parents or not tmp.exists():
|
||
return None
|
||
raw["received"] = set(int(i) for i in (raw.get("received") or []))
|
||
return raw
|
||
except Exception:
|
||
return None
|
||
|
||
def _atomic_json_write(path, data):
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||
tmp.write_text(json.dumps(data, ensure_ascii=False, sort_keys=True))
|
||
with open(tmp, "rb") as f: os.fsync(f.fileno())
|
||
tmp.replace(path)
|
||
|
||
def _forget_chunk_job(uid: str):
|
||
try:
|
||
with _CHUNK_UPLOADS_LOCK:
|
||
state = json.loads(_CHUNK_STATE.read_text()) if _CHUNK_STATE.exists() else {}
|
||
if uid in state:
|
||
state.pop(uid, None)
|
||
_atomic_json_write(_CHUNK_STATE, state)
|
||
except Exception:
|
||
pass
|
||
|
||
def _persist_chunk_job(uid: str, job: dict):
|
||
"""Merge one session into the state file without clobbering other uploads."""
|
||
try:
|
||
with _CHUNK_UPLOADS_LOCK:
|
||
state = json.loads(_CHUNK_STATE.read_text()) if _CHUNK_STATE.exists() else {}
|
||
state[uid] = {k: v for k, v in job.items() if k != "received"} | {
|
||
"received": sorted(job.get("received") or [])
|
||
}
|
||
_atomic_json_write(_CHUNK_STATE, state)
|
||
except Exception:
|
||
# The in-memory session remains usable; a transient state-file failure
|
||
# must not turn a successfully written chunk into a failed request.
|
||
pass
|
||
def _validate_chunk(meta, index, size, offset):
|
||
if index in meta["received"] or index != len(meta["received"]): raise HTTPException(409, "分块重复或乱序")
|
||
if index < 0 or index >= meta.get("total_chunks", 1): raise HTTPException(409, "分块编号无效")
|
||
expected = min(_CHUNK_SIZE_DEFAULT, meta["size"] - offset)
|
||
if offset + size > meta["size"] or (index == meta.get("total_chunks", 1) - 1 and offset + size != meta["size"]): raise HTTPException(409, "分块总大小不一致")
|
||
if offset != meta.get("received_bytes", 0) or size <= 0 or size > expected: raise HTTPException(409, "分块 offset/size 无效")
|
||
return True
|
||
|
||
|
||
@app.post("/api/uploads/init")
|
||
def chunk_upload_init(body: dict, request: Request = None, s: Session = Depends(get_session)):
|
||
if not request_user(request):
|
||
raise HTTPException(401, "请先登录")
|
||
filename = _safe_upload_name(body.get("filename") or "file").strip()
|
||
try:
|
||
total_size = int(body.get("size") or 0)
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(422, "文件大小参数不正确")
|
||
total_chunks = math.ceil(total_size / _CHUNK_SIZE_DEFAULT) if total_size else 0
|
||
purpose = str(body.get("purpose") or "kb") # kb | distill
|
||
if not filename or total_size <= 0:
|
||
raise HTTPException(422, "缺少文件名")
|
||
if purpose not in {"kb", "distill"}:
|
||
raise HTTPException(422, "上传用途不正确")
|
||
if total_size > _CHUNK_MAX_BYTES:
|
||
raise HTTPException(413, "单文件不能超过 20GB")
|
||
selected_kb = int(body["kb_id"]) if str(body.get("kb_id") or "").isdigit() else None
|
||
raw_fields = body.get("fields") or {}
|
||
if not isinstance(raw_fields, dict):
|
||
raise HTTPException(422, "上传附加参数不正确")
|
||
if purpose == "kb":
|
||
if not selected_kb:
|
||
raise HTTPException(422, "请选择要导入的知识库")
|
||
require_kb(s, selected_kb, request, "manage")
|
||
elif raw_fields.get("digital_human_id") or raw_fields.get("redistill_id"):
|
||
target_id = raw_fields.get("digital_human_id") or raw_fields.get("redistill_id")
|
||
if not str(target_id).isdigit() or int(target_id) <= 0:
|
||
raise HTTPException(422, "请选择要重新蒸馏的数字人")
|
||
_dh_manage(s, int(target_id), request)
|
||
uid = _uuid.uuid4().hex[:16]
|
||
tmp = KB_DIR / "uploads" / f"chunked_{uid}.part"
|
||
tmp.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp.write_bytes(b"")
|
||
with _CHUNK_UPLOADS_LOCK:
|
||
_CHUNK_UPLOADS[uid] = {"filename": filename, "size": total_size, "total_chunks": total_chunks,
|
||
"purpose": purpose, "tmp": str(tmp), "received": set(), "received_bytes": 0,
|
||
"user_id": int(request_user(request).id),
|
||
# Keep the selected KB and distill form fields with
|
||
# the upload session; the finish request has no body.
|
||
"kb_id": selected_kb,
|
||
"fields": {str(k): str(v)[:1000] for k, v in raw_fields.items()}}
|
||
return {"upload_id": uid, "chunk_size": _CHUNK_SIZE_DEFAULT}
|
||
|
||
|
||
@app.post("/api/uploads/{uid}/chunk/{index}")
|
||
async def chunk_upload_put(uid: str, index: int, request: Request,
|
||
data: UploadFile = File(...), s: Session = Depends(get_session)):
|
||
if not request_user(request):
|
||
raise HTTPException(401, "请先登录")
|
||
with _CHUNK_UPLOADS_LOCK:
|
||
job = _CHUNK_UPLOADS.get(uid)
|
||
if not job:
|
||
job = _restore_chunk_job(uid)
|
||
if job:
|
||
_CHUNK_UPLOADS[uid] = job
|
||
if not job:
|
||
raise HTTPException(404, "上传会话不存在或已过期")
|
||
user=request_user(request)
|
||
if int(job.get("user_id", -1)) != int(user.id): raise HTTPException(403, "上传会话不属于当前用户")
|
||
offset=int(request.headers.get("X-Chunk-Offset", job.get("received_bytes",0)))
|
||
raw = await data.read(_CHUNK_SIZE_DEFAULT + 1024)
|
||
if len(raw) > _CHUNK_SIZE_DEFAULT:
|
||
raise HTTPException(413, "分块过大")
|
||
_validate_chunk(job,index,len(raw),offset)
|
||
with open(job["tmp"], "ab") as out:
|
||
out.write(raw)
|
||
out.flush(); os.fsync(out.fileno())
|
||
job["received"].append if False else job["received"].add(index)
|
||
job["received_bytes"] += len(raw)
|
||
_persist_chunk_job(uid, job)
|
||
return {"ok": True, "received": len(job["received"])}
|
||
|
||
|
||
@app.post("/api/uploads/{uid}/finish")
|
||
def chunk_upload_finish(uid: str, request: Request = None, s: Session = Depends(get_session)):
|
||
user = request_user(request)
|
||
if not user:
|
||
raise HTTPException(401, "请先登录")
|
||
with _CHUNK_UPLOADS_LOCK:
|
||
job = _CHUNK_UPLOADS.pop(uid, None)
|
||
if not job:
|
||
job = _restore_chunk_job(uid)
|
||
if not job:
|
||
raise HTTPException(404, "上传会话不存在")
|
||
if int(job.get("user_id", -1)) != int(user.id):
|
||
with _CHUNK_UPLOADS_LOCK:
|
||
_CHUNK_UPLOADS[uid] = job
|
||
raise HTTPException(403, "上传会话不属于当前用户")
|
||
tmp = Path(job["tmp"])
|
||
if len(job.get("received", set())) != job.get("total_chunks", 1) or (job["size"] and tmp.stat().st_size != job["size"]):
|
||
# 允许继续传缺失块:文件保留,提示重传
|
||
with _CHUNK_UPLOADS_LOCK:
|
||
_CHUNK_UPLOADS[uid] = job
|
||
raise HTTPException(409, f"分块不完整(已收 {tmp.stat().st_size}/{job['size']} 字节),请重试 finish 或重新上传缺失块")
|
||
suffix = Path(job["filename"]).suffix.lower()
|
||
purpose = job["purpose"]
|
||
if purpose == "distill":
|
||
# 转给数字人蒸馏管线:读取文本→AI(复用既有 analyze 思路,文本量可能大,截断)
|
||
text = ""
|
||
try:
|
||
if suffix == ".docx":
|
||
import docx as _docx
|
||
doc = _docx.Document(str(tmp))
|
||
text = "\n".join(x.text for x in doc.paragraphs if x.text.strip())
|
||
elif suffix == ".doc":
|
||
tmp_docx = _convert_doc_to_docx(tmp)
|
||
import docx as _docx2
|
||
doc = _docx2.Document(str(tmp_docx))
|
||
text = "\n".join(x.text for x in doc.paragraphs if x.text.strip())
|
||
tmp_docx.unlink(missing_ok=True)
|
||
elif suffix == ".pdf":
|
||
import pypdf as _pdf
|
||
reader = _pdf.PdfReader(str(tmp))
|
||
text = "\n".join((pg.extract_text() or "") for pg in reader.pages)
|
||
else:
|
||
# 蒸馏模型最终只接收约 30k 字;不要把用户上传的 20GB
|
||
# 文本一次性读进内存,避免公网分片上传完成后进程被 OOM 杀掉。
|
||
with tmp.open("r", encoding="utf-8", errors="ignore") as fh:
|
||
text = fh.read(120000)
|
||
except Exception as exc:
|
||
raise HTTPException(422, f"文档解析失败:{str(exc)[:200]}")
|
||
from .rag.parse import clean_text as _clean_source_text
|
||
text = _clean_source_text(text, remove_fillers=False)
|
||
if not text.strip():
|
||
raise HTTPException(422, "文件里没有可蒸馏的文字(扫描版不支持)")
|
||
fields = job.get("fields") or {}
|
||
name = str(fields.get("name") or Path(job["filename"]).stem).strip()
|
||
desc = str(fields.get("desc") or "").strip()
|
||
visibility = str(fields.get("visibility") or "private")
|
||
if visibility not in {"private", "shared", "global"}:
|
||
visibility = "private"
|
||
target_id = fields.get("digital_human_id") or fields.get("redistill_id")
|
||
if target_id and (not str(target_id).isdigit() or int(target_id) <= 0):
|
||
raise HTTPException(422, "请选择要重新蒸馏的数字人")
|
||
target = _dh_manage(s, int(target_id), request)[0] if target_id else None
|
||
sources = _unique_source_names(list(target.source_files or []) + [job["filename"]]) if target else [job["filename"]]
|
||
prior_material = str((target.persona_meta or {}).get("source_material") or "").strip() if target else ""
|
||
distill_samples = ([prior_material] if prior_material else []) + [_representative_sample(text, 15000)]
|
||
dh = _dh_distill(s, int(user.organization_id or 1), int(user.id or 0),
|
||
distill_samples, sources, advice=str(fields.get("advice") or ""),
|
||
previous_persona=target.persona if target else None)
|
||
if target:
|
||
target.persona = dh
|
||
target.persona_meta = _distill_meta(
|
||
[text], sources, str(fields.get("advice") or ""),
|
||
previous_meta=target.persona_meta,
|
||
batch_sources=[job["filename"]],
|
||
)
|
||
target.version += 1
|
||
target.status = "testing"
|
||
target.source_files = _unique_source_names(sources)
|
||
target.last_feedback = str(fields.get("advice") or "")[:500]
|
||
s.add(target); s.commit(); s.refresh(target)
|
||
tmp.unlink(missing_ok=True)
|
||
_forget_chunk_job(uid)
|
||
return {"purpose": "distill", **_dh_persona_result(target)}
|
||
from .models import DigitalHuman as DH
|
||
row = DH(name=(name or "未命名")[:60], desc=desc[:300], persona=dh,
|
||
persona_meta=_distill_meta([text], [job["filename"]], str(fields.get("advice") or "")), status="testing", version=1,
|
||
source_files=[job["filename"]], organization_id=int(user.organization_id or 1),
|
||
owner_user_id=int(user.id or 0), visibility=visibility_for_create(request, visibility))
|
||
s.add(row); s.commit(); s.refresh(row)
|
||
tmp.unlink(missing_ok=True)
|
||
_forget_chunk_job(uid)
|
||
return {"purpose": "distill", "id": row.id, "persona": row.persona, "version": 1}
|
||
else:
|
||
# KB 导入:挪后台(嵌入大文件耗时数分钟)
|
||
kb_id = job.get("kb_id")
|
||
kb_row = s.get(KnowledgeBase, int(kb_id)) if kb_id else None
|
||
if not kb_row:
|
||
raise HTTPException(422, "请选择要导入的知识库")
|
||
require_kb(s, int(kb_id), request, "manage")
|
||
kb_id = kb_row.id
|
||
job_id2 = _uuid.uuid4().hex[:12]
|
||
with _import_jobs_lock:
|
||
_import_jobs[job_id2] = {"status": "running", "kb_id": kb_id, "total": 1,
|
||
"current": "分块上传完成,正在解析嵌入…", "files_done": 0, "created": time.time()}
|
||
|
||
def _bg_chunk_import(_tmp=str(tmp), fname=job["filename"], kb=kb_id, jid=job_id2):
|
||
from sqlmodel import Session as _S
|
||
bg = _S(_engine(), expire_on_commit=False)
|
||
staging = KB_DIR / "uploads" / ("chunk-import-" + jid)
|
||
try:
|
||
staging.mkdir(parents=True, exist_ok=True)
|
||
dest = staging / Path(fname).name
|
||
shutil.move(_tmp, str(dest))
|
||
r = rag_pipeline.import_kb_files(bg, kb, [str(dest)])
|
||
with _import_jobs_lock:
|
||
_import_jobs[jid].update(status="done", result=r,
|
||
done_at=__import__("datetime").datetime.now().isoformat(timespec="seconds"))
|
||
except Exception as exc:
|
||
with _import_jobs_lock:
|
||
_import_jobs[jid].update(status="error", error=str(exc)[:300])
|
||
|
||
finally:
|
||
bg.close()
|
||
shutil.rmtree(staging, ignore_errors=True)
|
||
|
||
threading.Thread(target=_bg_chunk_import, daemon=True).start()
|
||
_forget_chunk_job(uid)
|
||
return {"purpose": "kb", "kb_id": kb_id, "background": True, "job_id": job_id2,
|
||
"message": "上传完成,正在后台解析入库(可在知识库上传进度中查看)"}
|
||
|
||
|
||
@app.post("/api/kbs/{kid}/import")
|
||
async def import_kb(kid: int, files: list[UploadFile] = File(...), category_id: int | None = Form(None),
|
||
s: Session = Depends(get_session), request: Request = None):
|
||
k = require_kb(s, kid, request, "manage")
|
||
_validate_category(s, kid, category_id)
|
||
tmp: list[str] = []
|
||
background_started = False
|
||
# 每批上传使用独立临时目录;文件名只取 basename,避免路径穿越和同名覆盖。
|
||
tmp_dir = KB_DIR / "uploads" / uuid.uuid4().hex
|
||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||
seen_names: set[str] = set()
|
||
max_upload_bytes = _upload_limit_bytes(request)
|
||
try:
|
||
for f in files:
|
||
safe_name = Path(f.filename or "upload.bin").name
|
||
if safe_name in ("", ".", "..") or safe_name in seen_names:
|
||
raise HTTPException(422, "上传文件名为空或同批次重复")
|
||
suffix = Path(safe_name).suffix.lower()
|
||
if suffix == ".doc":
|
||
safe_name = Path(safe_name).stem + ".doc" # 保持原名,转换后统一处理
|
||
if suffix not in rag_pipeline.SUPPORTED_SUFFIXES and suffix not in rag_pipeline.MEDIA_SUFFIXES:
|
||
raise HTTPException(415, f"不支持的文件类型:{safe_name}(支持 docx/doc/pdf/txt/md/srt 及音视频)")
|
||
payload = await f.read(max_upload_bytes + 1)
|
||
if len(payload) > max_upload_bytes:
|
||
raise HTTPException(413, f"单个文件不能超过 {max_upload_bytes // (1024 * 1024)}MB(公网 90MB;局域网直连可达 10GB)")
|
||
p = tmp_dir / safe_name
|
||
p.write_bytes(payload)
|
||
if suffix == ".doc":
|
||
try:
|
||
p = _convert_doc_to_docx(p) # 就地转 docx,后续管线无感
|
||
except ValueError as conv_exc:
|
||
raise HTTPException(422, str(conv_exc))
|
||
seen_names.add(safe_name)
|
||
tmp.append(str(p))
|
||
# 大文件走后台线程(公网网关 100s 超时保护):总字节 > 2MB 时立即返回 job_id
|
||
total_bytes = sum(Path(x).stat().st_size for x in tmp)
|
||
if total_bytes > 2 * 1024 * 1024:
|
||
job_id = uuid.uuid4().hex[:12]
|
||
with _import_jobs_lock:
|
||
_import_jobs[job_id] = {"status": "running", "kb_id": kid, "total": len(tmp),
|
||
"current": "排队中…", "files_done": 0, "created": time.time()}
|
||
|
||
def _bg_import(paths=list(tmp), cat=category_id, kb_id_=kid, job=job_id):
|
||
from sqlmodel import Session as _S
|
||
bg_session = _S(_engine(), expire_on_commit=False)
|
||
try:
|
||
r = rag_pipeline.import_kb_files(bg_session, kb_id_, paths)
|
||
if cat is not None and r.get("imported_files", 0):
|
||
for path in paths:
|
||
if Path(path).name not in r.get("imported_file_names", []):
|
||
continue
|
||
record = bg_session.exec(select(KnowledgeFile).where(
|
||
KnowledgeFile.kb_id == kb_id_,
|
||
KnowledgeFile.file_name == Path(path).name)).first()
|
||
if record:
|
||
record.category_id = cat
|
||
bg_session.add(record)
|
||
bg_session.commit()
|
||
with _import_jobs_lock:
|
||
_import_jobs[job].update(status="done", result=r,
|
||
done_at=__import__("datetime").datetime.now().isoformat(timespec="seconds"))
|
||
except Exception as bg_exc:
|
||
with _import_jobs_lock:
|
||
_import_jobs[job].update(status="error", error=str(bg_exc)[:300])
|
||
|
||
finally:
|
||
bg_session.close()
|
||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||
|
||
threading.Thread(target=_bg_import, daemon=True).start()
|
||
background_started = True
|
||
return {"job_id": job_id, "background": True, "files": len(tmp)}
|
||
res = rag_pipeline.import_kb_files(s, kid, tmp) # 小文件:同步增量(秒级)
|
||
if category_id is not None and res.get("imported_files", 0):
|
||
for path in tmp:
|
||
file_name = Path(path).name
|
||
if file_name not in res.get("imported_file_names", []):
|
||
continue
|
||
record = s.exec(select(KnowledgeFile).where(
|
||
KnowledgeFile.kb_id == kid, KnowledgeFile.file_name == file_name
|
||
)).first()
|
||
if record:
|
||
record.category_id = category_id
|
||
s.add(record)
|
||
s.commit()
|
||
return res
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
raise HTTPException(500, f"建库失败: {e}")
|
||
finally:
|
||
if not background_started:
|
||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||
|
||
@app.post("/api/kbs/{kid}/rebuild")
|
||
def rebuild_kb(kid: int, s: Session = Depends(get_session), request: Request = None):
|
||
require_kb(s, kid, request, "manage")
|
||
try:
|
||
return rag_pipeline.rebuild_kb(s, kid)
|
||
except Exception as e:
|
||
raise HTTPException(500, f"重建失败: {e}")
|
||
|
||
@app.get("/api/kbs/{kid}/search")
|
||
def search_kb(kid: int, q: str, k: int = 6, s: Session = Depends(get_session), request: Request = None):
|
||
require_kb(s, kid, request, "use")
|
||
import time as _t
|
||
_t0 = _t.time()
|
||
try:
|
||
organization_id, user_id = actor_fields(request)
|
||
result = rag_pipeline.search_kbs_remote(s, [kid], q, k, user_id=user_id,
|
||
organization_id=organization_id)
|
||
print(f"[search-timing] kb={kid} q={q[:20]} took {_t.time()-_t0:.1f}s hits={len(result)}", flush=True)
|
||
return result
|
||
except Exception as e:
|
||
raise HTTPException(500, f"检索失败: {e}")
|
||
|
||
@app.post("/api/kbs/search")
|
||
def search_multi(body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
try:
|
||
allowed = visible_kb_ids(s, request, "use")
|
||
kb_ids = [int(kid) for kid in (body.get("kb_ids") or []) if str(kid).isdigit() and int(kid) in allowed]
|
||
organization_id, user_id = actor_fields(request)
|
||
return rag_pipeline.search_kbs_remote(s, kb_ids, body.get("q", ""),
|
||
k=body.get("k", 6), user_id=user_id,
|
||
organization_id=organization_id)
|
||
except Exception as e:
|
||
raise HTTPException(500, f"检索失败: {e}")
|
||
|
||
# ---------- 导图 OCR ----------
|
||
@app.post("/api/ocr")
|
||
async def ocr_upload(file: UploadFile = File(...), s: Session = Depends(get_session), request: Request = None):
|
||
from .ocr import ocr_image
|
||
from .config import OCR_DIR
|
||
ext = Path(file.filename or "导图.png").suffix or ".png"
|
||
import uuid
|
||
p = OCR_DIR / f"upload_{uuid.uuid4().hex[:8]}{ext}"
|
||
payload = await file.read()
|
||
with open(p, "wb") as f:
|
||
f.write(payload)
|
||
r = ocr_image(str(p))
|
||
from .billing import record_usage_event
|
||
organization_id, user_id = actor_fields(request)
|
||
record_usage_event(s, idempotency_key=f"ocr:{hashlib.sha256(payload).hexdigest()}",
|
||
user_id=user_id, organization_id=organization_id,
|
||
service_type="ocr", unit="image", quantity=1,
|
||
status="measured", metadata={"filename": file.filename or ""})
|
||
s.commit()
|
||
return r
|
||
|
||
# ---------- 画布 ----------
|
||
@app.get("/api/canvases")
|
||
def list_canvases(s: Session = Depends(get_session), request: Request = None):
|
||
user = request_user(request)
|
||
return [{"id": c.id, "name": c.name, "nodes": c.nodes, "edges": c.edges,
|
||
"supervisor_agent_id": c.supervisor_agent_id, "supervisor_mode": c.supervisor_mode,
|
||
"organization_id": c.organization_id, **owner_fields(s, c.owner_user_id),
|
||
"visibility": c.visibility, "created_at": c.created_at}
|
||
for c in s.exec(select(Canvas).order_by(Canvas.id)).all()
|
||
if not _hidden_for_user(s, "canvas", int(c.id or 0), request)
|
||
and can_use_canvas(s, c, user, "read")]
|
||
|
||
@app.post("/api/canvases")
|
||
def create_canvas(body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
nodes, edges = body.get("nodes", []), body.get("edges", [])
|
||
if not isinstance(nodes, list) or not isinstance(edges, list):
|
||
raise HTTPException(422, "画布节点和连线必须是列表")
|
||
nodes, edges = _normalize_canvas(s, nodes, edges)
|
||
_validate_canvas(s, nodes, edges)
|
||
user = request_user(request)
|
||
if any(not can_access(s.get(Agent, node.get("agent_id")), user, "use") for node in nodes):
|
||
raise HTTPException(404, "画布包含当前用户不可使用的 Agent")
|
||
organization_id, owner_user_id = actor_fields(request)
|
||
supervisor_agent_id = body.get("supervisor_agent_id")
|
||
if supervisor_agent_id is not None:
|
||
supervisor_agent_id = int(supervisor_agent_id)
|
||
if not can_access(s.get(Agent, supervisor_agent_id), user, "use"):
|
||
raise HTTPException(404, "总监 Agent 不存在或当前用户不可使用")
|
||
supervisor_mode = str(body.get("supervisor_mode") or "on_drift")
|
||
if supervisor_mode not in {"always", "on_drift", "off"}:
|
||
raise HTTPException(422, "总监模式必须是 always、on_drift 或 off")
|
||
c = Canvas(name=_required_text(body.get("name", "新画布"), "画布名称"), nodes=nodes, edges=edges,
|
||
supervisor_agent_id=supervisor_agent_id, supervisor_mode=supervisor_mode,
|
||
organization_id=organization_id, owner_user_id=owner_user_id,
|
||
visibility=visibility_for_create(request, body.get("visibility", "private")))
|
||
s.add(c); s.commit(); s.refresh(c)
|
||
return {"id": c.id}
|
||
|
||
@app.get("/api/canvases/{cid}")
|
||
def get_canvas(cid: int, s: Session = Depends(get_session), request: Request = None):
|
||
c = require_canvas(s, cid, request, "read")
|
||
return {"id": c.id, "name": c.name, "nodes": c.nodes, "edges": c.edges,
|
||
"supervisor_agent_id": c.supervisor_agent_id, "supervisor_mode": c.supervisor_mode,
|
||
"organization_id": c.organization_id, **owner_fields(s, c.owner_user_id),
|
||
"visibility": c.visibility}
|
||
|
||
@app.put("/api/canvases/{cid}")
|
||
def update_canvas(cid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
source = s.get(Canvas, cid)
|
||
raw_nodes, raw_edges = body.get("nodes", source.nodes if source else []), body.get("edges", source.edges if source else [])
|
||
if not isinstance(raw_nodes, list) or not isinstance(raw_edges, list):
|
||
raise HTTPException(422, "画布节点和连线必须是列表")
|
||
nodes, edges = _normalize_canvas(s, raw_nodes, raw_edges)
|
||
# Keep pending global revisions normalized as well; otherwise approval
|
||
# could reintroduce a composer node after the update is accepted.
|
||
if "nodes" in body:
|
||
body["nodes"] = nodes
|
||
if "edges" in body:
|
||
body["edges"] = edges
|
||
revision = _global_revision(s, "canvas", source, body, request) if source else None
|
||
if revision:
|
||
return {"ok": True, "pending": True, "revision_id": revision.id, "version": revision.version}
|
||
c = require_canvas(s, cid, request, "manage")
|
||
_validate_canvas(s, nodes, edges)
|
||
user = request_user(request)
|
||
if any(not can_access(s.get(Agent, node.get("agent_id")), user, "use") for node in nodes):
|
||
raise HTTPException(404, "画布包含当前用户不可使用的 Agent")
|
||
if "name" in body:
|
||
body["name"] = _required_text(body.get("name"), "画布名称")
|
||
if "visibility" in body:
|
||
body["visibility"] = visibility_for_create(request, body.get("visibility"))
|
||
if "supervisor_agent_id" in body:
|
||
supervisor_agent_id = body.get("supervisor_agent_id")
|
||
if supervisor_agent_id is not None:
|
||
supervisor_agent_id = int(supervisor_agent_id)
|
||
if not can_access(s.get(Agent, supervisor_agent_id), user, "use"):
|
||
raise HTTPException(404, "总监 Agent 不存在或当前用户不可使用")
|
||
body["supervisor_agent_id"] = supervisor_agent_id
|
||
if "supervisor_mode" in body:
|
||
if body["supervisor_mode"] not in {"always", "on_drift", "off"}:
|
||
raise HTTPException(422, "总监模式必须是 always、on_drift 或 off")
|
||
# Always write normalized nodes/edges so a legacy canvas is cleaned up on
|
||
# its next edit even when the client only changes its name.
|
||
c.nodes, c.edges = nodes, edges
|
||
for k in ("name", "visibility", "supervisor_agent_id", "supervisor_mode"):
|
||
if k in body:
|
||
setattr(c, k, body[k])
|
||
s.add(c); s.commit()
|
||
_record_fork_revision(s, "canvas", cid, request)
|
||
return {"ok": True}
|
||
|
||
@app.delete("/api/canvases/{cid}")
|
||
def delete_canvas(cid: int, s: Session = Depends(get_session), request: Request = None):
|
||
source = s.get(Canvas, cid)
|
||
revision = _global_revision(s, "canvas", source, {}, request, "delete") if source else None
|
||
if revision:
|
||
return {"ok": True, "pending": True, "revision_id": revision.id}
|
||
canvas = require_canvas(s, cid, request, "manage")
|
||
# 一并清理成员授权,避免画布删除后残留无效授权记录。
|
||
for grant in s.exec(select(CanvasGrant).where(CanvasGrant.canvas_id == cid)).all():
|
||
s.delete(grant)
|
||
_cleanup_fork(s, "canvas", cid)
|
||
s.delete(canvas); s.commit()
|
||
return {"ok": True}
|
||
|
||
@app.get("/api/admin/canvases")
|
||
def list_admin_canvases(s: Session = Depends(get_session), request: Request = None):
|
||
organization_id = int(_tenant_user(request).organization_id or 1) if request else None
|
||
query = select(Canvas).order_by(Canvas.id)
|
||
if organization_id is not None:
|
||
query = query.where(Canvas.organization_id.in_([0, organization_id]))
|
||
rows = s.exec(query).all()
|
||
result = []
|
||
for canvas in rows:
|
||
grants = s.exec(select(CanvasGrant).where(CanvasGrant.canvas_id == canvas.id)
|
||
.order_by(CanvasGrant.id)).all()
|
||
if organization_id is not None:
|
||
grants = [grant for grant in grants if _grantable_user(s.get(AppUser, grant.user_id)) and
|
||
s.get(AppUser, grant.user_id).organization_id == organization_id]
|
||
else:
|
||
grants = [grant for grant in grants if _grantable_user(s.get(AppUser, grant.user_id))]
|
||
result.append({"id": canvas.id, "name": canvas.name, "nodes": canvas.nodes, "edges": canvas.edges,
|
||
"organization_id": canvas.organization_id, **owner_fields(s, canvas.owner_user_id),
|
||
"visibility": canvas.visibility, "created_at": canvas.created_at,
|
||
"grants": [{"id": grant.id, "user_id": grant.user_id, "permission": grant.permission,
|
||
"enabled": grant.enabled,
|
||
"display_name": (s.get(AppUser, grant.user_id).display_name
|
||
if s.get(AppUser, grant.user_id) else "")}
|
||
for grant in grants]})
|
||
return result
|
||
|
||
@app.post("/api/admin/canvases/{cid}/grants")
|
||
def grant_canvas(cid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
canvas = s.get(Canvas, cid)
|
||
if not canvas or not _same_tenant(canvas, _tenant_user(request)):
|
||
raise HTTPException(404, "画布不存在")
|
||
user_id = _body_int(body, "user_id", "成员编号")
|
||
target_user = s.get(AppUser, user_id)
|
||
actor = _tenant_user(request)
|
||
if not _grantable_user(target_user, actor):
|
||
raise HTTPException(404, "用户不存在")
|
||
permission = str(body.get("permission") or "use")
|
||
if permission not in {"read", "use", "manage", "export"}:
|
||
raise HTTPException(422, "授权权限无效")
|
||
grant = s.exec(select(CanvasGrant).where(CanvasGrant.canvas_id == cid,
|
||
CanvasGrant.user_id == user_id)).first()
|
||
grant = grant or CanvasGrant(canvas_id=cid, user_id=user_id)
|
||
grant.permission = permission; grant.enabled = True
|
||
if canvas.visibility != "global":
|
||
canvas.visibility = "shared"
|
||
s.add(grant); s.add(canvas); s.commit(); s.refresh(grant)
|
||
return {"ok": True, "id": grant.id}
|
||
|
||
@app.delete("/api/admin/canvases/{cid}/grants/{grant_id}")
|
||
def revoke_canvas(cid: int, grant_id: int, s: Session = Depends(get_session), request: Request = None):
|
||
canvas = s.get(Canvas, cid)
|
||
grant = s.get(CanvasGrant, grant_id)
|
||
if not canvas or not _same_tenant(canvas, _tenant_user(request)) or not grant or grant.canvas_id != cid:
|
||
raise HTTPException(404, "画布授权不存在")
|
||
s.delete(grant); s.commit()
|
||
return {"ok": True}
|
||
|
||
# ---------- 数字人资源授权(访问控制页) ----------
|
||
@app.get("/api/admin/digital-humans")
|
||
def list_admin_digital_humans(s: Session = Depends(get_session), request: Request = None):
|
||
"""管理端按租户列出数字人及其成员授权,不包含超级管理员账号。"""
|
||
organization_id = int(_tenant_user(request).organization_id or 1) if request else None
|
||
query = select(DigitalHuman).order_by(DigitalHuman.id.desc())
|
||
if organization_id is not None:
|
||
query = query.where(DigitalHuman.organization_id.in_([0, organization_id]))
|
||
result = []
|
||
for human in s.exec(query).all():
|
||
grants = s.exec(select(DigitalHumanGrant).where(
|
||
DigitalHumanGrant.dh_id == human.id).order_by(DigitalHumanGrant.id)).all()
|
||
grants = [g for g in grants if _grantable_user(s.get(AppUser, g.user_id), _tenant_user(request))]
|
||
result.append({"id": human.id, "name": human.name, "desc": human.desc,
|
||
"status": human.status, "version": human.version,
|
||
"source_count": len(_unique_source_names(human.source_files or [])),
|
||
"organization_id": human.organization_id, **owner_fields(s, human.owner_user_id),
|
||
"visibility": human.visibility,
|
||
"grants": [_grant_payload(g, s.get(AppUser, g.user_id)) for g in grants]})
|
||
return result
|
||
|
||
|
||
@app.post("/api/admin/digital-humans/{dhid}/grants")
|
||
def grant_digital_human_admin(dhid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
human = s.get(DigitalHuman, dhid)
|
||
actor = _tenant_user(request)
|
||
if not human or not _same_tenant(human, actor):
|
||
raise HTTPException(404, "数字人不存在")
|
||
user_id = _body_int(body, "user_id", "成员编号")
|
||
target_user = s.get(AppUser, user_id)
|
||
if not _grantable_user(target_user, actor):
|
||
raise HTTPException(404, "用户不存在或已停用")
|
||
permission = str(body.get("permission") or "use").strip().lower()
|
||
if permission not in {"read", "use"}:
|
||
raise HTTPException(422, "数字人授权权限只能是 read 或 use")
|
||
grant = s.exec(select(DigitalHumanGrant).where(
|
||
DigitalHumanGrant.dh_id == dhid, DigitalHumanGrant.user_id == user_id)).first()
|
||
if not grant:
|
||
grant = DigitalHumanGrant(dh_id=dhid, user_id=user_id, permission=permission)
|
||
else:
|
||
grant.permission = permission
|
||
grant.enabled = True
|
||
if human.visibility != "global":
|
||
human.visibility = "shared"
|
||
s.add(grant); s.add(human); s.commit(); s.refresh(grant)
|
||
return _grant_payload(grant, target_user)
|
||
|
||
|
||
@app.delete("/api/admin/digital-humans/{dhid}/grants/{grant_id}")
|
||
def revoke_digital_human_admin(dhid: int, grant_id: int, s: Session = Depends(get_session), request: Request = None):
|
||
human = s.get(DigitalHuman, dhid)
|
||
grant = s.get(DigitalHumanGrant, grant_id)
|
||
if not human or not _same_tenant(human, _tenant_user(request)) or not grant or grant.dh_id != dhid:
|
||
raise HTTPException(404, "数字人授权不存在")
|
||
s.delete(grant); s.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/admin/digital-humans/{dhid}/publish-global")
|
||
def publish_digital_human_global(dhid: int, s: Session = Depends(get_session), request: Request = None):
|
||
human = s.get(DigitalHuman, dhid)
|
||
if not human or not _same_tenant(human, _tenant_user(request)):
|
||
raise HTTPException(404, "数字人不存在")
|
||
human.visibility = "global"; s.add(human); s.commit()
|
||
return {"ok": True, "visibility": human.visibility}
|
||
|
||
|
||
@app.get("/api/admin/digital-humans/{dhid}/export")
|
||
def export_digital_human_admin(dhid: int, s: Session = Depends(get_session), request: Request = None):
|
||
human = s.get(DigitalHuman, dhid)
|
||
if not human or not _same_tenant(human, _tenant_user(request)):
|
||
raise HTTPException(404, "数字人不存在")
|
||
_deny_global_user_export(human, request, s)
|
||
grants = s.exec(select(DigitalHumanGrant).where(DigitalHumanGrant.dh_id == dhid)).all()
|
||
return JSONResponse({"id": human.id, "name": human.name, "desc": human.desc,
|
||
"status": human.status, "version": human.version,
|
||
"persona": human.persona, "persona_meta": human.persona_meta,
|
||
"source_files": human.source_files, "visibility": human.visibility,
|
||
**owner_fields(s, human.owner_user_id),
|
||
"grants": [{"user_id": g.user_id, "permission": g.permission, "enabled": g.enabled} for g in grants]},
|
||
headers={"Content-Disposition": f'attachment; filename="digital-human-{dhid}.json"'})
|
||
|
||
@app.post("/api/admin/canvases/{cid}/publish-global")
|
||
def publish_canvas_global(cid: int, s: Session = Depends(get_session), request: Request = None):
|
||
canvas = s.get(Canvas, cid)
|
||
if not canvas or not _same_tenant(canvas, _tenant_user(request)):
|
||
raise HTTPException(404, "画布不存在")
|
||
canvas.visibility = "global"; s.add(canvas); s.commit()
|
||
return {"ok": True, "visibility": canvas.visibility}
|
||
|
||
@app.get("/api/admin/canvases/{cid}/export")
|
||
def export_canvas(cid: int, s: Session = Depends(get_session), request: Request = None):
|
||
canvas = s.get(Canvas, cid)
|
||
if not canvas or not _same_tenant(canvas, _tenant_user(request)):
|
||
raise HTTPException(404, "画布不存在")
|
||
_deny_global_user_export(canvas, request, s)
|
||
grants = s.exec(select(CanvasGrant).where(CanvasGrant.canvas_id == cid)).all()
|
||
return JSONResponse({"id": canvas.id, "name": canvas.name, "nodes": canvas.nodes, "edges": canvas.edges,
|
||
"visibility": canvas.visibility, **owner_fields(s, canvas.owner_user_id),
|
||
"grants": [{"user_id": item.user_id, "permission": item.permission,
|
||
"enabled": item.enabled} for item in grants]},
|
||
headers={"Content-Disposition": f'attachment; filename="canvas-{cid}.json"'})
|
||
|
||
# ---------- 任务 ----------
|
||
def _default_criteria(objective: str) -> str:
|
||
"""按任务目标生成默认验收标准(AI 时代人工只做修改不做默写)。"""
|
||
return (f"1. 全部内容围绕唯一目标「{objective[:60]}」展开,不跑题、不夹带第二个目标;"
|
||
"2. 每段正文口语化可直接播读,无书面腔和列表演讲感;"
|
||
"3. 关键说法可在知识库引用中找到来源,无编造事实;"
|
||
"4. 段落之间衔接自然,无重复表述和逻辑断层。")
|
||
|
||
|
||
@app.post("/api/campaigns/suggest-criteria")
|
||
def suggest_criteria(body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
"""AI 根据任务目标预填验收标准;人工在表单里可继续修改。"""
|
||
objective = _required_text(_body_text(body, "objective", "任务目标", 500), "任务目标")
|
||
organization_id, user_id = actor_fields(request)
|
||
try:
|
||
draft = ai_draft(s, "template",
|
||
f"为内容任务「{objective}」写 3-5 条人工验收标准,每条一句话,"
|
||
"覆盖:内容围绕目标不跑题、口语化可直接播读、关键说法有知识库来源、段落衔接自然。"
|
||
"只输出约束列表。", user_id=user_id, organization_id=organization_id)
|
||
except ValueError:
|
||
# AI 不可用时给确定性默认稿,绝不阻塞创建流程
|
||
return {"criteria": _default_criteria(objective), "source": "default"}
|
||
constraints = [str(item) for item in (draft.get("constraints") or []) if str(item).strip()]
|
||
if not constraints:
|
||
return {"criteria": _default_criteria(objective), "source": "default"}
|
||
return {"criteria": ";".join(constraints)[:990], "source": "ai"}
|
||
|
||
|
||
@app.post("/api/campaigns")
|
||
def create_campaign(c: dict, s: Session = Depends(get_session), request: Request = None):
|
||
organization_id, owner_user_id = actor_fields(request)
|
||
canvas_id = c.get("canvas_id")
|
||
if canvas_id in (None, "", 0, "0"):
|
||
raise HTTPException(422, "请选择画布模板后再创建内容任务")
|
||
try:
|
||
canvas_id = int(canvas_id)
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(422, "画布模板无效")
|
||
if canvas_id <= 0:
|
||
raise HTTPException(422, "画布模板无效")
|
||
# 导图已经承载任务目标;保留旧接口的 objective 兼容,并为纯导图创建落默认值。
|
||
objective = _body_text(c, "objective", "任务目标", 500).strip()
|
||
if not objective:
|
||
if not str(c.get("mindmap_text") or "").strip():
|
||
raise HTTPException(422, "任务导图不能为空")
|
||
objective = "按导图完成本次内容任务"
|
||
# 验收标准:允许 AI 预填后人工修改;为空时按目标自动生成一份默认稿。
|
||
success_criteria = _body_text(c, "success_criteria", "验收标准", 1000) or _default_criteria(objective)
|
||
try:
|
||
duration_minutes = max(0, int(c.get("duration_minutes") or 0))
|
||
except (TypeError, ValueError):
|
||
duration_minutes = 0
|
||
qc_mode = str(c.get("qc_mode") or "full").strip()
|
||
if qc_mode not in ("full", "lazy", "off"):
|
||
qc_mode = "full"
|
||
camp = Campaign(name=c.get("name", "新任务"), mindmap_text=c.get("mindmap_text", ""),
|
||
objective=objective, success_criteria=success_criteria,
|
||
duration_minutes=duration_minutes, qc_mode=qc_mode,
|
||
previous_campaign_id=c.get("previous_campaign_id"),
|
||
organization_id=organization_id, owner_user_id=owner_user_id,
|
||
visibility=visibility_for_create(request, c.get("visibility", "private")))
|
||
cv = s.get(Canvas, canvas_id)
|
||
if not cv or not can_use_canvas(s, cv, request_user(request), "use"):
|
||
raise HTTPException(404, "画布不存在或当前用户没有使用权限")
|
||
# Strip any legacy composer node before checking per-node permissions; the
|
||
# built-in composer is resolved by the final composition stage and should
|
||
# never make a user's task fail authorization.
|
||
canvas_nodes, canvas_edges = _normalize_canvas(s, list(cv.nodes or []), list(cv.edges or []))
|
||
canvas_use_granted = can_use_canvas(s, cv, request_user(request), "use")
|
||
if any(not can_access(s.get(Agent, node.get("agent_id")), request_user(request), "use")
|
||
and not canvas_use_granted for node in canvas_nodes):
|
||
raise HTTPException(404, "画布包含当前用户不可使用的 Agent")
|
||
# Snapshot the executable canvas without the built-in composer stage. A
|
||
# composer Agent is resolved automatically during final composition and
|
||
# must never be required as a user-configured node.
|
||
camp.canvas_snapshot = {"nodes": canvas_nodes, "edges": canvas_edges}
|
||
camp.canvas_name = cv.name
|
||
s.add(camp); s.commit(); s.refresh(camp)
|
||
return {"id": camp.id}
|
||
|
||
# 近义标签映射:画布无精确匹配时按语义归入相近节点
|
||
TAG_ALIAS = {
|
||
"产品回指": ["产品销售", "产品体验"],
|
||
"转场": ["开场", "互动"],
|
||
"预告": ["钩子"],
|
||
"见证": ["案例"],
|
||
"金句": ["心法"],
|
||
"共鸣": ["心法"],
|
||
"安全提示": ["技法"],
|
||
"跟练": ["技法", "功法"],
|
||
"氛围": ["场景"],
|
||
"情绪": ["场景", "心法"],
|
||
}
|
||
|
||
|
||
def _match_agent(session: Session, tag: str, candidates: list[Agent] | None = None,
|
||
*, allow_unassigned: bool = False):
|
||
"""按内容标签找启用中的生产 Agent:优先标签精确命中,其次别名,最后回退任一启用生产 Agent。"""
|
||
aliases = TAG_ALIAS.get(tag, [])
|
||
if candidates is None:
|
||
candidates = list(session.exec(select(Agent).where(
|
||
Agent.role == "production", Agent.status == "active")).all())
|
||
for alias in [tag, *aliases]:
|
||
for agent in candidates:
|
||
tags = agent.content_tags or []
|
||
if alias in tags:
|
||
return agent
|
||
if candidates:
|
||
return candidates[0]
|
||
if allow_unassigned:
|
||
# 拆解本身只负责把导图编译成任务段。Agent 可以稍后在清单中绑定,
|
||
# 不能因为后台暂时没有启用的生产 Agent 就把整次拆解标为失败。
|
||
return None
|
||
raise HTTPException(500, f"没有能处理标签[{tag}]的启用生产 Agent——请先在 Agent 管理中配置")
|
||
|
||
|
||
def _match_node(session: Session, tag: str, canvas_nodes: list, used: dict,
|
||
*, allow_unassigned: bool = False):
|
||
"""画布节点匹配:按 content_tag 找节点(含别名),同节点多段轮转分配。
|
||
返回 (agent, node_id)。"""
|
||
aliases = [tag, *TAG_ALIAS.get(tag, [])]
|
||
node = None
|
||
for alias in aliases:
|
||
hits = [n for n in canvas_nodes
|
||
if str(n.get("content_tag") or n.get("tag") or "") == alias]
|
||
if hits:
|
||
node = hits[used.get(alias, 0) % len(hits)]
|
||
used[alias] = used.get(alias, 0) + 1
|
||
break
|
||
if node is None:
|
||
return _match_agent(session, tag, allow_unassigned=allow_unassigned), f"seg-{len(used) + 1}"
|
||
agent = session.get(Agent, int(node.get("agent_id")))
|
||
if not agent:
|
||
if allow_unassigned:
|
||
return None, str(node.get("id") or node.get("node_id") or f"seg-{len(used) + 1}")
|
||
raise HTTPException(500, "画布节点引用的 Agent 不存在")
|
||
if allow_unassigned and (agent.status != "active" or agent.role != "production"):
|
||
return None, str(node.get("id") or node.get("node_id") or f"seg-{len(used) + 1}")
|
||
return agent, str(node.get("id") or node.get("node_id") or f"seg-{len(used) + 1}")
|
||
|
||
|
||
def _require_current_plan_approved(s: Session, campaign: Campaign) -> None:
|
||
"""DSH Worker 只执行当前批准版本;新 Planner 版本等待审批时禁止继续旧运行。"""
|
||
if not execution_config.is_dsh_backend():
|
||
return
|
||
if not campaign.approved_plan_version_id:
|
||
raise HTTPException(409, "DSH Worker 只能执行已人工批准的计划版本;请先完成计划审查")
|
||
latest = s.exec(select(CampaignPlanVersion).where(
|
||
CampaignPlanVersion.campaign_id == campaign.id
|
||
).order_by(CampaignPlanVersion.version.desc())).first()
|
||
if latest and latest.id != campaign.approved_plan_version_id:
|
||
raise HTTPException(409, "DSH Planner 已生成新版本,请审批或驳回后再执行;Worker 不会运行未批准计划")
|
||
if campaign.status in {"planning", "plan_review", "plan_failed"}:
|
||
raise HTTPException(409, "计划正在规划或等待审批,暂不能派发内容生产")
|
||
|
||
|
||
@app.post("/api/campaigns/{cid}/parse")
|
||
def campaign_parse(cid: int, body: dict = None, s: Session = Depends(get_session), request: Request = None):
|
||
"""兼容旧前端:统一改走 DSH Planner 的“计划版本→校验→审批”流水线。"""
|
||
camp = s.get(Campaign, cid)
|
||
if not camp or not can_access(camp, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404)
|
||
return create_campaign_plan(cid, body=body or {}, s=s, request=request)
|
||
|
||
@app.get("/api/campaigns")
|
||
def list_campaigns(s: Session = Depends(get_session), request: Request = None):
|
||
out = []
|
||
for c in s.exec(select(Campaign).order_by(Campaign.id.desc())).all():
|
||
if not can_access(c, request_user(request), "read"):
|
||
continue
|
||
latest = s.exec(select(CampaignRun).where(CampaignRun.campaign_id == c.id)
|
||
.order_by(CampaignRun.id.desc())).first()
|
||
out.append({"id": c.id, "name": c.name, "objective": c.objective, **owner_fields(s, c.owner_user_id),
|
||
"status": c.status, "has_failure": c.has_failure,
|
||
"run_id": latest.id if latest else None,
|
||
"run_status": latest.status if latest else None,
|
||
"created_at": c.created_at})
|
||
return out
|
||
|
||
@app.get("/api/campaigns/{cid}")
|
||
def get_campaign(cid: int, s: Session = Depends(get_session), request: Request = None):
|
||
c = s.get(Campaign, cid)
|
||
if not c or not can_access(c, request_user(request), "read"):
|
||
raise HTTPException(404)
|
||
segs = []
|
||
category_options_cache: dict[int, list[dict]] = {}
|
||
all_segments = s.exec(select(Segment).where(Segment.campaign_id == cid).order_by(Segment.id)).all()
|
||
for seg in all_segments:
|
||
fusion_ids = seg.fusion_agent_ids or []
|
||
fusion_names = [agent.name for agent in (s.get(Agent, fusion_id) for fusion_id in fusion_ids) if agent]
|
||
agent = s.get(Agent, seg.agent_id) if seg.agent_id else None
|
||
if agent and agent.id not in category_options_cache:
|
||
category_options_cache[agent.id] = category_options_for_agent(s, agent)
|
||
category_options = category_options_cache.get(agent.id, []) if agent else []
|
||
recommendation = seg.kb_category_recommendation or heuristic_segment_recommendation(category_options, seg)
|
||
segs.append({"id": seg.id, "node_id": seg.node_id, "agent_id": seg.agent_id,
|
||
"agent_name": agent.name if agent else "未分配 Agent",
|
||
"agent_role": agent.role if agent else "",
|
||
"agent_assignment_mode": seg.agent_assignment_mode,
|
||
"previous_version_count": len(seg.generation_history or []),
|
||
"agent_change_affected": [{"id": item.id, "title": item.title} for item in all_segments
|
||
if item.id >= seg.id and item.status != "skipped"
|
||
and segment_assignment.has_generation(item)],
|
||
"fusion_agent_ids": fusion_ids, "fusion_agent_names": fusion_names,
|
||
"content_tag": seg.content_tag, "title": seg.title,
|
||
"time_range": seg.time_range, "status": seg.status, "round": seg.round,
|
||
"product_ref": seg.product_ref, "anchor_card": seg.anchor_card,
|
||
"qc_reports": seg.qc_reports, "failure": seg.failure,
|
||
"references": seg.references,
|
||
"kb_category_ids": seg.kb_category_ids or [],
|
||
"kb_category_options": category_options,
|
||
"kb_category_recommendation": recommendation,
|
||
"artifact": open(seg.artifact_path, encoding="utf-8").read()
|
||
if seg.artifact_path and Path(seg.artifact_path).exists() else ""})
|
||
artifact = s.exec(select(Artifact).where(Artifact.campaign_id == cid)
|
||
.order_by(Artifact.id.desc())).first()
|
||
final_artifact = None
|
||
if artifact and artifact.docx_path:
|
||
artifact_path = Path(artifact.docx_path)
|
||
if artifact_path.exists():
|
||
final_artifact = {
|
||
"id": artifact.id,
|
||
"file_name": artifact_path.name,
|
||
"size_bytes": artifact_path.stat().st_size,
|
||
"final_status": artifact.final_status,
|
||
"download_url": f"/api/artifacts/{artifact.id}/download",
|
||
"preview_url": f"/api/artifacts/{artifact.id}/preview",
|
||
"created_at": artifact.created_at,
|
||
}
|
||
latest_run = s.exec(select(CampaignRun).where(CampaignRun.campaign_id == cid)
|
||
.order_by(CampaignRun.id.desc())).first()
|
||
latest_plan = s.exec(select(CampaignPlanVersion).where(
|
||
CampaignPlanVersion.campaign_id == cid
|
||
).order_by(CampaignPlanVersion.version.desc())).first()
|
||
return {"id": c.id, "name": c.name, "status": c.status, "mindmap_text": c.mindmap_text,
|
||
"approved_plan_version_id": c.approved_plan_version_id,
|
||
"latest_plan": ({"id": latest_plan.id, "version": latest_plan.version,
|
||
"status": latest_plan.status, "summary": latest_plan.summary,
|
||
"risks": latest_plan.risks or [],
|
||
"validation": latest_plan.validation or {}}
|
||
if latest_plan else None),
|
||
"objective": c.objective, "success_criteria": c.success_criteria,
|
||
"duration_minutes": c.duration_minutes or 0,
|
||
"agent_options": [{"id": item.id, "name": item.name, "content_tags": item.content_tags,
|
||
"desc": item.desc, "kb_count": len(item.kb_ids or [])}
|
||
for item in segment_assignment.available_agents(s, c, request_user(request))],
|
||
"can_edit_agents": can_access(c, request_user(request), "manage", "agents:write", s)
|
||
and not bool(segment_assignment.edit_blocker(s, c)),
|
||
"qc_mode": c.qc_mode or "full",
|
||
"pending_confirms": c.pending_confirms, "has_failure": c.has_failure,
|
||
"canvas_name": c.canvas_name, "artifact_id": artifact.id if artifact else None,
|
||
"final_artifact": final_artifact,
|
||
"compose_report": artifact.compose_report if artifact else None,
|
||
"run": {"id": latest_run.id, "status": latest_run.status,
|
||
"checkpoint": latest_run.checkpoint, "trace_id": latest_run.trace_id,
|
||
"error": latest_run.error}
|
||
if latest_run else None,
|
||
"segments": segs}
|
||
|
||
@app.delete("/api/campaigns/{cid}")
|
||
def delete_campaign(cid: int, s: Session = Depends(get_session), request: Request = None):
|
||
"""删除任务:运行中不可删;连带删除段落/运行/会话/事件(产物文件保留供审计)。"""
|
||
campaign = s.get(Campaign, cid)
|
||
if not campaign or not can_access(campaign, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "任务不存在")
|
||
active_run = s.exec(select(CampaignRun).where(
|
||
CampaignRun.campaign_id == cid,
|
||
CampaignRun.status.in_(["queued", "running", "paused"]))).first()
|
||
if active_run:
|
||
raise HTTPException(409, "任务正在运行或暂停中,请先停止再删除")
|
||
segs = s.exec(select(Segment).where(Segment.campaign_id == cid)).all()
|
||
seg_ids = [seg.id for seg in segs]
|
||
for run in s.exec(select(CampaignRun).where(CampaignRun.campaign_id == cid)).all():
|
||
for agent_sess in s.exec(select(AgentSession).where(AgentSession.run_id == run.id)).all():
|
||
s.delete(agent_sess)
|
||
for event in s.exec(select(RunEvent).where(RunEvent.run_id == run.id)).all():
|
||
s.delete(event)
|
||
s.delete(run)
|
||
for seg_id in seg_ids:
|
||
for event in s.exec(select(RunEvent).where(RunEvent.segment_id == seg_id)).all():
|
||
s.delete(event)
|
||
for artifact in s.exec(select(Artifact).where(Artifact.campaign_id == cid)).all():
|
||
s.delete(artifact)
|
||
for plan_version in s.exec(select(CampaignPlanVersion).where(CampaignPlanVersion.campaign_id == cid)).all():
|
||
s.delete(plan_version)
|
||
for seg in segs:
|
||
s.delete(seg)
|
||
s.delete(campaign)
|
||
s.commit()
|
||
return {"ok": True, "deleted_segments": len(seg_ids)}
|
||
|
||
|
||
@app.get("/api/campaigns/{cid}/readiness")
|
||
def campaign_readiness(cid: int, s: Session = Depends(get_session), request: Request = None):
|
||
"""Hermes 融合:任务启动前准备度检查 + 成本预估,任务页直接展示。"""
|
||
from .cost_estimate import estimate_campaign_cost
|
||
campaign = s.get(Campaign, cid)
|
||
if not campaign or not can_access(campaign, request_user(request), "read"):
|
||
raise HTTPException(404, "任务不存在")
|
||
report = readiness_report(s, campaign)
|
||
report["cost_estimate"] = estimate_campaign_cost(s, campaign)
|
||
return report
|
||
|
||
@app.get("/api/campaigns/{cid}/segments/{sid}/category-options")
|
||
def segment_category_options(cid: int, sid: int, s: Session = Depends(get_session), request: Request = None):
|
||
"""轻量只读分类目录;不会调用模型,候选严格来自执行 Agent 的授权范围。"""
|
||
campaign = s.get(Campaign, cid); segment = s.get(Segment, sid)
|
||
if not campaign or not segment or segment.campaign_id != cid or not can_access(campaign, request_user(request), "read"):
|
||
raise HTTPException(404, "内容段不存在")
|
||
options = category_options_for_agent(s, s.get(Agent, segment.agent_id))
|
||
return {"options": options, "selected_ids": segment.kb_category_ids or [],
|
||
"recommendation": segment.kb_category_recommendation or heuristic_segment_recommendation(options, segment)}
|
||
|
||
@app.post("/api/campaigns/{cid}/segments/{sid}/recommend-category")
|
||
def segment_recommend_category(cid: int, sid: int, s: Session = Depends(get_session), request: Request = None):
|
||
"""已有任务显式请求一次 AI 分类推荐;推荐保存为待人工采用。"""
|
||
campaign = s.get(Campaign, cid); segment = s.get(Segment, sid)
|
||
if not campaign or campaign.id != cid or not segment or segment.campaign_id != cid or not can_access(campaign, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "内容段不存在")
|
||
try:
|
||
recommendation = recommend_segment_categories(s, segment)
|
||
except ValueError as exc:
|
||
raise HTTPException(422, str(exc))
|
||
segment.kb_category_recommendation = recommendation
|
||
s.add(segment); s.commit()
|
||
return {"recommendation": recommendation}
|
||
|
||
@app.get("/api/campaigns/{cid}/segments/{sid}/versions")
|
||
def segment_versions(cid: int, sid: int, s: Session = Depends(get_session), request: Request = None):
|
||
campaign = s.get(Campaign, cid); segment = s.get(Segment, sid)
|
||
if not campaign or not segment or segment.campaign_id != cid or not can_access(campaign, request_user(request), "read"):
|
||
raise HTTPException(404, "内容段不存在")
|
||
result = []
|
||
for item in reversed(segment.generation_history or []):
|
||
row = {key: item.get(key) for key in ('agent_id', 'agent_name', 'round', 'status', 'archived_at', 'reason')}
|
||
path = Path(item['artifact_path']) if item.get('artifact_path') else None
|
||
row['artifact'] = path.read_text(encoding='utf-8') if path and path.is_file() else ''
|
||
result.append(row)
|
||
return result
|
||
|
||
|
||
@app.put("/api/campaigns/{cid}/segments/{sid}")
|
||
def segment_edit(cid: int, sid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
"""手动覆盖执行智能体;旧快照/知识范围不能沿用到新智能体。"""
|
||
with _RUN_LOCK:
|
||
camp = s.get(Campaign, cid)
|
||
seg = s.get(Segment, sid)
|
||
user = request_user(request)
|
||
if not camp or not seg or seg.campaign_id != cid or not can_access(camp, user, "manage", "agents:write", s):
|
||
raise HTTPException(404, "内容段不存在")
|
||
blocker = segment_assignment.edit_blocker(s, camp)
|
||
if blocker:
|
||
raise HTTPException(409, blocker)
|
||
if execution_config.is_dsh_backend():
|
||
plan_controlled_fields = {"agent_id", "title", "content_tag", "time_range",
|
||
"product_ref", "kb_category_ids"}
|
||
if camp.status in {"planning", "plan_review", "plan_failed"}:
|
||
raise HTTPException(409, "计划正在规划或等待审批,不能直接修改任务单;请审批/驳回或重新规划")
|
||
if camp.approved_plan_version_id and any(key in body for key in plan_controlled_fields):
|
||
raise HTTPException(409, "DSH Worker 锁定执行已批准计划版本;需调整分段、Agent 或时间时,请重新发起规划并审批新版本")
|
||
new_tag = str(body.get("content_tag") or "").strip()[:40]
|
||
assigned = None
|
||
if "agent_id" in body:
|
||
if not str(body["agent_id"]).isdigit() or int(body["agent_id"]) <= 0:
|
||
raise HTTPException(422, "请选择有效的执行智能体")
|
||
assigned = next((item for item in segment_assignment.available_agents(s, camp, user)
|
||
if item.id == int(body["agent_id"])), None)
|
||
if not assigned:
|
||
raise HTTPException(404, "指定的生产智能体不可用或没有使用权限")
|
||
elif new_tag and new_tag != seg.content_tag and seg.agent_assignment_mode != "manual":
|
||
assigned = _match_agent(s, new_tag, segment_assignment.available_agents(s, camp, user))
|
||
reset_ids = []
|
||
if assigned:
|
||
old_mode = seg.agent_assignment_mode
|
||
reset_ids = segment_assignment.change_agent(s, camp, seg, assigned,
|
||
reset_generated=body.get("reset_generated") is True)
|
||
if "agent_id" not in body:
|
||
seg.agent_assignment_mode = old_mode
|
||
if "title" in body:
|
||
seg.title = str(body["title"]).strip()[:200] or seg.title
|
||
if new_tag:
|
||
seg.content_tag = new_tag
|
||
seg.kb_category_recommendation = {}
|
||
if "time_range" in body:
|
||
seg.time_range = str(body["time_range"]).strip()[:60]
|
||
if "product_ref" in body:
|
||
seg.product_ref = str(body["product_ref"]).strip()[:200]
|
||
if "kb_category_ids" in body:
|
||
try:
|
||
seg.kb_category_ids = validate_segment_category_ids(s, seg, body.get("kb_category_ids") or [])
|
||
except ValueError as exc:
|
||
s.rollback()
|
||
raise HTTPException(422, str(exc))
|
||
seg.updated_at = now()
|
||
s.add(seg); s.commit()
|
||
return {"status": "ok", "reset_segment_ids": reset_ids, "segment": {
|
||
"id": seg.id, "title": seg.title, "content_tag": seg.content_tag,
|
||
"time_range": seg.time_range, "product_ref": seg.product_ref,
|
||
"agent_id": seg.agent_id, "agent_assignment_mode": seg.agent_assignment_mode,
|
||
"kb_category_ids": seg.kb_category_ids or [],
|
||
"kb_category_recommendation": seg.kb_category_recommendation or {}}}
|
||
|
||
|
||
@app.delete("/api/campaigns/{cid}/segments/{sid}")
|
||
def segment_delete(cid: int, sid: int, s: Session = Depends(get_session), request: Request = None):
|
||
"""从清单中删除一段(生成前才允许)。"""
|
||
camp = s.get(Campaign, cid)
|
||
seg = s.get(Segment, sid)
|
||
if not camp or not seg or seg.campaign_id != cid or not can_access(camp, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "内容段不存在")
|
||
if camp.status == "done":
|
||
raise HTTPException(409, "任务已交付,不能删除段落")
|
||
if execution_config.is_dsh_backend() and not camp.approved_plan_version_id and camp.status in {"planning", "plan_review", "plan_failed"}:
|
||
raise HTTPException(409, "计划尚未批准,不能直接修改任务单;请调整资源后重新规划")
|
||
s.delete(seg); s.commit()
|
||
left = s.exec(select(Segment).where(Segment.campaign_id == cid)).all()
|
||
if not left:
|
||
camp.status = "parsed"
|
||
camp.task_spec = []
|
||
s.add(camp)
|
||
s.commit()
|
||
return {"status": "ok"}
|
||
|
||
|
||
@app.post("/api/campaigns/{cid}/segments")
|
||
def segment_add(cid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
"""在清单中新增一段。body: title(必填), content_tag, time_range, product_ref, agent_id。"""
|
||
camp = s.get(Campaign, cid)
|
||
if not camp or not can_access(camp, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "任务不存在")
|
||
if camp.status == "done":
|
||
raise HTTPException(409, "任务已交付,不能再修改")
|
||
if execution_config.is_dsh_backend() and not camp.approved_plan_version_id and camp.status in {"planning", "plan_review", "plan_failed"}:
|
||
raise HTTPException(409, "计划尚未批准,不能直接新增任务段;请先审查 DSH Planner 计划")
|
||
title = str(body.get("title") or "").strip()
|
||
if not title:
|
||
raise HTTPException(422, "段落标题不能为空")
|
||
tag = str(body.get("content_tag") or "心法").strip()[:40]
|
||
agent = None
|
||
if str(body.get("agent_id") or "").isdigit():
|
||
agent = s.get(Agent, int(body["agent_id"]))
|
||
if not agent:
|
||
try:
|
||
agent = _match_agent(s, tag)
|
||
except HTTPException:
|
||
agent = None
|
||
segs = s.exec(select(Segment).where(Segment.campaign_id == cid).order_by(Segment.id)).all()
|
||
seg = Segment(campaign_id=cid,
|
||
node_id=f"custom-{segs[-1].id + 1 if segs else 1}",
|
||
agent_id=agent.id if agent else 0,
|
||
title=title[:200], content_tag=tag,
|
||
time_range=str(body.get("time_range") or "").strip()[:60],
|
||
mindmap_node=title[:200],
|
||
product_ref=str(body.get("product_ref") or "").strip()[:200])
|
||
s.add(seg); s.commit()
|
||
return {"status": "ok", "segment": {"id": seg.id, "title": seg.title,
|
||
"content_tag": seg.content_tag, "time_range": seg.time_range,
|
||
"product_ref": seg.product_ref, "agent_id": seg.agent_id}}
|
||
|
||
|
||
@app.post("/api/campaigns/{cid}/reparse")
|
||
def campaign_reparse(cid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
"""用户对拆解清单不满意 → 带修改意见让 AI 重新拆解(后台执行)。"""
|
||
camp = s.get(Campaign, cid)
|
||
if not camp or not can_access(camp, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "任务不存在")
|
||
if camp.status == "done":
|
||
raise HTTPException(409, "任务已交付")
|
||
advice = str(body.get("advice") or "").strip()
|
||
if not advice:
|
||
raise HTTPException(422, "请填写你对当前清单的修改意见,AI 会按意见重新拆解")
|
||
r = campaign_parse(cid, body={"advice": advice}, s=s, request=request)
|
||
return r
|
||
|
||
|
||
@app.post("/api/campaigns/{cid}/run-step")
|
||
def campaign_run_step(cid: int, s: Session = Depends(get_session), request: Request = None):
|
||
"""逐段生成:只派发下一个待生成段(pending),跑完停在人工审查。
|
||
用户确认通过后再次调用即派发下一段。"""
|
||
campaign = s.get(Campaign, cid)
|
||
if not campaign or not can_access(campaign, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "任务不存在")
|
||
if campaign.status == "done":
|
||
raise HTTPException(409, "任务已交付")
|
||
_require_current_plan_approved(s, campaign)
|
||
with _RUN_LOCK:
|
||
active = s.exec(select(CampaignRun).where(
|
||
CampaignRun.campaign_id == cid,
|
||
CampaignRun.status.in_(["queued", "running", "paused"]))).first()
|
||
if active:
|
||
return {"status": active.status, "accepted": False, "run_id": active.id,
|
||
"message": "上一步还在执行或未确认"}
|
||
segs = s.exec(select(Segment).where(Segment.campaign_id == cid)
|
||
.order_by(Segment.id)).all()
|
||
if not segs:
|
||
raise HTTPException(400, "没有内容段,请先拆解")
|
||
next_seg = next((x for x in segs if x.status in ("pending", "rework", "failed")), None)
|
||
if not next_seg:
|
||
all_done = all(x.status in ("approved", "skipped") for x in segs)
|
||
return {"status": "no_pending", "accepted": False,
|
||
"message": "全部段落已处理完毕" + (",可以组合成稿" if all_done else "")}
|
||
# 逐段模式必须保持课程顺序:没有确认(通过或跳过)的前置段,
|
||
# 后续段不能先生成,否则模型会把未看过的内容当作既成知识,造成
|
||
# 用户跳过一段后上下文断裂,也会让“下一段”按钮绕过人工审查。
|
||
prior = [x for x in segs if (x.id or 0) < (next_seg.id or 0)]
|
||
blocking = [x for x in prior if x.status not in ("approved", "skipped")]
|
||
if blocking:
|
||
names = "、".join((x.title or f"内容段#{x.id}") for x in blocking[:3])
|
||
suffix = "等" if len(blocking) > 3 else ""
|
||
raise HTTPException(409, f"请先通过或跳过前置内容段:{names}{suffix},再生成下一段")
|
||
readiness = readiness_report(s, campaign)
|
||
if not readiness["ready"]:
|
||
raise HTTPException(409, "任务准备度检查未通过:" + ";".join(readiness["blockers"]))
|
||
from .cost_estimate import estimate_campaign_cost
|
||
from .points_guard import points_preflight
|
||
estimate = estimate_campaign_cost(s, campaign, single_segment_id=next_seg.id)
|
||
preflight = points_preflight(s, campaign, int(estimate.get("estimated_points") or 0))
|
||
if not preflight["ok"]:
|
||
raise HTTPException(409, preflight["blocker"])
|
||
agent = s.get(Agent, next_seg.agent_id)
|
||
if agent and not next_seg.runtime_snapshot:
|
||
snap = campaign.canvas_snapshot or {}
|
||
canvas_agent_ids = {int(node.get("agent_id")) for node in (snap.get("nodes", []) if isinstance(snap, dict) else [])
|
||
if str(node.get("agent_id", "")).isdigit()}
|
||
next_seg.runtime_snapshot = {"primary": _agent_runtime_snapshot(
|
||
s, agent, user=request_user(request), allow_agent_scope=bool(campaign.canvas_snapshot),
|
||
allowed_agent_ids=canvas_agent_ids)}
|
||
s.add(next_seg)
|
||
run = CampaignRun(campaign_id=cid, user_id=actor_fields(request)[1] or campaign.owner_user_id,
|
||
organization_id=actor_fields(request)[0] or campaign.organization_id,
|
||
status=("queued" if execution_config.is_dsh_backend() else "running"),
|
||
checkpoint={"mode": "step", "segment_ids": [next_seg.id]})
|
||
s.add(run); s.commit(); s.refresh(run)
|
||
campaign.status = "running"
|
||
s.add(campaign); s.commit()
|
||
snapshot = {"segment_id": next_seg.id, "node_id": next_seg.node_id,
|
||
"runtime": next_seg.runtime_snapshot or {}}
|
||
if execution_config.is_dsh_backend():
|
||
_enqueue_campaign_job(f"campaign-run:{run.id}",
|
||
{"run_id": run.id, "mode": "step", "segment_id": next_seg.id,
|
||
"campaign_id": cid})
|
||
return {"status": "queued", "queued": True, "accepted": True, "run_id": run.id,
|
||
"backend": "dsh",
|
||
"segment": {"id": next_seg.id, "title": next_seg.title, "agent_id": next_seg.agent_id}}
|
||
import threading as _threading
|
||
def _worker():
|
||
from app.db import engine as _engine
|
||
from sqlmodel import Session as _Session
|
||
from app.models import Segment as _Seg
|
||
with _Session(_engine) as session:
|
||
r = session.get(CampaignRun, run.id)
|
||
seg = session.get(_Seg, next_seg.id)
|
||
c = session.get(Campaign, cid)
|
||
if not r or not seg or not c:
|
||
return
|
||
try:
|
||
_progress.publish(int(c.owner_user_id or 0), "run_step_start", campaign_id=cid,
|
||
segment_id=next_seg.id, title=next_seg.title)
|
||
result = runner.execute_segment(session, seg, c, run_id=run.id)
|
||
if runner._supervisor_gate(session, seg, c, run.id) == "redo":
|
||
seg = session.get(_Seg, next_seg.id)
|
||
result = runner.execute_segment(session, seg, c, run_id=run.id)
|
||
r.checkpoint = dict(r.checkpoint or {}, last_result=str(result.get("status")))
|
||
r.status = "completed" if str(result.get("status")) in ("human_review", "rework", "approved") else "failed"
|
||
if str(result.get("status")) == "failed":
|
||
r.error = str(result.get("error") or "")[:300]
|
||
# 逐段模式收尾:任务回到 human_review 等确认(有失败段也如此,让用户决定重试/跳过)
|
||
all_segs = session.exec(select(Segment).where(Segment.campaign_id == cid)).all()
|
||
c.has_failure = any(x.status == "failed" for x in all_segs)
|
||
if c.status != "done":
|
||
c.status = "human_review"
|
||
session.add(r); session.add(c); session.commit()
|
||
except Exception as exc:
|
||
seg.status = "failed"
|
||
seg.failure = {"detail": str(exc)[:300]}
|
||
session.add(seg)
|
||
r.status = "failed"
|
||
r.error = str(exc)[:300]
|
||
c.status = "human_review"
|
||
session.add(r); session.add(c); session.commit()
|
||
_threading.Thread(target=_worker, daemon=True).start()
|
||
return {"status": "accepted", "accepted": True, "run_id": run.id,
|
||
"segment": {"id": next_seg.id, "title": next_seg.title, "agent_id": next_seg.agent_id}}
|
||
|
||
|
||
@app.post("/api/campaigns/{cid}/run")
|
||
def campaign_run(cid: int, s: Session = Depends(get_session), request: Request = None):
|
||
campaign = s.get(Campaign, cid)
|
||
if not campaign or not can_access(campaign, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "任务不存在")
|
||
if campaign.status == "done":
|
||
raise HTTPException(409, "任务已交付;需要修改内容时请创建新版本任务")
|
||
_require_current_plan_approved(s, campaign)
|
||
actor_org, actor_id = actor_fields(request)
|
||
campaign_segments = s.exec(select(Segment).where(Segment.campaign_id == cid)
|
||
.order_by(Segment.id)).all()
|
||
if not campaign_segments:
|
||
raise HTTPException(400, "没有内容段,请先拆解")
|
||
# Hermes 融合(交付报告 P1-1):启动前准备度检查,未就绪直接阻断并返回原因。
|
||
readiness = readiness_report(s, campaign)
|
||
if not readiness["ready"]:
|
||
raise HTTPException(409, "任务准备度检查未通过:" + ";".join(readiness["blockers"]))
|
||
# 点数预检:按成本预估 + 20% 缓冲核对余额,不够就不开跑(避免跑一半断粮)。
|
||
from .cost_estimate import estimate_campaign_cost
|
||
from .points_guard import points_preflight
|
||
estimate = estimate_campaign_cost(s, campaign)
|
||
preflight = points_preflight(s, campaign, int(estimate.get("estimated_points") or 0))
|
||
if not preflight["ok"]:
|
||
raise HTTPException(409, preflight["blocker"])
|
||
with _RUN_LOCK:
|
||
active = s.exec(select(CampaignRun).where(
|
||
CampaignRun.campaign_id == cid,
|
||
CampaignRun.status.in_(["queued", "running"]))).first()
|
||
if active:
|
||
return {"status": active.status, "accepted": False, "run_id": active.id,
|
||
"message": "任务已经在执行中"}
|
||
segment_snapshots = []
|
||
for segment in campaign_segments:
|
||
agent = s.get(Agent, segment.agent_id)
|
||
if agent and not segment.runtime_snapshot:
|
||
snap = campaign.canvas_snapshot or {}
|
||
canvas_agent_ids = {int(node.get("agent_id")) for node in (snap.get("nodes", []) if isinstance(snap, dict) else [])
|
||
if str(node.get("agent_id", "")).isdigit()}
|
||
segment.runtime_snapshot = {"primary": _agent_runtime_snapshot(
|
||
s, agent, user=request_user(request), allow_agent_scope=bool(campaign.canvas_snapshot),
|
||
allowed_agent_ids=canvas_agent_ids)}
|
||
s.add(segment)
|
||
segment_snapshots.append({"segment_id": segment.id, "node_id": segment.node_id,
|
||
"runtime": segment.runtime_snapshot or {}})
|
||
run = CampaignRun(campaign_id=cid, user_id=actor_id or campaign.owner_user_id,
|
||
organization_id=actor_org or campaign.organization_id,
|
||
status="queued", trace_id=new_trace_id(), runtime_version="v1",
|
||
config_snapshot={"canvas": campaign.canvas_snapshot or {},
|
||
"campaign_contract": {
|
||
"objective": campaign.objective or "按导图完成本次内容任务",
|
||
"success_criteria": campaign.success_criteria or "内容可审查、引用可溯源、跨段衔接一致",
|
||
"mindmap_hash": hashlib.sha256((campaign.mindmap_text or "").encode("utf-8")).hexdigest(),
|
||
},
|
||
"campaign_id": cid, "segments": segment_snapshots,
|
||
"stages": freeze_stage_snapshot(s)})
|
||
campaign.status = "running"
|
||
s.add(run); s.add(campaign); s.commit(); s.refresh(run)
|
||
_record_run_event(s, run, "run_queued", "queued")
|
||
s.commit()
|
||
_schedule_run(run.id)
|
||
return {"status": "queued", "accepted": True, "run_id": run.id}
|
||
|
||
@app.get("/api/campaigns/{cid}/runs")
|
||
def list_campaign_runs(cid: int, s: Session = Depends(get_session), request: Request = None):
|
||
campaign = s.get(Campaign, cid)
|
||
if not campaign or not can_access(campaign, request_user(request), "read"):
|
||
raise HTTPException(404, "任务不存在")
|
||
runs = s.exec(select(CampaignRun).where(CampaignRun.campaign_id == cid)
|
||
.order_by(CampaignRun.id.desc())).all()
|
||
return [{"id": r.id, "campaign_id": r.campaign_id, "status": r.status,
|
||
"cancel_requested": r.cancel_requested, "checkpoint": r.checkpoint,
|
||
"trace_id": r.trace_id, "runtime_version": r.runtime_version,
|
||
"error": r.error, "created_at": r.created_at, "started_at": r.started_at,
|
||
"finished_at": r.finished_at} for r in runs]
|
||
|
||
@app.get("/api/runs/{run_id}")
|
||
def get_campaign_run(run_id: int, s: Session = Depends(get_session), request: Request = None):
|
||
run = s.get(CampaignRun, run_id)
|
||
if not run:
|
||
raise HTTPException(404, "运行记录不存在")
|
||
campaign = s.get(Campaign, run.campaign_id)
|
||
if not campaign or not can_access(campaign, request_user(request), "read"):
|
||
raise HTTPException(404, "运行记录不存在")
|
||
events = s.exec(select(RunEvent).where(RunEvent.run_id == run_id)
|
||
.order_by(RunEvent.id)).all()
|
||
sessions = s.exec(select(AgentSession).where(AgentSession.run_id == run_id)
|
||
.order_by(AgentSession.id)).all()
|
||
return {"id": run.id, "campaign_id": run.campaign_id, "status": run.status,
|
||
"cancel_requested": run.cancel_requested, "checkpoint": run.checkpoint,
|
||
"config_snapshot": run.config_snapshot, "trace_id": run.trace_id,
|
||
"runtime_version": run.runtime_version, "error": run.error,
|
||
"created_at": run.created_at, "started_at": run.started_at,
|
||
"finished_at": run.finished_at,
|
||
"events": [{"id": e.id, "event_type": e.event_type, "status": e.status,
|
||
"segment_id": e.segment_id, "node_id": e.node_id,
|
||
"trace_id": e.trace_id, "span_id": e.span_id, "parent_span_id": e.parent_span_id,
|
||
"actor_agent_id": e.actor_agent_id, "actor_name": e.actor_name,
|
||
"tool_name": e.tool_name, "payload": e.payload, "created_at": e.created_at}
|
||
for e in events],
|
||
"sessions": [{"id": item.id, "agent_id": item.agent_id, "agent_name": item.agent_name,
|
||
"parent_session_id": item.parent_session_id, "phase": item.phase,
|
||
"status": item.status, "turn": item.turn, "max_turns": item.max_turns,
|
||
"error": item.error, "result_meta": item.result_meta,
|
||
"created_at": item.created_at, "updated_at": item.updated_at,
|
||
"finished_at": item.finished_at}
|
||
for item in sessions]}
|
||
|
||
@app.get("/api/agent-sessions/{session_id}")
|
||
def get_agent_session(session_id: int, s: Session = Depends(get_session), request: Request = None):
|
||
item = s.get(AgentSession, session_id)
|
||
if not item:
|
||
raise HTTPException(404, "Agent Runtime 会话不存在")
|
||
campaign = s.get(Campaign, item.campaign_id)
|
||
if not campaign or not can_access(campaign, request_user(request), "read"):
|
||
raise HTTPException(404, "Agent Runtime 会话不存在")
|
||
return {"id": item.id, "run_id": item.run_id, "campaign_id": item.campaign_id,
|
||
"segment_id": item.segment_id, "agent_id": item.agent_id, "agent_name": item.agent_name,
|
||
"parent_session_id": item.parent_session_id, "phase": item.phase, "trace_id": item.trace_id,
|
||
"status": item.status, "turn": item.turn, "max_turns": item.max_turns,
|
||
"tool_state": item.tool_state, "result_text": item.result_text,
|
||
"result_meta": item.result_meta, "error": item.error, "created_at": item.created_at,
|
||
"updated_at": item.updated_at, "finished_at": item.finished_at}
|
||
|
||
@app.post("/api/agent-sessions/{session_id}/tool-approval")
|
||
def decide_agent_tool(session_id: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
item = s.get(AgentSession, session_id)
|
||
campaign = s.get(Campaign, item.campaign_id) if item else None
|
||
if not item or not campaign or not can_access(campaign, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "Agent Runtime 会话不存在")
|
||
try:
|
||
item = set_tool_approval(s, session_id, bool(body.get("approved")))
|
||
except ValueError as exc:
|
||
raise HTTPException(409, str(exc)) from exc
|
||
return {"ok": True, "session_id": item.id, "status": item.status}
|
||
|
||
@app.post("/api/runs/{run_id}/cancel")
|
||
def cancel_campaign_run(run_id: int, s: Session = Depends(get_session), request: Request = None):
|
||
run = s.get(CampaignRun, run_id)
|
||
if not run:
|
||
raise HTTPException(404, "运行记录不存在")
|
||
campaign = s.get(Campaign, run.campaign_id)
|
||
if not campaign or not can_access(campaign, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "运行记录不存在")
|
||
if run.status in ("completed", "failed", "cancelled"):
|
||
return {"ok": True, "status": run.status}
|
||
if run.status in {"paused", "awaiting_review"}:
|
||
run.status = "cancelled"
|
||
run.cancel_requested = True
|
||
run.finished_at = now()
|
||
campaign.status = "human_review"
|
||
for runtime_session in s.exec(select(AgentSession).where(
|
||
AgentSession.run_id == run_id, AgentSession.status == "waiting_approval")).all():
|
||
runtime_session.status = "cancelled"
|
||
s.add(runtime_session)
|
||
s.add(run); s.add(campaign); s.commit()
|
||
return {"ok": True, "status": "cancelled"}
|
||
run.cancel_requested = True
|
||
run.checkpoint = {**(run.checkpoint or {}), "cancel_requested_at": now()}
|
||
s.add(run); s.commit()
|
||
return {"ok": True, "status": "cancelling"}
|
||
|
||
|
||
@app.post("/api/runs/{run_id}/pause")
|
||
def pause_campaign_run(run_id: int, s: Session = Depends(get_session), request: Request = None):
|
||
"""暂停:当前段跑完后停住,已完成段保留;用 resume 从断点继续。"""
|
||
run = s.get(CampaignRun, run_id)
|
||
if not run:
|
||
raise HTTPException(404, "运行记录不存在")
|
||
campaign = s.get(Campaign, run.campaign_id)
|
||
if not campaign or not can_access(campaign, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "运行记录不存在")
|
||
if run.status in ("completed", "failed", "cancelled", "paused"):
|
||
raise HTTPException(409, f"当前状态({run.status})不能暂停")
|
||
run.pause_requested = True
|
||
run.checkpoint = {**(run.checkpoint or {}), "pause_requested_at": now()}
|
||
s.add(run); s.commit()
|
||
return {"ok": True, "status": "pausing", "note": "正在进行的段完成后停止"}
|
||
|
||
@app.post("/api/runs/{run_id}/resume")
|
||
def resume_campaign_run(run_id: int, s: Session = Depends(get_session), request: Request = None):
|
||
run = s.get(CampaignRun, run_id)
|
||
if not run:
|
||
raise HTTPException(404, "运行记录不存在")
|
||
campaign = s.get(Campaign, run.campaign_id)
|
||
if not campaign or not can_access(campaign, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "运行记录不存在")
|
||
if (run.checkpoint or {}).get("segment_assignment_changed"):
|
||
raise HTTPException(409, "内容段已更换智能体,请从任务页重新生成下一段,旧运行仅供查看")
|
||
if run.status in ("queued", "running"):
|
||
return {"ok": True, "status": run.status, "run_id": run.id}
|
||
if run.status == "paused":
|
||
# 从暂停断点继续:清暂停标记,重新排队,只跑剩余 pending/rework/failed 段。
|
||
run.status = "queued"
|
||
run.pause_requested = False
|
||
run.error = ""
|
||
run.finished_at = ""
|
||
campaign.status = "running"
|
||
s.add(campaign); s.add(run); s.commit()
|
||
_schedule_run(run.id)
|
||
return {"ok": True, "status": "queued", "run_id": run.id}
|
||
if run.status == "awaiting_review":
|
||
resumable = s.exec(select(AgentSession).where(
|
||
AgentSession.run_id == run_id,
|
||
AgentSession.status.in_(["waiting_approval", "running"])
|
||
)).all()
|
||
if not resumable:
|
||
raise HTTPException(409, "该运行已完成生成并等待人工审查")
|
||
# 审批后的运行保持原内容段和原 Runtime session;runner 会从保存的
|
||
# tool call / message history 继续,而不是重新建立会话。
|
||
resumed_segment_ids = {item.segment_id for item in resumable if item.segment_id}
|
||
for segment_id in resumed_segment_ids:
|
||
segment = s.get(Segment, segment_id)
|
||
if segment:
|
||
segment.status = "pending"
|
||
s.add(segment)
|
||
run.status = "queued"
|
||
run.cancel_requested = False
|
||
run.error = ""
|
||
run.finished_at = ""
|
||
# 恢复会立即重新排队;同步任务主状态,避免列表仍显示旧的失败/待审查状态。
|
||
campaign.status = "running"
|
||
campaign.has_failure = False
|
||
s.add(campaign)
|
||
s.add(run); s.commit()
|
||
_schedule_run(run.id)
|
||
return {"ok": True, "status": "queued", "run_id": run.id}
|
||
|
||
# ---------- 审查与修复 ----------
|
||
@app.post("/api/segments/{sid}/review")
|
||
def segment_review(sid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
segment = s.get(Segment, sid)
|
||
campaign = s.get(Campaign, segment.campaign_id) if segment else None
|
||
if not segment or not campaign or not can_access(campaign, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "内容段不存在")
|
||
action = body.get("action", "")
|
||
opinion = str(body.get("opinion") or "")
|
||
kb_action = str(body.get("kb_action") or "keep") # keep=继续使用 | discard=不再使用
|
||
if action == "reject" and execution_config.is_dsh_backend():
|
||
active_redo = s.exec(select(TaskQueueItem).where(
|
||
TaskQueueItem.task_key == f"campaign-segment-redo:{sid}",
|
||
TaskQueueItem.status == "running")).first()
|
||
if active_redo:
|
||
raise HTTPException(409, "该内容段正在 DSH 中重做,请完成后再审查")
|
||
if action == "reject" and kb_action == "discard":
|
||
# 本段消费的知识永久排除(对该 Agent 而言)
|
||
from app.models import KnowledgeUsage as _KU
|
||
used = s.exec(select(_KU).where(_KU.segment_id == sid, _KU.status == "consumed")).all()
|
||
for u in used:
|
||
u.banned = True
|
||
s.add(u)
|
||
s.commit()
|
||
result = runner.human_review(s, sid, action, opinion)
|
||
if result.get("status") == "error":
|
||
raise HTTPException(409, result.get("error", "审核操作失败"))
|
||
# 打回 = 按意见重新生成这段。DSH 只入队;inline 由 API 后台线程执行。
|
||
if action == "reject":
|
||
if execution_config.is_dsh_backend():
|
||
job_key = f"campaign-segment-redo:{sid}"
|
||
_enqueue_campaign_job(job_key, {
|
||
"campaign_id": campaign.id,
|
||
"segment_id": sid,
|
||
"opinion": opinion.strip(),
|
||
"expected_agent_id": int(segment.agent_id or 0),
|
||
"expected_round": int(segment.round or 0),
|
||
}, update_existing=True)
|
||
result["regenerating"] = True
|
||
result["queued"] = True
|
||
result["backend"] = "dsh"
|
||
return result
|
||
import threading as _threading
|
||
expected_generation = (segment.agent_id, segment.round)
|
||
|
||
def _redo():
|
||
from app.db import engine as _engine
|
||
from sqlmodel import Session as _Session
|
||
import time as _t
|
||
_t.sleep(0.5)
|
||
with _Session(_engine) as session:
|
||
seg = session.get(Segment, sid)
|
||
camp = session.get(Campaign, segment.campaign_id)
|
||
if not seg or not camp:
|
||
return
|
||
try:
|
||
with _RUN_LOCK:
|
||
session.refresh(seg)
|
||
if (seg.agent_id, seg.round) != expected_generation:
|
||
return
|
||
seg.status = "running"
|
||
session.add(seg); session.commit()
|
||
_progress.publish(int(camp.owner_user_id or 0), "redo_start", campaign_id=camp.id,
|
||
segment_id=sid, round=seg.round, title=seg.title, reason="user")
|
||
runner.execute_segment(session, seg, camp,
|
||
auto_retry_qc=False, qc_issues=opinion.strip())
|
||
seg2 = session.get(Segment, sid)
|
||
_progress.publish(int(camp.owner_user_id or 0), "redo_done", campaign_id=camp.id,
|
||
segment_id=sid, status=(seg2.status if seg2 else "?"),
|
||
round=(seg.round if seg else 0),
|
||
title=(seg.title if seg else ""))
|
||
except Exception as exc:
|
||
seg = session.get(Segment, sid)
|
||
seg.status = "failed"
|
||
seg.failure = {"type": "redo_failed", "detail": str(exc)[:300]}
|
||
session.add(seg); session.commit()
|
||
|
||
_threading.Thread(target=_redo, daemon=True).start()
|
||
result["regenerating"] = True
|
||
return result
|
||
|
||
@app.post("/api/segments/{sid}/fix")
|
||
def segment_fix(sid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
segment = s.get(Segment, sid)
|
||
campaign = s.get(Campaign, segment.campaign_id) if segment else None
|
||
if not segment or not campaign or not can_access(campaign, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "内容段不存在")
|
||
if execution_config.is_dsh_backend():
|
||
active_fix = s.exec(select(TaskQueueItem).where(
|
||
TaskQueueItem.task_key == f"campaign-segment-fix:{sid}",
|
||
TaskQueueItem.status == "running")).first()
|
||
if active_fix:
|
||
return {"status": "running", "queued": True, "backend": "dsh",
|
||
"message": "DSH 正在执行该内容段修复"}
|
||
result = runner.fix_segment(s, sid, body, execute=False)
|
||
if result.get("status") == "error":
|
||
raise HTTPException(409, result.get("error", "修复操作失败"))
|
||
if result.get("queued"):
|
||
job_key = f"campaign-segment-fix:{sid}"
|
||
_enqueue_campaign_job(job_key, {
|
||
"campaign_id": campaign.id,
|
||
"segment_id": sid,
|
||
"fix": body,
|
||
"expected_agent_id": int(result.get("agent_id") or segment.agent_id or 0),
|
||
}, update_existing=True)
|
||
result["queued"] = True
|
||
result["backend"] = "dsh"
|
||
return result
|
||
result = runner.fix_segment(s, sid, body)
|
||
if result.get("status") == "error":
|
||
raise HTTPException(409, result.get("error", "修复操作失败"))
|
||
return result
|
||
|
||
# ---------- 组合 ----------
|
||
@app.post("/api/campaigns/{cid}/compose")
|
||
def campaign_compose(cid: int, s: Session = Depends(get_session), request: Request = None):
|
||
campaign = s.get(Campaign, cid)
|
||
if not campaign or not can_access(campaign, request_user(request), "manage", "agents:write", s):
|
||
raise HTTPException(404, "任务不存在")
|
||
if execution_config.is_dsh_backend() and campaign.status == "composing":
|
||
active_compose = s.exec(select(TaskQueueItem).where(
|
||
TaskQueueItem.task_key == f"campaign-compose:{cid}",
|
||
TaskQueueItem.status.in_(["pending", "running"]))).first()
|
||
if active_compose:
|
||
return {"status": "queued", "queued": True, "backend": "dsh", "message": "成稿组合中"}
|
||
campaign.status = "composing_ready"
|
||
s.add(campaign)
|
||
s.commit()
|
||
if execution_config.is_dsh_backend():
|
||
campaign.status = "composing"
|
||
s.add(campaign)
|
||
s.commit()
|
||
job_key = f"campaign-compose:{cid}"
|
||
_enqueue_campaign_job(job_key, {"campaign_id": cid}, update_existing=True)
|
||
return {"status": "queued", "queued": True, "backend": "dsh"}
|
||
r = compose(s, cid)
|
||
if r["status"] == "error":
|
||
raise HTTPException(400, r["error"])
|
||
return r
|
||
|
||
@app.get("/api/artifacts/{aid}/download")
|
||
def artifact_download(aid: int, s: Session = Depends(get_session), request: Request = None):
|
||
a = s.get(Artifact, aid)
|
||
if not a:
|
||
a = s.exec(select(Artifact).where(Artifact.campaign_id == aid)
|
||
.order_by(Artifact.id.desc())).first()
|
||
campaign = s.get(Campaign, a.campaign_id) if a else None
|
||
if not a or not campaign or not can_access(campaign, request_user(request), "read") or not Path(a.docx_path).exists():
|
||
raise HTTPException(404)
|
||
return FileResponse(a.docx_path, filename=Path(a.docx_path).name)
|
||
|
||
@app.get("/api/artifacts/{aid}/preview")
|
||
def artifact_preview(aid: int, s: Session = Depends(get_session), request: Request = None):
|
||
"""提取已交付 docx 的正文,供工作台内直接审阅。"""
|
||
artifact = s.get(Artifact, aid)
|
||
if not artifact:
|
||
artifact = s.exec(select(Artifact).where(Artifact.campaign_id == aid)
|
||
.order_by(Artifact.id.desc())).first()
|
||
campaign = s.get(Campaign, artifact.campaign_id) if artifact else None
|
||
if not artifact or not campaign or not can_access(campaign, request_user(request), "read") or not Path(artifact.docx_path).exists():
|
||
raise HTTPException(404, "成稿不存在")
|
||
try:
|
||
from docx import Document
|
||
document = Document(artifact.docx_path)
|
||
text = "\n".join(paragraph.text for paragraph in document.paragraphs if paragraph.text.strip())
|
||
except Exception as exc:
|
||
raise HTTPException(500, f"成稿读取失败: {exc}")
|
||
return {"artifact_id": artifact.id, "name": Path(artifact.docx_path).name,
|
||
"text": text[:100000], "characters": len(text), "truncated": len(text) > 100000}
|
||
|
||
# ---------- 用量与账单 ----------
|
||
def _usage_user(request: Request) -> AppUser:
|
||
user = request_user(request)
|
||
if not user:
|
||
raise HTTPException(401, "登录已过期")
|
||
return user
|
||
|
||
|
||
@app.get("/api/usage/summary")
|
||
def usage_summary_api(period: str = "monthly", service_type: str = "", request: Request = None,
|
||
s: Session = Depends(get_session)):
|
||
user = _usage_user(request)
|
||
if period not in {"daily", "monthly"}:
|
||
raise HTTPException(422, "period 必须是 daily 或 monthly")
|
||
events = events_for_period(s, user_id=user.id, organization_id=user.organization_id,
|
||
period=period, service_type=service_type)
|
||
return {"period": period, "user_id": user.id, "summary": usage_summary(events),
|
||
"points": point_state(s, user.id or 0, user.organization_id or 1)}
|
||
|
||
|
||
@app.get("/api/usage/events")
|
||
def usage_events_api(period: str = "monthly", service_type: str = "", status: str = "",
|
||
limit: int = 200, request: Request = None, s: Session = Depends(get_session)):
|
||
user = _usage_user(request)
|
||
if period not in {"daily", "monthly"}:
|
||
raise HTTPException(422, "period 必须是 daily 或 monthly")
|
||
rows = events_for_period(s, user_id=user.id, organization_id=user.organization_id,
|
||
period=period, service_type=service_type, status=status)
|
||
users = {u.id: u for u in s.exec(select(AppUser).where(AppUser.id.in_({r.user_id for r in rows}))).all()}
|
||
return [event_payload(row, users.get(row.user_id)) for row in rows[:max(1, min(limit, 500))]]
|
||
|
||
|
||
@app.get("/api/me/usage")
|
||
def me_usage(period: str = "monthly", service_type: str = "", request: Request = None,
|
||
s: Session = Depends(get_session)):
|
||
"""v6 文档约定的用户用量汇总别名。"""
|
||
return usage_summary_api(period=period, service_type=service_type, request=request, s=s)
|
||
|
||
|
||
@app.get("/api/me/billing")
|
||
def me_billing(period: str = "monthly", service_type: str = "", status: str = "",
|
||
limit: int = 200, request: Request = None, s: Session = Depends(get_session)):
|
||
user = _usage_user(request)
|
||
if period not in {"daily", "monthly"}:
|
||
raise HTTPException(422, "period 必须是 daily 或 monthly")
|
||
events = events_for_period(s, user_id=user.id, organization_id=user.organization_id,
|
||
period=period, service_type=service_type, status=status)
|
||
return {"period": period, "summary": usage_summary(events),
|
||
"events": [event_payload(row) for row in events[:max(1, min(limit, 500))]],
|
||
"points": point_state(s, user.id or 0, user.organization_id or 1)}
|
||
|
||
|
||
@app.get("/api/points/me")
|
||
def my_points(request: Request, s: Session = Depends(get_session)):
|
||
user = _usage_user(request)
|
||
return point_state(s, user.id or 0, user.organization_id or 1)
|
||
|
||
|
||
@app.get("/api/admin/usage/summary")
|
||
def admin_usage_summary(period: str = "monthly", user_id: int = 0, model: str = "", service_type: str = "",
|
||
request: Request = None, s: Session = Depends(get_session)):
|
||
if period not in {"daily", "monthly"}:
|
||
raise HTTPException(422, "period 必须是 daily 或 monthly")
|
||
organization_id = int(_tenant_user(request).organization_id or 1)
|
||
events = events_for_period(s, user_id=user_id or None, organization_id=organization_id,
|
||
period=period, service_type=service_type)
|
||
if model:
|
||
events = [event for event in events if event.model == model]
|
||
return {"period": period, "summary": usage_summary(events),
|
||
"users": usage_summary_by_user(s, events)}
|
||
|
||
|
||
@app.get("/api/admin/points/grant-tiers")
|
||
def get_grant_tiers():
|
||
"""点数发放档位目录(一档1千万/二档1亿/三档10亿)。"""
|
||
from .grant_limits import tier_catalog
|
||
return tier_catalog()
|
||
|
||
|
||
@app.get("/api/admin/points")
|
||
def admin_points(request: Request, s: Session = Depends(get_session)):
|
||
"""管理端查看本组织每位用户的点数余额。"""
|
||
organization_id = int(_tenant_user(request).organization_id or 1)
|
||
users = s.exec(select(AppUser).where(
|
||
AppUser.organization_id == organization_id).order_by(AppUser.id)).all()
|
||
rows = []
|
||
for user in users:
|
||
account = s.exec(select(PointAccount).where(
|
||
PointAccount.user_id == int(user.id or 0),
|
||
PointAccount.organization_id == organization_id)).first()
|
||
last_tx = s.exec(select(PointTransaction).where(
|
||
PointTransaction.user_id == int(user.id or 0),
|
||
PointTransaction.organization_id == organization_id
|
||
).order_by(PointTransaction.id.desc())).first()
|
||
rows.append({"user_id": user.id, "display_name": user.display_name or user.username,
|
||
"username": user.username, "updated_at": account.updated_at if account else "",
|
||
"last_change_at": last_tx.created_at if last_tx else "",
|
||
"last_change_reason": last_tx.reason if last_tx else "",
|
||
"status": "available" if point_state(s, user.id or 0, organization_id)["points"] >= POINTS_PER_TOKEN else "insufficient",
|
||
**point_state(s, user.id or 0, organization_id)})
|
||
return rows
|
||
|
||
|
||
@app.post("/api/admin/points/grant")
|
||
def admin_grant_points(body: dict, request: Request, s: Session = Depends(get_session)):
|
||
"""管理员为本组织用户增加点数;10 点数折算 1 Token。支持档位(tier=1/2/3)快捷发放。"""
|
||
operator = _tenant_user(request)
|
||
user_id = _body_int(body, "user_id", "成员编号")
|
||
target = s.get(AppUser, user_id)
|
||
if not target or target.organization_id != operator.organization_id:
|
||
raise HTTPException(404, "用户不存在")
|
||
points = _body_int(body, "points", "增加点数") if body.get("points") is not None else None
|
||
reason = str(body.get("reason") or "").strip()
|
||
if points is None:
|
||
# 未填具体点数时按档位发放
|
||
from .grant_limits import resolve_tier
|
||
tier_no = body.get("tier")
|
||
points = resolve_tier(tier_no)
|
||
if points is None:
|
||
raise HTTPException(422, "请填写点数,或选择有效档位(1=一档1千万,2=二档1亿,3=三档10亿)")
|
||
if not reason:
|
||
reason = {1: "一档发放(1千万点数)", 2: "二档发放(1亿点数)", 3: "三档发放(10亿点数)"}.get(int(tier_no or 0), "档位发放")
|
||
if not reason:
|
||
reason = "管理员增加点数"
|
||
if points <= 0:
|
||
raise HTTPException(422, "增加点数必须大于 0")
|
||
key = str(body.get("idempotency_key") or uuid.uuid4().hex)
|
||
try:
|
||
transaction = grant_points(
|
||
s, user_id=user_id, organization_id=operator.organization_id or 1,
|
||
points=points, reason=reason, operator_user_id=operator.id or 0,
|
||
idempotency_key=f"admin-credit:{key}", metadata={"operator": operator.display_name or operator.username},
|
||
)
|
||
s.commit()
|
||
return {"ok": True, "transaction_id": transaction.id,
|
||
"user_id": user_id, "points": point_state(s, user_id, target.organization_id or 1),
|
||
"transaction": {"delta_points": transaction.delta_points,
|
||
"reason": transaction.reason,
|
||
"created_at": transaction.created_at}}
|
||
except ValueError as exc:
|
||
raise HTTPException(422, str(exc))
|
||
|
||
|
||
def usage_summary_by_user(s: Session, events: list[UsageEvent]) -> list[dict]:
|
||
grouped: dict[int, list[UsageEvent]] = {}
|
||
for event in events:
|
||
grouped.setdefault(int(event.user_id or 0), []).append(event)
|
||
result = []
|
||
for uid, rows in grouped.items():
|
||
# Avoid exposing credentials; only display name is returned to administrators.
|
||
user = s.get(AppUser, uid) if uid else None
|
||
summary = usage_summary(rows)
|
||
result.append({"user_id": uid, "display_name": user.display_name if user else "系统任务",
|
||
"summary": summary})
|
||
return result
|
||
|
||
|
||
@app.get("/api/admin/usage/events")
|
||
def admin_usage_events(period: str = "monthly", user_id: int = 0, model: str = "",
|
||
service_type: str = "", status: str = "", limit: int = 500,
|
||
request: Request = None, s: Session = Depends(get_session)):
|
||
if period not in {"daily", "monthly"}:
|
||
raise HTTPException(422, "period 必须是 daily 或 monthly")
|
||
organization_id = int(_tenant_user(request).organization_id or 1)
|
||
rows = events_for_period(s, user_id=user_id or None, organization_id=organization_id, period=period,
|
||
service_type=service_type, status=status)
|
||
if model:
|
||
rows = [row for row in rows if row.model == model]
|
||
# Resolve the foreign key while the user still exists; system events use 0
|
||
# and are intentionally labelled rather than presented as an unknown user.
|
||
return [event_payload(row, s.get(AppUser, int(row.user_id or 0)) if row.user_id else None)
|
||
for row in rows[:max(1, min(limit, 1000))]]
|
||
|
||
|
||
@app.get("/api/admin/usage")
|
||
def admin_usage(period: str = "monthly", user_id: int = 0, model: str = "",
|
||
service_type: str = "", status: str = "", limit: int = 500,
|
||
request: Request = None, s: Session = Depends(get_session)):
|
||
"""v6 文档约定的管理端用量汇总与明细入口。"""
|
||
overview = admin_usage_summary(period=period, user_id=user_id, model=model,
|
||
service_type=service_type, request=request, s=s)
|
||
rows = admin_usage_events(period=period, user_id=user_id, model=model,
|
||
service_type=service_type, status=status, limit=limit, request=request, s=s)
|
||
return overview | {"events": rows}
|
||
|
||
|
||
@app.get("/api/admin/billing/settlements")
|
||
def billing_settlements(period: str = "monthly", request: Request = None, s: Session = Depends(get_session)):
|
||
if period not in {"daily", "monthly"}:
|
||
raise HTTPException(422, "period 必须是 daily 或 monthly")
|
||
organization_id = int(_tenant_user(request).organization_id or 1)
|
||
events = events_for_period(s, organization_id=organization_id, period=period)
|
||
grouped: dict[tuple[int, int], list[UsageEvent]] = {}
|
||
for event in events:
|
||
grouped.setdefault((int(event.organization_id or 1), int(event.user_id or 0)), []).append(event)
|
||
rows = []
|
||
for (organization_id, user_id), items in grouped.items():
|
||
user = s.get(AppUser, user_id) if user_id else None
|
||
rows.append({"organization_id": organization_id, "user_id": user_id,
|
||
"display_name": user.display_name if user else "系统任务",
|
||
"period": period, "summary": usage_summary(items),
|
||
"status": "ready" if all(item.status in {"measured", "settled", "estimated"} for item in items) else "review"})
|
||
rows.sort(key=lambda item: (item["organization_id"], item["user_id"]))
|
||
return rows
|
||
|
||
|
||
@app.post("/api/admin/billing/adjustments")
|
||
def billing_adjustment(body: dict, request: Request, s: Session = Depends(get_session)):
|
||
user_id = _body_int(body, "user_id", "成员编号")
|
||
user = s.get(AppUser, user_id)
|
||
if not user or user.organization_id != _tenant_user(request).organization_id:
|
||
raise HTTPException(404, "用户不存在")
|
||
amount = _body_int(body, "amount_minor", "调账金额")
|
||
reason = str(body.get("reason") or "").strip()
|
||
if amount == 0 or not reason:
|
||
raise HTTPException(422, "调账金额和原因不能为空")
|
||
key = str(body.get("idempotency_key") or uuid.uuid4().hex)
|
||
event = record_usage_event(s, idempotency_key=f"adjustment:{key}",
|
||
user_id=user_id, organization_id=user.organization_id,
|
||
service_type="adjustment", unit="request", quantity=1,
|
||
amount_minor_override=amount, status="settled",
|
||
metadata={"reason": reason, "operator": "admin"})
|
||
s.commit()
|
||
return event_payload(event)
|
||
|
||
|
||
@app.get("/api/admin/quotas")
|
||
def list_quotas(request: Request, s: Session = Depends(get_session)):
|
||
organization_id = int(_tenant_user(request).organization_id or 1)
|
||
users = {user.id: user for user in s.exec(select(AppUser).where(AppUser.organization_id == organization_id)).all()}
|
||
return [{"id": quota.id, "user_id": quota.user_id,
|
||
"display_name": users.get(quota.user_id).display_name if users.get(quota.user_id) else "",
|
||
"username": users.get(quota.user_id).username if users.get(quota.user_id) else "",
|
||
"organization_id": quota.organization_id, "period": quota.period,
|
||
"token_limit": quota.token_limit, "amount_limit_minor": quota.amount_limit_minor,
|
||
"storage_limit_bytes": quota.storage_limit_bytes, "concurrent_limit": quota.concurrent_limit,
|
||
"warning_ratio": quota.warning_ratio, "overage_policy": quota.overage_policy,
|
||
"enabled": quota.enabled, "updated_at": quota.updated_at}
|
||
for quota in s.exec(select(UsageQuota).where(UsageQuota.organization_id == organization_id).order_by(UsageQuota.user_id, UsageQuota.period)).all()]
|
||
|
||
|
||
@app.put("/api/admin/quotas/{user_id}")
|
||
def upsert_quota(user_id: int, body: dict, request: Request, s: Session = Depends(get_session)):
|
||
user = s.get(AppUser, user_id)
|
||
if not user or user.organization_id != _tenant_user(request).organization_id:
|
||
raise HTTPException(404, "用户不存在")
|
||
period = str(body.get("period") or "monthly")
|
||
if period not in {"daily", "monthly"}:
|
||
raise HTTPException(422, "period 必须是 daily 或 monthly")
|
||
quota = s.exec(select(UsageQuota).where(UsageQuota.user_id == user_id,
|
||
UsageQuota.organization_id == user.organization_id,
|
||
UsageQuota.period == period)).first()
|
||
quota = quota or UsageQuota(user_id=user_id, organization_id=user.organization_id, period=period)
|
||
field_labels = {"token_limit": "Token 上限", "amount_limit_minor": "金额上限", "storage_limit_bytes": "存储上限", "concurrent_limit": "并发上限"}
|
||
for field in ("token_limit", "amount_limit_minor", "storage_limit_bytes", "concurrent_limit"):
|
||
if field in body:
|
||
value = _body_int(body, field, field_labels[field])
|
||
if value < 0:
|
||
raise HTTPException(422, f"{field} 不能为负数")
|
||
setattr(quota, field, value)
|
||
if "warning_ratio" in body:
|
||
quota.warning_ratio = min(1.0, max(0.0, _body_float(body, "warning_ratio", "预警比例")))
|
||
if "overage_policy" in body:
|
||
policy = str(body.get("overage_policy") or "warn")
|
||
if policy not in {"warn", "soft_limit", "hard_limit"}:
|
||
raise HTTPException(422, "overage_policy 无效")
|
||
quota.overage_policy = policy
|
||
if "enabled" in body:
|
||
quota.enabled = bool(body.get("enabled"))
|
||
quota.updated_at = now()
|
||
s.add(quota); s.commit(); s.refresh(quota)
|
||
return {"ok": True, "id": quota.id}
|
||
|
||
|
||
@app.delete("/api/admin/quotas/{quota_id}")
|
||
def delete_quota(quota_id: int, request: Request, s: Session = Depends(get_session)):
|
||
quota = s.get(UsageQuota, quota_id)
|
||
if not quota or quota.organization_id != _tenant_user(request).organization_id:
|
||
raise HTTPException(404, "配额不存在")
|
||
s.delete(quota); s.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
# ---------- 日志 / 通知 / 记忆 ----------
|
||
@app.get("/api/llm_calls")
|
||
def list_llm_calls(campaign_id: int = 0, trace_id: str = "", status: str = "",
|
||
request: Request = None, s: Session = Depends(get_session)):
|
||
q = select(LLMCall)
|
||
user = request_user(request)
|
||
if user and user.role != "admin":
|
||
q = q.where(LLMCall.user_id == user.id)
|
||
if campaign_id:
|
||
q = q.where(LLMCall.campaign_id == campaign_id)
|
||
if trace_id:
|
||
q = q.where(LLMCall.trace_id == trace_id)
|
||
if status:
|
||
q = q.where(LLMCall.status == status)
|
||
q = q.order_by(LLMCall.id.desc()).limit(500)
|
||
return [{"id": c.id, "agent_name": c.agent_name, "provider": c.provider, "model": c.model,
|
||
"run_id": c.run_id, "trace_id": c.trace_id, "span_id": c.span_id,
|
||
"attempt": c.attempt, "status": c.status, "error": c.error, "prompt_tokens": c.prompt_tokens,
|
||
"completion_tokens": c.completion_tokens, "duration_ms": c.duration_ms,
|
||
"created_at": c.created_at, "segment_id": c.segment_id}
|
||
for c in s.exec(q).all()]
|
||
|
||
|
||
@app.get("/api/traces")
|
||
def list_traces(campaign_id: int = 0, status: str = "", limit: int = 100,
|
||
request: Request = None, s: Session = Depends(get_session)):
|
||
"""按 trace 聚合模型调用和 Runtime 事件,供运维从一次任务回放完整链路。"""
|
||
limit = max(1, min(limit, 300))
|
||
user = request_user(request)
|
||
runs = s.exec(select(CampaignRun).order_by(CampaignRun.id.desc())).all()
|
||
if user and user.role != "admin":
|
||
runs = [run for run in runs if run.user_id == user.id]
|
||
calls_query = select(LLMCall).where(LLMCall.trace_id != "").order_by(LLMCall.id.desc()).limit(3000)
|
||
if user and user.role != "admin":
|
||
calls_query = calls_query.where(LLMCall.user_id == user.id)
|
||
calls = s.exec(calls_query).all()
|
||
run_by_trace = {run.trace_id: run for run in runs if run.trace_id}
|
||
grouped: dict[str, list[LLMCall]] = {}
|
||
for call in calls:
|
||
grouped.setdefault(call.trace_id, []).append(call)
|
||
trace_ids = list(dict.fromkeys([*(run.trace_id for run in runs if run.trace_id), *grouped.keys()]))
|
||
rows = []
|
||
for trace_id in trace_ids:
|
||
run = run_by_trace.get(trace_id)
|
||
if campaign_id and (not run or run.campaign_id != campaign_id):
|
||
continue
|
||
if status and (not run or run.status != status):
|
||
continue
|
||
trace_calls = grouped.get(trace_id, [])
|
||
sessions = s.exec(select(AgentSession).where(AgentSession.trace_id == trace_id)).all()
|
||
events = s.exec(select(RunEvent).where(RunEvent.trace_id == trace_id)).all()
|
||
rows.append({
|
||
"trace_id": trace_id, "run_id": run.id if run else 0,
|
||
"campaign_id": run.campaign_id if run else (trace_calls[0].campaign_id if trace_calls else 0),
|
||
"run_status": run.status if run else "unlinked",
|
||
"created_at": run.created_at if run else (trace_calls[-1].created_at if trace_calls else ""),
|
||
"finished_at": run.finished_at if run else "",
|
||
"call_count": len(trace_calls), "error_count": sum(item.status != "ok" for item in trace_calls),
|
||
"prompt_tokens": sum(item.prompt_tokens for item in trace_calls),
|
||
"completion_tokens": sum(item.completion_tokens for item in trace_calls),
|
||
"duration_ms": sum(item.duration_ms for item in trace_calls),
|
||
"session_count": len(sessions), "event_count": len(events),
|
||
"agents": sorted({item.agent_name for item in trace_calls if item.agent_name}),
|
||
})
|
||
rows.sort(key=lambda item: (item["created_at"], item["trace_id"]), reverse=True)
|
||
# 服务端分页:offset/limit + total,前端禁止一次渲染全部
|
||
offset = request.query_params.get("offset") if request else None
|
||
try:
|
||
offset = max(0, int(offset or 0))
|
||
except (TypeError, ValueError):
|
||
offset = 0
|
||
total = len(rows)
|
||
return {"items": rows[offset:offset + limit], "total": total, "offset": offset, "limit": limit}
|
||
|
||
|
||
@app.get("/api/traces/{trace_id}")
|
||
def get_trace(trace_id: str, request: Request = None, s: Session = Depends(get_session)):
|
||
if not trace_id.strip():
|
||
raise HTTPException(404, "Trace 不存在")
|
||
run = s.exec(select(CampaignRun).where(CampaignRun.trace_id == trace_id)).first()
|
||
calls = s.exec(select(LLMCall).where(LLMCall.trace_id == trace_id).order_by(LLMCall.id)).all()
|
||
sessions = s.exec(select(AgentSession).where(AgentSession.trace_id == trace_id).order_by(AgentSession.id)).all()
|
||
events = s.exec(select(RunEvent).where(RunEvent.trace_id == trace_id).order_by(RunEvent.id)).all()
|
||
reports = s.exec(select(QCReport).where(QCReport.trace_id == trace_id).order_by(QCReport.id)).all()
|
||
artifacts = s.exec(select(Artifact).where(Artifact.trace_id == trace_id).order_by(Artifact.id)).all()
|
||
user = request_user(request)
|
||
if user and user.role != "admin":
|
||
if run and run.user_id != user.id:
|
||
raise HTTPException(404, "Trace 不存在")
|
||
if not run and not any(item.user_id == user.id for item in calls):
|
||
raise HTTPException(404, "Trace 不存在")
|
||
if not run and not calls and not sessions and not events:
|
||
raise HTTPException(404, "Trace 不存在")
|
||
return {
|
||
"trace_id": trace_id,
|
||
"run": {"id": run.id, "campaign_id": run.campaign_id, "status": run.status,
|
||
"runtime_version": run.runtime_version, "config_snapshot": run.config_snapshot,
|
||
"checkpoint": run.checkpoint, "error": run.error, "created_at": run.created_at,
|
||
"started_at": run.started_at, "finished_at": run.finished_at} if run else None,
|
||
"calls": [{"id": item.id, "run_id": item.run_id, "agent_name": item.agent_name,
|
||
"provider": item.provider, "model": item.model, "status": item.status,
|
||
"error": item.error, "span_id": item.span_id, "attempt": item.attempt,
|
||
"prompt_hash": item.prompt_hash, "output_hash": item.output_hash,
|
||
"prompt_tokens": item.prompt_tokens, "completion_tokens": item.completion_tokens,
|
||
"duration_ms": item.duration_ms, "created_at": item.created_at,
|
||
"segment_id": item.segment_id} for item in calls],
|
||
"sessions": [{"id": item.id, "agent_name": item.agent_name, "phase": item.phase,
|
||
"status": item.status, "turn": item.turn, "max_turns": item.max_turns,
|
||
"parent_session_id": item.parent_session_id, "error": item.error,
|
||
"created_at": item.created_at, "updated_at": item.updated_at,
|
||
"finished_at": item.finished_at} for item in sessions],
|
||
"events": [{"id": item.id, "event_type": item.event_type, "status": item.status,
|
||
"actor_name": item.actor_name, "tool_name": item.tool_name,
|
||
"segment_id": item.segment_id, "span_id": item.span_id,
|
||
"parent_span_id": item.parent_span_id, "payload": item.payload,
|
||
"created_at": item.created_at} for item in events],
|
||
"qc_reports": [{"id": item.id, "segment_id": item.segment_id, "gate": item.gate,
|
||
"verdict": item.verdict, "runtime_snapshot": item.runtime_snapshot,
|
||
"created_at": item.created_at} for item in reports],
|
||
"artifacts": [{"id": item.id, "final_status": item.final_status,
|
||
"runtime_snapshot": item.runtime_snapshot, "created_at": item.created_at}
|
||
for item in artifacts],
|
||
}
|
||
|
||
def _notification_visible(notification: Notification, user: AppUser, s: Session) -> bool:
|
||
segment = s.get(Segment, notification.ref_id)
|
||
campaign = s.get(Campaign, notification.ref_id)
|
||
if segment:
|
||
campaign = s.get(Campaign, segment.campaign_id)
|
||
if campaign:
|
||
return int(campaign.organization_id or 1) in {0, int(user.organization_id or 1)} and (
|
||
user.role == "admin" or int(campaign.owner_user_id or 0) == int(user.id or 0)
|
||
)
|
||
# 历史通知没有资源关联时只交给本租户管理员处理,普通成员不读取未知来源。
|
||
return user.role == "admin"
|
||
|
||
|
||
@app.get("/api/notifications")
|
||
def list_notifications(request: Request, s: Session = Depends(get_session)):
|
||
user = request_user(request)
|
||
return [{"id": n.id, "type": n.type, "ref_id": n.ref_id, "message": n.message,
|
||
"status": n.status, "created_at": n.created_at}
|
||
for n in s.exec(select(Notification).order_by(Notification.id.desc()).limit(100)).all()
|
||
if user and _notification_visible(n, user, s)][:50]
|
||
|
||
@app.post("/api/notifications/{nid}/read")
|
||
def read_notification(nid: int, request: Request, s: Session = Depends(get_session)):
|
||
n = s.get(Notification, nid)
|
||
user = request_user(request)
|
||
if n and user and _notification_visible(n, user, s):
|
||
n.status = "read"
|
||
s.add(n); s.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/notifications/read-all")
|
||
def read_all_notifications(request: Request, s: Session = Depends(get_session)):
|
||
"""一键全部已读:只标记当前用户可见的通知。"""
|
||
user = request_user(request)
|
||
if not user:
|
||
raise HTTPException(401, "请先登录")
|
||
marked = 0
|
||
for n in s.exec(select(Notification).where(Notification.status != "read")).all():
|
||
if _notification_visible(n, user, s):
|
||
n.status = "read"
|
||
s.add(n)
|
||
marked += 1
|
||
s.commit()
|
||
return {"ok": True, "marked": marked}
|
||
|
||
@app.get("/api/pending_memories")
|
||
def list_pending_memories(agent_id: int | None = None, memory_type: str | None = None,
|
||
s: Session = Depends(get_session), request: Request = None):
|
||
query = select(PendingMemory).where(PendingMemory.status == "pending")
|
||
if agent_id:
|
||
query = query.where(PendingMemory.target_agent_id == agent_id)
|
||
if memory_type:
|
||
query = query.where(PendingMemory.type == memory_type)
|
||
user = request_user(request)
|
||
visible_agents = {agent.id for agent in s.exec(select(Agent)).all()
|
||
if agent.id and can_access_agent(s, agent, user, "read")}
|
||
query = query.where(PendingMemory.target_agent_id.in_(visible_agents or {-1}))
|
||
rows = []
|
||
for m in s.exec(query.order_by(PendingMemory.id.desc())).all():
|
||
if can_access(m, user, "read"):
|
||
rows.append({"id": m.id, "target_agent_id": m.target_agent_id, "type": m.type,
|
||
"agent_name": _agent_name(s, m.target_agent_id), "content": m.content,
|
||
"source_ref": m.source_ref, "status": m.status,
|
||
"organization_id": m.organization_id, **owner_fields(s, m.owner_user_id),
|
||
"visibility": m.visibility, "created_at": m.created_at,
|
||
"can_manage": can_access(m, user, "manage", "agents:write", s)
|
||
and can_access(s.get(Agent, m.target_agent_id), user, "manage", "agents:write", s)})
|
||
return rows
|
||
|
||
@app.post("/api/pending_memories")
|
||
def create_pending_memory(body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
try:
|
||
agent_id = int(body.get("agent_id") or 0)
|
||
except (ValueError, TypeError) as exc:
|
||
raise HTTPException(422, "请选择有效的智能体") from exc
|
||
agent = s.get(Agent, agent_id)
|
||
if not agent or not can_access_agent(s, agent, request_user(request), "manage"):
|
||
raise HTTPException(404, "智能体不存在或没有管理权限")
|
||
content = str(body.get("content") or "").strip()
|
||
layer = str(body.get("type") or "L3").strip()
|
||
if not content or len(content) > 6000:
|
||
raise HTTPException(422, "记忆正文需为 1–6000 个字符")
|
||
if layer not in {"L1", "L2", "L3", "L4"}:
|
||
raise HTTPException(422, "请选择规则、纠错、偏好或成功经验")
|
||
organization_id, owner_user_id = actor_fields(request)
|
||
existing = s.exec(select(PendingMemory).where(
|
||
PendingMemory.target_agent_id == agent_id, PendingMemory.type == layer,
|
||
PendingMemory.content == content, PendingMemory.status == "pending",
|
||
PendingMemory.organization_id == organization_id,
|
||
PendingMemory.owner_user_id == owner_user_id, PendingMemory.visibility == "private")).first()
|
||
if existing:
|
||
return {"ok": True, "id": existing.id, "status": "pending"}
|
||
candidate = PendingMemory(target_agent_id=agent_id, type=layer, content=content,
|
||
source_ref=str(body.get("source_ref") or "人工补充").strip()[:500],
|
||
organization_id=organization_id, owner_user_id=owner_user_id,
|
||
visibility="private")
|
||
s.add(candidate); s.commit(); s.refresh(candidate)
|
||
return {"ok": True, "id": candidate.id, "status": candidate.status}
|
||
|
||
|
||
@app.post("/api/pending_memories/{mid}")
|
||
def decide_memory(mid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
m = s.get(PendingMemory, mid)
|
||
if not m:
|
||
raise HTTPException(404, "待确认记忆不存在")
|
||
user = request_user(request)
|
||
target_agent = s.get(Agent, m.target_agent_id)
|
||
if not target_agent or not can_access(target_agent, user, "manage", "agents:write", s) \
|
||
or not can_access(m, user, "manage", "agents:write", s):
|
||
raise HTTPException(404, "待确认记忆不存在")
|
||
action = body.get("action", "approve")
|
||
if action not in {"approve", "reject", "block"}:
|
||
raise HTTPException(422, "记忆操作只能是确认、拒绝或屏蔽")
|
||
if m.status != "pending":
|
||
raise HTTPException(409, "这条记忆已处理,请刷新列表")
|
||
existing = None
|
||
replaced = None
|
||
if action == "approve":
|
||
content = str(body.get("content", m.content) or "").strip()
|
||
layer = str(body.get("type", m.type) or "").strip()
|
||
if not content or len(content) > 6000:
|
||
raise HTTPException(422, "记忆正文需为 1–6000 个字符,请提炼成可复用的规则或经验")
|
||
if layer not in {"L1", "L2", "L3", "L4"}:
|
||
raise HTTPException(422, "请选择规则、纠错、偏好或成功经验")
|
||
if int(target_agent.organization_id) not in {0, int(m.organization_id)}:
|
||
raise HTTPException(422, "记忆与智能体不属于同一组织")
|
||
tags = body.get("tags", [])
|
||
if not isinstance(tags, list):
|
||
raise HTTPException(422, "标签必须是数组")
|
||
try:
|
||
replace_id = int(body.get("replace_memory_id") or 0)
|
||
except (ValueError, TypeError) as exc:
|
||
raise HTTPException(422, "替代记忆编号无效") from exc
|
||
if replace_id:
|
||
replaced = require_memory(s, replace_id, request, "manage")
|
||
if (replaced.agent_id != m.target_agent_id or replaced.organization_id != m.organization_id
|
||
or replaced.owner_user_id != m.owner_user_id or replaced.visibility != m.visibility):
|
||
raise HTTPException(422, "只能替代同一智能体、所有者及可见范围的记忆")
|
||
if replaced.status != "active":
|
||
raise HTTPException(409, "旧记忆已失效,请刷新后选择")
|
||
if "tags" not in body:
|
||
tags = list(replaced.tags or [])
|
||
existing = s.exec(select(Memory).where(
|
||
Memory.agent_id == m.target_agent_id, Memory.layer == layer,
|
||
Memory.content == content, Memory.status == "active",
|
||
Memory.organization_id == m.organization_id,
|
||
Memory.owner_user_id == m.owner_user_id,
|
||
Memory.visibility == m.visibility)).first()
|
||
if replaced and existing:
|
||
raise HTTPException(409, "已有相同的生效记忆,请修改内容或取消替代")
|
||
# 先按 pending 状态原子认领,避免并发确认创建重复记忆。
|
||
next_status = {"approve": "approved", "reject": "rejected", "block": "blocked"}[action]
|
||
result = s.execute(update(PendingMemory).where(
|
||
PendingMemory.id == mid, PendingMemory.status == "pending"
|
||
).values(status=next_status).execution_options(synchronize_session=False))
|
||
if result.rowcount != 1:
|
||
s.rollback()
|
||
raise HTTPException(409, "这条记忆已处理,请刷新列表")
|
||
if action == "approve":
|
||
# 认领已取得写锁;再次检查其他候选刚确认的同范围记忆。
|
||
if not replaced:
|
||
existing = s.exec(select(Memory).where(
|
||
Memory.agent_id == m.target_agent_id, Memory.layer == layer,
|
||
Memory.content == content, Memory.status == "active",
|
||
Memory.organization_id == m.organization_id,
|
||
Memory.owner_user_id == m.owner_user_id,
|
||
Memory.visibility == m.visibility)).first()
|
||
if replaced:
|
||
changed = s.execute(update(Memory).where(
|
||
Memory.id == replaced.id, Memory.status == "active"
|
||
).values(status="expired").execution_options(synchronize_session=False))
|
||
if changed.rowcount != 1:
|
||
s.rollback()
|
||
raise HTTPException(409, "旧记忆已变更,请刷新后重试")
|
||
if not existing:
|
||
existing = Memory(
|
||
agent_id=m.target_agent_id, layer=layer, content=content,
|
||
title=str(body.get("title") or content.splitlines()[0][:80]).strip()[:500],
|
||
description=str(body.get("description") or "人工确认后沉淀的智能体记忆").strip()[:500],
|
||
source_ref=str(body.get("source_ref", m.source_ref) or "").strip()[:500],
|
||
privacy=replaced.privacy if replaced else "internal",
|
||
tags=[str(item).strip()[:100] for item in tags if str(item).strip()][:20],
|
||
timestamp=now(), organization_id=m.organization_id,
|
||
owner_user_id=m.owner_user_id, visibility=m.visibility or "private",
|
||
supersedes_id=int(replaced.id) if replaced else 0)
|
||
s.add(existing)
|
||
if replaced:
|
||
s.flush()
|
||
for grant in s.exec(select(MemoryGrant).where(MemoryGrant.memory_id == replaced.id)).all():
|
||
s.add(MemoryGrant(memory_id=existing.id, user_id=grant.user_id,
|
||
permission=grant.permission, enabled=grant.enabled))
|
||
s.commit()
|
||
s.refresh(m)
|
||
if existing:
|
||
s.refresh(existing)
|
||
return {"ok": True, "memory_id": existing.id if existing else None,
|
||
"replaced_memory_id": replaced.id if replaced else None}
|
||
|
||
@app.get("/api/memories")
|
||
def list_memories(agent_id: int | None = None, status: str | None = None,
|
||
memory_type: str | None = None, q: str | None = None,
|
||
s: Session = Depends(get_session), request: Request = None):
|
||
allowed = visible_memory_ids(s, request, "read")
|
||
manageable = visible_memory_ids(s, request, "manage")
|
||
query = select(Memory).where(Memory.id.in_(allowed or {-1}))
|
||
if agent_id:
|
||
query = query.where(Memory.agent_id == agent_id)
|
||
if status:
|
||
query = query.where(Memory.status == status)
|
||
if memory_type:
|
||
query = query.where(Memory.layer == memory_type)
|
||
rows = s.exec(query.order_by(Memory.id.desc())).all()
|
||
needle = str(q or "").strip().lower()
|
||
return [{"id": m.id, "agent_id": m.agent_id, "agent_name": _agent_name(s, m.agent_id),
|
||
"type": m.layer, "title": m.title, "description": m.description,
|
||
"content": m.content, "source_ref": m.source_ref, "privacy": m.privacy,
|
||
"tags": list(m.tags or []), "timestamp": m.timestamp,
|
||
"index_status": m.index_status, "usage_count": m.usage_count,
|
||
"last_used_at": m.last_used_at, "supersedes_id": m.supersedes_id,
|
||
"can_manage": m.id in manageable,
|
||
"status": m.status, "organization_id": m.organization_id,
|
||
**owner_fields(s, m.owner_user_id), "visibility": m.visibility,
|
||
"created_at": m.created_at}
|
||
for m in rows if not needle or needle in f"{m.title} {m.description} {m.content} {m.source_ref} {' '.join(m.tags or [])}".lower()]
|
||
|
||
@app.put("/api/memories/{mid}")
|
||
def update_memory(mid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
source = s.get(Memory, mid)
|
||
revision = _global_revision(s, "memory", source, body, request) if source else None
|
||
if revision:
|
||
return {"ok": True, "pending": True, "revision_id": revision.id, "version": revision.version}
|
||
memory = require_memory(s, mid, request, "manage")
|
||
for field in ("title", "description", "source_ref"):
|
||
if field in body:
|
||
setattr(memory, field, str(body.get(field) or "").strip()[:500])
|
||
if "tags" in body:
|
||
if not isinstance(body.get("tags"), list):
|
||
raise HTTPException(422, "标签必须是数组")
|
||
memory.tags = [str(item).strip() for item in body.get("tags") if str(item).strip()][:20]
|
||
if "privacy" in body:
|
||
privacy = str(body.get("privacy") or "internal").strip().lower()
|
||
if privacy not in {"internal", "public"}:
|
||
raise HTTPException(422, "隐私范围只能是内部或公开")
|
||
memory.privacy = privacy
|
||
if "status" in body:
|
||
status = str(body.get("status") or "active").strip().lower()
|
||
if status not in {"active", "expired"}:
|
||
raise HTTPException(422, "记忆状态只能是已生效或已过期")
|
||
if status == "active" and memory.status != "active":
|
||
frontier, visited = {mid}, set()
|
||
while frontier:
|
||
descendants = s.exec(select(Memory).where(Memory.supersedes_id.in_(frontier))).all()
|
||
if any(item.status == "active" for item in descendants):
|
||
raise HTTPException(409, "这条记忆已被新记忆替代,请先停用对应的新记忆")
|
||
visited.update(frontier)
|
||
frontier = {item.id for item in descendants if item.id not in visited}
|
||
memory.status = status
|
||
memory.index_status = "indexed"
|
||
s.add(memory); s.commit(); s.refresh(memory)
|
||
_record_fork_revision(s, "memory", mid, request)
|
||
return {"ok": True, "id": memory.id, "index_status": memory.index_status}
|
||
|
||
@app.delete("/api/memories/{mid}")
|
||
def delete_memory(mid: int, s: Session = Depends(get_session), request: Request = None):
|
||
source = s.get(Memory, mid)
|
||
revision = _global_revision(s, "memory", source, {}, request, "delete") if source else None
|
||
if revision:
|
||
return {"ok": True, "pending": True, "revision_id": revision.id}
|
||
memory = require_memory(s, mid, request, "manage")
|
||
_detach_memory_bindings(s, mid)
|
||
_cleanup_fork(s, "memory", mid)
|
||
s.delete(memory); s.commit()
|
||
return {"ok": True}
|
||
|
||
@app.post("/api/memories/batch-delete")
|
||
def batch_delete_memories(body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
ids = {int(item) for item in (body.get("ids") or []) if str(item).isdigit()}
|
||
allowed = visible_memory_ids(s, request, "manage")
|
||
deleted = 0
|
||
for memory in s.exec(select(Memory).where(Memory.id.in_(ids & allowed))).all():
|
||
for grant in s.exec(select(MemoryGrant).where(MemoryGrant.memory_id == memory.id)).all():
|
||
s.delete(grant)
|
||
s.delete(memory); deleted += 1
|
||
s.commit()
|
||
return {"ok": True, "deleted": deleted}
|
||
|
||
@app.delete("/api/pending_memories/{mid}")
|
||
def delete_pending_memory(mid: int, s: Session = Depends(get_session), request: Request = None):
|
||
memory = s.get(PendingMemory, mid)
|
||
if not memory:
|
||
raise HTTPException(404, "待确认记忆不存在")
|
||
target_agent = s.get(Agent, memory.target_agent_id)
|
||
if not target_agent or not can_access(target_agent, request_user(request), "manage", "agents:write", s) \
|
||
or not can_access(memory, request_user(request), "manage", "memories:write", s):
|
||
raise HTTPException(404, "待确认记忆不存在")
|
||
s.delete(memory); s.commit()
|
||
return {"ok": True}
|
||
|
||
@app.post("/api/pending_memories/batch-delete")
|
||
def batch_delete_pending_memories(body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
ids = {int(item) for item in (body.get("ids") or []) if str(item).isdigit()}
|
||
deleted = 0
|
||
for memory in s.exec(select(PendingMemory).where(PendingMemory.id.in_(ids))).all():
|
||
target_agent = s.get(Agent, memory.target_agent_id)
|
||
if not target_agent or not can_access(target_agent, request_user(request), "manage", "agents:write", s) \
|
||
or not can_access(memory, request_user(request), "manage", "memories:write", s):
|
||
continue
|
||
s.delete(memory); deleted += 1
|
||
s.commit()
|
||
return {"ok": True, "deleted": deleted}
|
||
|
||
|
||
@app.get("/api/admin/memories")
|
||
def list_admin_memories(s: Session = Depends(get_session), request: Request = None):
|
||
organization_id = int(_tenant_user(request).organization_id or 1) if request else None
|
||
rows = []
|
||
query = select(Memory).order_by(Memory.id.desc())
|
||
if organization_id is not None:
|
||
query = query.where(Memory.organization_id.in_([0, organization_id]))
|
||
for memory in s.exec(query).all():
|
||
grants = s.exec(select(MemoryGrant).where(
|
||
MemoryGrant.memory_id == memory.id).order_by(MemoryGrant.id)).all()
|
||
if organization_id is not None:
|
||
grants = [grant for grant in grants if _grantable_user(s.get(AppUser, grant.user_id)) and
|
||
s.get(AppUser, grant.user_id).organization_id == organization_id]
|
||
else:
|
||
grants = [grant for grant in grants if _grantable_user(s.get(AppUser, grant.user_id))]
|
||
rows.append({"id": memory.id, "agent_id": memory.agent_id,
|
||
"agent_name": _agent_name(s, memory.agent_id), "layer": memory.layer,
|
||
"title": memory.title, "description": memory.description,
|
||
"content": memory.content, "source_ref": memory.source_ref,
|
||
"privacy": memory.privacy, "tags": list(memory.tags or []),
|
||
"timestamp": memory.timestamp, "index_status": memory.index_status,
|
||
"usage_count": memory.usage_count,
|
||
"status": memory.status, "organization_id": memory.organization_id,
|
||
**owner_fields(s, memory.owner_user_id), "visibility": memory.visibility,
|
||
"grants": [_grant_payload(item, s.get(AppUser, item.user_id)) for item in grants]})
|
||
return rows
|
||
|
||
|
||
@app.post("/api/admin/memories/{mid}/grants")
|
||
def grant_memory(mid: int, body: dict, s: Session = Depends(get_session), request: Request = None):
|
||
memory = s.get(Memory, mid)
|
||
if not memory or not _same_tenant(memory, _tenant_user(request)):
|
||
raise HTTPException(404, "记忆不存在")
|
||
try:
|
||
user_id = int(body.get("user_id"))
|
||
except (TypeError, ValueError) as exc:
|
||
raise HTTPException(422, "user_id 必须是有效用户") from exc
|
||
user = s.get(AppUser, user_id)
|
||
actor = _tenant_user(request)
|
||
if not _grantable_user(user, actor):
|
||
raise HTTPException(404, "用户不存在或已停用")
|
||
permission = str(body.get("permission") or "use").strip().lower()
|
||
if permission not in _GRANT_PERMISSIONS:
|
||
raise HTTPException(422, "permission 必须是 read、use、manage 或 export")
|
||
grant = s.exec(select(MemoryGrant).where(
|
||
MemoryGrant.memory_id == mid, MemoryGrant.user_id == user_id)).first()
|
||
if not grant:
|
||
grant = MemoryGrant(memory_id=mid, user_id=user_id, permission=permission)
|
||
else:
|
||
grant.permission = permission
|
||
grant.enabled = True
|
||
if memory.visibility != "global":
|
||
memory.visibility = "shared"
|
||
s.add(grant); s.add(memory); s.commit(); s.refresh(grant)
|
||
return _grant_payload(grant, user)
|
||
|
||
|
||
@app.delete("/api/admin/memories/{mid}/grants/{grant_id}")
|
||
def revoke_memory(mid: int, grant_id: int, s: Session = Depends(get_session), request: Request = None):
|
||
memory = s.get(Memory, mid)
|
||
grant = s.get(MemoryGrant, grant_id)
|
||
if not memory or not _same_tenant(memory, _tenant_user(request)) or not grant or grant.memory_id != mid:
|
||
raise HTTPException(404, "授权记录不存在")
|
||
s.delete(grant); s.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@app.post("/api/admin/memories/{mid}/publish-global")
|
||
def publish_memory_global(mid: int, s: Session = Depends(get_session), request: Request = None):
|
||
memory = s.get(Memory, mid)
|
||
if not memory or not _same_tenant(memory, _tenant_user(request)):
|
||
raise HTTPException(404, "记忆不存在")
|
||
memory.visibility = "global"
|
||
s.add(memory); s.commit()
|
||
return {"ok": True, "visibility": memory.visibility}
|
||
|
||
@app.get("/api/admin/memories/{mid}/export")
|
||
def export_memory(mid: int, s: Session = Depends(get_session), request: Request = None):
|
||
memory = s.get(Memory, mid)
|
||
if not memory or not _same_tenant(memory, _tenant_user(request)):
|
||
raise HTTPException(404, "记忆不存在")
|
||
_deny_global_user_export(memory, request, s)
|
||
return JSONResponse({"id": memory.id, "agent_id": memory.agent_id,
|
||
"agent_name": _agent_name(s, memory.agent_id), "layer": memory.layer,
|
||
"title": memory.title, "description": memory.description,
|
||
"content": memory.content, "source_ref": memory.source_ref,
|
||
"privacy": memory.privacy, "tags": list(memory.tags or []),
|
||
"timestamp": memory.timestamp, "index_status": memory.index_status,
|
||
"usage_count": memory.usage_count,
|
||
"status": memory.status, "visibility": memory.visibility,
|
||
**owner_fields(s, memory.owner_user_id), "created_at": memory.created_at},
|
||
headers={"Content-Disposition": f'attachment; filename="memory-{mid}.json"'})
|
||
|
||
# ---------------- 生产/一体化部署:后端直接托管前端构建产物 ----------------
|
||
# 必须放在全部 API 路由之后挂载,否则 mount("/") 会遮蔽 API。
|
||
# API 路由优先匹配;非 /api 的未知路径回退 index.html(SPA 路由)。
|
||
_WEB_DIST = Path(__file__).resolve().parent.parent.parent / "web" / "dist"
|
||
if _WEB_DIST.is_dir() and (_WEB_DIST / "index.html").is_file():
|
||
from fastapi.staticfiles import StaticFiles
|
||
|
||
class _SPAStaticFiles(StaticFiles):
|
||
"""SPA 路由兜底:/admin 等前端路由回退到 index.html。"""
|
||
|
||
async def get_response(self, path: str, scope):
|
||
# 注意必须捕获 starlette 的 HTTPException 基类:
|
||
# StaticFiles 抛的是基类,FastAPI 的 HTTPException 是它的子类。
|
||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||
try:
|
||
response = await super().get_response(path, scope)
|
||
except StarletteHTTPException as exc:
|
||
if exc.status_code == 404:
|
||
response = await super().get_response("index.html", scope)
|
||
# SPA 路由必须每次重新协商入口页,否则公网浏览器可能继续执行旧
|
||
# 的页面脚本(例如已修复的画布删除/复制竞态)。带 hash 的 assets
|
||
# 仍由浏览器正常缓存,不影响静态资源性能。
|
||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||
response.headers["Pragma"] = "no-cache"
|
||
return response
|
||
raise
|
||
if path == "index.html" or not Path(path).suffix:
|
||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||
response.headers["Pragma"] = "no-cache"
|
||
return response
|
||
|
||
# Mounted after API declarations below so /api/events/progress is not swallowed by SPA fallback.
|
||
|
||
|
||
@app.get("/api/events/progress")
|
||
async def progress_stream(request: Request):
|
||
"""SSE 实时进度流。任务生成/质检/打回/上传全状态。"""
|
||
user = request_user(request)
|
||
uid = int(user.get("id") or 0) if isinstance(user, dict) else 0
|
||
_progress.register(uid)
|
||
|
||
async def gen():
|
||
nl = chr(10)
|
||
last_ts = time.time()
|
||
try:
|
||
# 连接成功后,通过 snapshot 同时读取 API 内存事件与 DSH 持久化事件。
|
||
yield f"data: {json.dumps({'event': 'connected', 'ts': time.time()})}{nl}{nl}"
|
||
last_beat = time.time()
|
||
while True:
|
||
try:
|
||
sent = 0
|
||
for ev in _progress.snapshot(uid, last_ts, limit=200):
|
||
sent += 1
|
||
last_ts = max(last_ts, float(ev.get("ts") or last_ts))
|
||
yield f"data: {json.dumps(ev, ensure_ascii=False)}{nl}{nl}"
|
||
if sent:
|
||
last_beat = time.time()
|
||
elif time.time() - last_beat > 15:
|
||
yield f": keep-alive{nl}{nl}"
|
||
last_beat = time.time()
|
||
await asyncio.sleep(0.7)
|
||
except asyncio.CancelledError:
|
||
break
|
||
finally:
|
||
_progress.unregister(uid)
|
||
|
||
return StreamingResponse(gen(), media_type="text/event-stream",
|
||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
||
|
||
|
||
@app.get("/api/events/progress-poll")
|
||
def progress_poll(after: float = 0.0, request: Request = None):
|
||
"""轮询备用通道(SSE 不可用时)。"""
|
||
user = request_user(request)
|
||
uid = int(user.get("id") or 0) if isinstance(user, dict) else 0
|
||
return {"events": _progress.snapshot(uid, after)}
|
||
|
||
|
||
# SPA fallback must be registered last; Starlette matches mounts in declaration order.
|
||
if _WEB_DIST.is_dir() and (_WEB_DIST / "index.html").is_file():
|
||
app.mount("/", _SPAStaticFiles(directory=str(_WEB_DIST), html=True), name="web")
|