Files

7718 lines
412 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")