"""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")